import { describe, test, expect, beforeEach, mock } from 'bun:test'; import { testDb as db } from '$lib/server/db/test'; import { dailyVerses, dailyCompletions } from '$lib/server/db/schema'; import { eq } from 'drizzle-orm'; import crypto from 'node:crypto'; describe('Timezone-aware daily verse system', () => { beforeEach(async () => { // Clean up test data before each test await db.delete(dailyVerses); await db.delete(dailyCompletions); }); describe('Daily verse retrieval', () => { test('users in different timezones can see different verses at the same UTC moment', async () => { // Simulate: It's 2024-01-15 23:00 UTC // - Tokyo (UTC+9): 2024-01-16 08:00 // - New York (UTC-5): 2024-01-15 18:00 const tokyoDate = '2024-01-16'; const newYorkDate = '2024-01-15'; // Create verses for both dates const tokyoVerse = { id: crypto.randomUUID(), date: tokyoDate, bookId: 'GEN', verseText: 'Tokyo verse', reference: 'Genesis 1:1', }; const newYorkVerse = { id: crypto.randomUUID(), date: newYorkDate, bookId: 'EXO', verseText: 'New York verse', reference: 'Exodus 1:1', }; await db.insert(dailyVerses).values([tokyoVerse, newYorkVerse]); // Verify Tokyo user gets Jan 16 verse const tokyoResult = await db .select() .from(dailyVerses) .where(eq(dailyVerses.date, tokyoDate)) .limit(1); expect(tokyoResult).toHaveLength(1); expect(tokyoResult[0].bookId).toBe('GEN'); expect(tokyoResult[0].verseText).toBe('Tokyo verse'); // Verify New York user gets Jan 15 verse const newYorkResult = await db .select() .from(dailyVerses) .where(eq(dailyVerses.date, newYorkDate)) .limit(1); expect(newYorkResult).toHaveLength(1); expect(newYorkResult[0].bookId).toBe('EXO'); expect(newYorkResult[0].verseText).toBe('New York verse'); }); test('verse dates are stored in YYYY-MM-DD format', async () => { const verse = { id: crypto.randomUUID(), date: '2024-01-15', bookId: 'GEN', verseText: 'Test verse', reference: 'Genesis 1:1', }; await db.insert(dailyVerses).values(verse); const result = await db .select() .from(dailyVerses) .where(eq(dailyVerses.id, verse.id)) .limit(1); expect(result[0].date).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); }); describe('Completion tracking', () => { test('completions are stored with user local date', async () => { const userId = 'test-user-1'; const localDate = '2024-01-16'; // User's local date const completion = { id: crypto.randomUUID(), anonymousId: userId, date: localDate, guessCount: 3, completedAt: new Date(), }; await db.insert(dailyCompletions).values(completion); const result = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)) .limit(1); expect(result).toHaveLength(1); expect(result[0].date).toBe(localDate); }); test('users in different timezones can complete different date verses simultaneously', async () => { const tokyoUser = 'tokyo-user'; const newYorkUser = 'newyork-user'; const completions = [ { id: crypto.randomUUID(), anonymousId: tokyoUser, date: '2024-01-16', // Tokyo: Jan 16 guessCount: 2, completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC }, { id: crypto.randomUUID(), anonymousId: newYorkUser, date: '2024-01-15', // New York: Jan 15 guessCount: 4, completedAt: new Date('2024-01-15T23:00:00Z'), // 23:00 UTC }, ]; await db.insert(dailyCompletions).values(completions); const tokyoResult = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, tokyoUser)) .limit(1); const newYorkResult = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, newYorkUser)) .limit(1); expect(tokyoResult[0].date).toBe('2024-01-16'); expect(newYorkResult[0].date).toBe('2024-01-15'); }); }); describe('Streak calculation', () => { test('consecutive days count as a streak', async () => { const userId = 'streak-user'; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-13', guessCount: 2, completedAt: new Date('2024-01-13T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', guessCount: 3, completedAt: new Date('2024-01-14T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-15', guessCount: 1, completedAt: new Date('2024-01-15T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); // Verify consecutive dates expect(sortedDates).toEqual(['2024-01-13', '2024-01-14', '2024-01-15']); // Calculate streak let streak = 1; for (let i = 1; i < sortedDates.length; i++) { const currentDate = new Date(sortedDates[i]); const prevDate = new Date(sortedDates[i - 1]); const daysDiff = Math.floor( (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) ); if (daysDiff === 1) { streak++; } else { break; } } expect(streak).toBe(3); }); test('current streak is active if last completion was today', async () => { const userId = 'current-streak-user'; const userToday = '2024-01-16'; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', guessCount: 2, completedAt: new Date('2024-01-14T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-15', guessCount: 3, completedAt: new Date('2024-01-15T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: userToday, guessCount: 1, completedAt: new Date('2024-01-16T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); const lastPlayedDate = sortedDates[sortedDates.length - 1]; const yesterdayDate = new Date(userToday); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const yesterday = yesterdayDate.toISOString().split('T')[0]; const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; expect(isStreakActive).toBe(true); expect(lastPlayedDate).toBe(userToday); }); test('current streak is active if last completion was yesterday', async () => { const userId = 'yesterday-streak-user'; const userToday = '2024-01-16'; const yesterdayDate = new Date(userToday); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const yesterday = yesterdayDate.toISOString().split('T')[0]; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-13', guessCount: 2, completedAt: new Date('2024-01-13T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', guessCount: 3, completedAt: new Date('2024-01-14T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: yesterday, guessCount: 1, completedAt: new Date(yesterday + 'T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); const lastPlayedDate = sortedDates[sortedDates.length - 1]; const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; expect(isStreakActive).toBe(true); expect(lastPlayedDate).toBe(yesterday); }); test('current streak is not active if last completion was 2+ days ago', async () => { const userId = 'broken-streak-user'; const userToday = '2024-01-16'; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-13', guessCount: 2, completedAt: new Date('2024-01-13T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', guessCount: 3, completedAt: new Date('2024-01-14T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); const lastPlayedDate = sortedDates[sortedDates.length - 1]; const yesterdayDate = new Date(userToday); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const yesterday = yesterdayDate.toISOString().split('T')[0]; const isStreakActive = lastPlayedDate === userToday || lastPlayedDate === yesterday; expect(isStreakActive).toBe(false); expect(lastPlayedDate).toBe('2024-01-14'); // 2 days ago }); test('gap in dates breaks the streak', async () => { const userId = 'gap-user'; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-10', guessCount: 2, completedAt: new Date('2024-01-10T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-11', guessCount: 3, completedAt: new Date('2024-01-11T12:00:00Z'), }, // Gap here (no 01-12) { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-13', guessCount: 1, completedAt: new Date('2024-01-13T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', guessCount: 2, completedAt: new Date('2024-01-14T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); // Calculate best streak (should be 2, not 4) let bestStreak = 1; let tempStreak = 1; for (let i = 1; i < sortedDates.length; i++) { const currentDate = new Date(sortedDates[i]); const prevDate = new Date(sortedDates[i - 1]); const daysDiff = Math.floor( (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) ); if (daysDiff === 1) { tempStreak++; } else { bestStreak = Math.max(bestStreak, tempStreak); tempStreak = 1; } } bestStreak = Math.max(bestStreak, tempStreak); expect(bestStreak).toBe(2); // Longest streak is Jan 13-14 or Jan 10-11 }); }); describe('Date validation', () => { test('date must be in YYYY-MM-DD format', () => { const validDates = ['2024-01-15', '2023-12-31', '2024-02-29']; validDates.forEach((date) => { expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); }); test('invalid date formats are rejected', () => { const invalidDates = [ '2024/01/15', // Wrong separator '01-15-2024', // Wrong order '2024-1-15', // Missing leading zero '2024-01-15T12:00:00Z', // Includes time ]; invalidDates.forEach((date) => { if (date.includes('T')) { expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/); } else { expect(date).not.toMatch(/^\d{4}-\d{2}-\d{2}$/); } }); }); }); describe('Edge cases', () => { test('crossing year boundary maintains streak', async () => { const userId = 'year-boundary-user'; const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2023-12-30', guessCount: 2, completedAt: new Date('2023-12-30T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2023-12-31', guessCount: 3, completedAt: new Date('2023-12-31T12:00:00Z'), }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-01', guessCount: 1, completedAt: new Date('2024-01-01T12:00:00Z'), }, ]; await db.insert(dailyCompletions).values(completions); const allCompletions = await db .select() .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const sortedDates = allCompletions.map((c) => c.date).sort(); let streak = 1; for (let i = 1; i < sortedDates.length; i++) { const currentDate = new Date(sortedDates[i]); const prevDate = new Date(sortedDates[i - 1]); const daysDiff = Math.floor( (currentDate.getTime() - prevDate.getTime()) / (1000 * 60 * 60 * 24) ); if (daysDiff === 1) { streak++; } } expect(streak).toBe(3); }); // Note: Duplicate prevention is handled by the API endpoint, not at the DB level in these tests // See /api/submit-completion for the unique constraint enforcement }); }); // --------------------------------------------------------------------------- // Streak walk-back logic — mirrors /api/streak/+server.ts // UTC is NEVER used for date comparison; all walk-back is pure string arithmetic. // --------------------------------------------------------------------------- function prevDay(dateStr: string): string { const d = new Date(dateStr + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - 1); return d.toISOString().slice(0, 10); } function calcStreak(completedDates: Set, localDate: string): number { let streak = 0; let cursor = localDate; while (completedDates.has(cursor)) { streak++; cursor = prevDay(cursor); } return streak < 2 ? 0 : streak; } describe('Streak walk-back — local time always used, UTC never', () => { test('prevDay uses UTC arithmetic, never server local time', () => { // Regardless of server timezone, prevDay("2024-03-10") must always be "2024-03-09" // (including across DST boundaries where local midnight ≠ UTC midnight) expect(prevDay('2024-03-10')).toBe('2024-03-09'); // DST spring-forward in US expect(prevDay('2024-11-03')).toBe('2024-11-02'); // DST fall-back in US expect(prevDay('2024-01-01')).toBe('2023-12-31'); // year boundary expect(prevDay('2024-03-01')).toBe('2024-02-29'); // leap year expect(prevDay('2023-03-01')).toBe('2023-02-28'); // non-leap year }); test('UTC+9 user: plays at 01:00 local (still previous UTC date) — streak uses local date', () => { // Scenario: It is 2024-01-16 01:00 in Tokyo (UTC+9) = 2024-01-15 16:00 UTC. // The verse served is for 2024-01-15 (UTC), but the user's LOCAL date is 2024-01-16. // Completions are stored as the user's local date (as returned by dailyVerse.date // which is set from the client's new Date().toLocaleDateString("en-CA")). // The streak walk-back must use local dates, not UTC dates. // Four consecutive local dates for a Tokyo user const completedLocalDates = new Set(['2024-01-13', '2024-01-14', '2024-01-15', '2024-01-16']); // If we (incorrectly) walked back from the UTC date "2024-01-15" instead of // the local date "2024-01-16", we would miss the most recent completion. const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-15'); const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-16'); // UTC walk-back misses the local "2024-01-16" entry → only finds 3 consecutive days // (but actually it finds 2024-01-15, 2024-01-14, 2024-01-13 = 3, returned as 3) // The point is it does NOT include 2024-01-16 which is "today" for the user. expect(wrongStreakIfUTC).toBe(3); // stale — missing today's local entry // Local walk-back correctly finds all four entries expect(correctStreakWithLocalDate).toBe(4); }); test('UTC-8 user: plays at 23:00 local (next UTC date) — streak uses local date', () => { // Scenario: It is 2024-01-15 23:00 in Los Angeles (UTC-8) = 2024-01-16 07:00 UTC. // The verse served is for 2024-01-16 (UTC), but the user's LOCAL date is still 2024-01-15. // Completion is stored as local date "2024-01-15". // Walk-back from "2024-01-15" (local) must find it; walk-back from "2024-01-16" (UTC) // would NOT find it (the entry is stored as "2024-01-15"). const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14', '2024-01-15']); // If we (incorrectly) walked back from the UTC date "2024-01-16" instead of // the local date "2024-01-15", "2024-01-16" is not in the set → streak = 0. const wrongStreakIfUTC = calcStreak(completedLocalDates, '2024-01-16'); const correctStreakWithLocalDate = calcStreak(completedLocalDates, '2024-01-15'); expect(wrongStreakIfUTC).toBe(0); // broken — UTC date not in DB expect(correctStreakWithLocalDate).toBe(4); }); test('UTC+13 user (Samoa): local date is two days ahead of UTC-11', () => { // Extreme case: UTC+13 user on 2024-01-16 local = 2024-01-15 UTC. const completedLocalDates = new Set(['2024-01-14', '2024-01-15', '2024-01-16']); expect(calcStreak(completedLocalDates, '2024-01-14')).toBe(0); // only 1 day (< 2) expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(3); // correct local streak expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); // UTC date misses Jan 16 }); test('streak is 0 when today is missing even if yesterday exists', () => { // User missed today — streak must reset regardless of timezone const completedLocalDates = new Set(['2024-01-12', '2024-01-13', '2024-01-14']); // "Today" is 2024-01-16 — they missed the 15th and 16th expect(calcStreak(completedLocalDates, '2024-01-16')).toBe(0); }); test('streak suppressed below 2 — single day returns 0', () => { const completedLocalDates = new Set(['2024-01-15']); expect(calcStreak(completedLocalDates, '2024-01-15')).toBe(0); }); test('DB entries with local date store correctly alongside UTC completion timestamp', async () => { // Verify DB round-trip: storing completions with local dates (not UTC) // ensures streak calculation with local dates always works. const userId = 'tz-test-user-' + crypto.randomUUID(); // UTC+9 scenario: played at 01:00 local on Jan 16 (= UTC Jan 15) // Local date is Jan 16, stored as "2024-01-16" in the DB. const completions = [ { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-14', // local date guessCount: 2, completedAt: new Date('2024-01-13T15:00:00Z'), // UTC }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-15', // local date guessCount: 3, completedAt: new Date('2024-01-14T15:00:00Z'), // UTC }, { id: crypto.randomUUID(), anonymousId: userId, date: '2024-01-16', // local date — UTC equivalent is 2024-01-15 guessCount: 1, completedAt: new Date('2024-01-15T16:00:00Z'), // 01:00 Tokyo time }, ]; await db.insert(dailyCompletions).values(completions); const rows = await db .select({ date: dailyCompletions.date }) .from(dailyCompletions) .where(eq(dailyCompletions.anonymousId, userId)); const storedDates = new Set(rows.map((r) => r.date)); // Walk-back from LOCAL date "2024-01-16" finds all three entries expect(calcStreak(storedDates, '2024-01-16')).toBe(3); // Walk-back from UTC date "2024-01-15" misses the "2024-01-16" local entry // (demonstrates the bug that was fixed: UTC walk-back gives wrong result) expect(calcStreak(storedDates, '2024-01-15')).toBe(0); await db.delete(dailyCompletions).where(eq(dailyCompletions.anonymousId, userId)); }); });