Files
bibdle/tests/timezone-handling.test.ts
2026-02-28 02:48:46 -05:00

645 lines
19 KiB
TypeScript

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