import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomBytes, randomUUID } from 'node:crypto'; import { createStatsStore } from '../src/server/stats-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createResultReporter } from '../src/client/result-reporter.js'; import { Analytics } from '../src/server/analytics.js'; import { createAnalyticsStore } from '../src/server/analytics-store.js'; import { ManualClock } from './helpers.js'; import { RETRY_MAX_AGE_MS, RETRY_QUEUE_LIMIT } from '../constants/analytics.js'; const uri = process.env.TEST_MONGODB_URI; test('lost result response keeps registration locked until idempotent retry settles', { skip: !uri }, async () => { const store = await createStatsStore({ mongoUri: uri!, databaseName: `dragon_ball_test_retry_${randomUUID().replaceAll('-', '')}` }); const clock = new ManualClock(), sessions = await createGameSessionStore(store.db, clock.now); const auth = await sessions.create(randomBytes(32).toString('base64url')); const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); const values = new Map(); let requests = 0; const reporter = createResultReporter({ gameSessionId: auth.gameSessionId, record: async data => { if (++requests === 1) throw new Error('offline'); const ack = await sessions.record(auth, data); if (requests === 2) throw new Error('response lost'); return ack; } }, { getItem: k => values.get(k) ?? null, setItem: (k, v) => { values.set(k, v); }, removeItem: k => { values.delete(k); } }, clock); try { const pending = reporter.submit({ ...active, outcome: 'win', reason: 'hit' }); await assert.rejects(sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 1 }), /MATCH_PENDING/); await clock.advance(10000); const ack = await pending; assert.equal(ack.status, 'duplicate'); assert.equal(requests, 3); assert.equal(values.size, 0); assert.equal((await sessions.get(auth)).stats.bot.current, 1); assert.equal((await sessions.get(auth)).stats.bot.wins, 1); } finally { reporter.destroy(); await store.db.dropDatabase(); await store.close(); } }); test('analytics outage is bounded, retry preserves receive time and game result persistence continues', { skip: !uri }, async () => { const databaseName = `dragon_ball_test_outage_${randomUUID().replaceAll('-', '')}`; const store = await createStatsStore({ mongoUri: uri!, databaseName }); const clock = new ManualClock(); const aStore = await createAnalyticsStore(store.db); const original = aStore.insert.bind(aStore); let offline = true; const analytics = new Analytics({ ...aStore, insert: e => offline ? Promise.reject(new Error('offline')) : original(e) }, clock); const guest = await store.createGuest(), player = { playerId: guest.playerId }; const sessions = await createGameSessionStore(store.db, clock.now); const auth = await sessions.create(randomBytes(32).toString('base64url'), guest.playerId); try { const at = clock.now(); await assert.rejects(analytics.clientEvent(player, { eventId: 'page-1', eventType: 'page_view', screen: 'home' })); const active = await sessions.register(auth, { requestId: randomUUID(), matchId: randomUUID(), mode: 'bot', expectedPreviousSeq: 0 }); await sessions.record(auth, { ...active, outcome: 'loss', reason: 'timeout' }); assert.equal((await sessions.get(auth)).stats.bot.losses, 1); assert.ok(analytics.status().incomplete); assert.equal(analytics.status().failures, 1); offline = false; await clock.advance(5000); const event = await store.db.collection('analytics_events').findOne(); assert.equal(+event!.occurredAt, at); assert.equal(await store.db.collection('analytics_events').countDocuments(), 1); assert.equal(analytics.status().queued, 0); offline = true; await assert.rejects(analytics.clientEvent(player, { eventId: 'page-2', eventType: 'page_view', screen: 'home' })); const deferred: Promise[] = []; for (let i = 1; i < RETRY_QUEUE_LIMIT; i++) deferred.push(analytics.enqueue(`pending-${i}`, clock.now(), async () => {}).catch(() => {})); await assert.rejects(analytics.enqueue('overflow', clock.now(), async () => {})); assert.equal(analytics.status().queued, 100); assert.ok(analytics.status().dropped > 0); const retry = clock.timers.get(analytics.timer)!; clock.timers.delete(analytics.timer); clock.time += RETRY_MAX_AGE_MS + 1; retry.callback(); await Promise.all(deferred); assert.equal(analytics.status().queued, 0); assert.ok(analytics.status().dropped >= 101); analytics.windows.set(player.playerId, { at: clock.now(), count: 120 }); await assert.rejects(analytics.clientEvent(player, { eventId: 'limited', eventType: 'page_view', screen: 'home' })); assert.equal(analytics.status().rateLimited, 1); assert.equal(analytics.status().incomplete, true); } finally { await analytics.close(); await store.db.dropDatabase(); await store.close(); } });