import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { testDb } from '$lib/server/db/test'; import { analyticsEvents, dailyCompletions, dailyVerses, statsSnapshot } from '$lib/server/db/schema'; import { buildSnapshot, computePayload, computePlayerState, ensureSnapshot, PAYLOAD_SCHEMA_VERSION, type LoadedData } from '$lib/server/stats/snapshot'; import { SETTINGS_DEFAULTS, type CompletionRow } from '$lib/types/stats'; import crypto from 'node:crypto'; const settings = { ...SETTINGS_DEFAULTS }; function c(anonymousId: string, date: string): CompletionRow { return { anonymousId, date, guessCount: 3, guesses: null, completedAt: new Date(date) }; } describe('computePlayerState', () => { test('builds streak and rolling play counts as of the date', () => { const completions: CompletionRow[] = [ c('A', '2026-09-08'), c('A', '2026-09-09'), c('A', '2026-09-10'), c('A', '2026-08-01'), c('B', '2026-09-10') ]; const state = computePlayerState(completions, '2026-09-10', 180); const a = state.find((p) => p.id === 'A')!; expect(a.firstDate).toBe('2026-08-01'); expect(a.lastDate).toBe('2026-09-10'); expect(a.totalPlays).toBe(4); expect(a.plays7).toBe(3); expect(a.streak).toBe(3); }); test('drops players outside the active window', () => { const state = computePlayerState([c('old', '2026-01-01')], '2026-09-10', 30); expect(state).toHaveLength(0); }); }); describe('computePayload point-in-time cutoff', () => { const data: LoadedData = { completions: [ c('A', '2026-09-10'), c('A', '2026-09-11'), // future relative to asOfDate c('B', '2026-09-11') // future ], verses: [{ date: '2026-09-10', bookId: 'GEN' }], startedByDate: new Map([['2026-09-10', 2]]), shareEvents: 0, firstEventDate: '2026-09-10', signupDates: [] }; test('activity only reflects data up to the as-of date', () => { const payload = computePayload('2026-09-10', settings, data); expect(payload.activity.dau).toBe(1); // only A, not B (future) }); test('completion rate uses verse_served events', () => { const payload = computePayload('2026-09-10', settings, data); expect(payload.meta.hasStartedData).toBe(true); expect(payload.kpis.completionRate).not.toBeNull(); }); test('payload has all lab sections', () => { const payload = computePayload('2026-09-10', settings, data); expect(payload.retention.cohorts.length).toBeGreaterThan(0); expect(payload.verseMix.coverage).toHaveLength(66); expect(payload.habit.calendar.length).toBe(120); }); }); describe('snapshot persistence', () => { beforeEach(async () => { await testDb.delete(statsSnapshot); await testDb.delete(analyticsEvents); await testDb.delete(dailyCompletions); await testDb.delete(dailyVerses); }); afterEach(async () => { await testDb.delete(statsSnapshot); await testDb.delete(analyticsEvents); await testDb.delete(dailyCompletions); await testDb.delete(dailyVerses); }); async function seed() { await testDb.insert(dailyCompletions).values([ { id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-10T12:00:00Z') }, { id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-11', guessCount: 3, completedAt: new Date('2026-01-11T12:00:00Z') }, { id: crypto.randomUUID(), anonymousId: 'B', date: '2026-01-11', guessCount: 1, completedAt: new Date('2026-01-11T13:00:00Z') } ]); await testDb.insert(dailyVerses).values([ { id: crypto.randomUUID(), date: '2026-01-10', bookId: 'GEN', verseText: 'x', reference: 'Genesis 1:1' }, { id: crypto.randomUUID(), date: '2026-01-11', bookId: 'EXO', verseText: 'y', reference: 'Exodus 1:1' } ]); } test('building the same date twice is idempotent', async () => { await seed(); const first = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb); await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb); const rows = await testDb.select().from(statsSnapshot); expect(rows).toHaveLength(1); expect(first.snapshot.payload.asOfDate).toBe('2026-01-10'); expect(first.snapshot.payload.activity.dau).toBe(1); }); test('ensureSnapshot builds when missing and reads when present', async () => { await seed(); const a = await ensureSnapshot('2026-01-10', {}, testDb); const b = await ensureSnapshot('2026-01-10', {}, testDb); expect(a.generatedAt.getTime()).toBe(b.generatedAt.getTime()); expect(a.buildKind).toBe('lazy'); }); test('rebuilds snapshots written with an outdated payload shape', async () => { await seed(); await testDb.insert(statsSnapshot).values({ date: '2026-01-10', generatedAt: new Date(), buildKind: 'lazy', configVersion: 'outdated', payload: '{}', playerState: '[]', config: '{}' }); const snap = await ensureSnapshot('2026-01-10', {}, testDb); expect(snap.payload.meta?.schemaVersion).toBe(PAYLOAD_SCHEMA_VERSION); }); });