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 }); });