66 lines
5.0 KiB
TypeScript
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(); }
|
|
});
|