mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-04-05 17:33:31 -04:00
645 lines
19 KiB
TypeScript
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));
|
|
});
|
|
});
|