Files
ssessesse-ki-battle/tests/resilience.test.ts
T

66 lines
5.0 KiB
TypeScript

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<string, string>(); 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<unknown>[] = [];
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(); }
});