import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import Fastify from 'fastify'; import { createStatsStore } from '../src/server/stats-store.js'; import { createGameSessionStore } from '../src/server/game-session-store.js'; import { createAnalyticsStore } from '../src/server/analytics-store.js'; import { Analytics } from '../src/server/analytics.js'; import { registerGameApi, registerAnalyticsApi } from '../src/server/game-api.js'; import { ManualClock } from './helpers.js'; import { getAnalyticsReport } from '../src/server/analytics-report.js'; test('HTTP analytics batches deduplicate, throttle activity writes and keep client game summaries separate', { skip: !process.env.TEST_MONGODB_URI }, async () => { const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_http_${randomUUID().replaceAll('-', '')}` }); const app = Fastify(), clock = new ManualClock(); const store = await createAnalyticsStore(stats.db); let touches = 0; const originalTouch = store.touch; store.touch = async (...args) => { touches++; return originalTouch(...args); }; const analytics = new Analytics(store, clock, 'test'); try { const sessions = await createGameSessionStore(stats.db); registerGameApi(app, sessions, stats); registerAnalyticsApi(app, sessions, analytics); const session = (await app.inject({ method: 'POST', url: '/api/game-sessions', payload: { createRequestId: 'b'.repeat(43) } })).json().data; const headers = { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId }; const post = (url: string, payload: any) => app.inject({ method: 'POST', url, headers, payload }); const events = [{ eventId: 'page', eventType: 'page_view', screen: 'home', metadata: { device: 'mobile' } }, ...Array.from({ length: 19 }, (_, i) => ({ eventId: `screen${i}`, eventType: 'screen_view', screen: 'match' }))]; assert.equal((await post('/api/analytics/events', { events })).statusCode, 200); assert.equal((await post('/api/analytics/events', { events })).statusCode, 200); assert.equal(await stats.db.collection('analytics_events').countDocuments(), 20); assert.equal(touches, 1); for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: true }); assert.equal(touches, 1); assert.equal(analytics.live().active, 1); clock.time += 60000; await post('/api/analytics/activity', { visible: true }); assert.equal(touches, 2); for (let i = 0; i < 10; i++) await post('/api/analytics/activity', { visible: false }); assert.equal(touches, 3); assert.equal(analytics.live().active, 0); const summary = await post('/api/analytics/events', { events: [{ eventId: 'start', eventType: 'game_started', matchId: 'm', mode: 'friend' }] }); assert.equal(summary.statusCode, 200); const event = await stats.db.collection('analytics_events').findOne({ eventType: 'game_started' }); assert.equal(event?.source, 'client'); assert.equal(event?.gameSessionId, session.gameSessionId); assert.equal(await stats.db.collection('matches').countDocuments(), 0); assert.equal((await post('/api/analytics/events', { events: Array(21).fill(events[0]) })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: [{ eventId: 'fake', eventType: 'match_started' }] })).statusCode, 400); assert.equal((await post('/api/analytics/events', { events: [ { eventId: 'ranked-screen', eventType: 'screen_view', screen: 'ranked-queue' }, { eventId: 'disconnect-summary', eventType: 'game_finished', matchId: 'disconnected', mode: 'friend', outcome: 'aborted', reason: 'disconnect' }, { eventId: 'double-disconnect-summary', eventType: 'game_finished', matchId: 'both-disconnected', mode: 'friend', outcome: 'aborted', reason: 'double_disconnect' }, ] })).statusCode, 200); assert.equal((await app.inject({ method: 'POST', url: '/api/analytics/activity', payload: { visible: true } })).statusCode, 401); } finally { await app.close(); await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } }); test('current client summaries remain available without legacy server metrics', { skip: !process.env.TEST_MONGODB_URI }, async () => { const stats = await createStatsStore({ mongoUri: process.env.TEST_MONGODB_URI!, databaseName: `dragon_ball_test_source_${randomUUID().replaceAll('-', '')}` }); const clock = new ManualClock(), store = await createAnalyticsStore(stats.db); const analytics = new Analytics(store, clock); try { const guest = await stats.createGuest(), p = { playerId: guest.playerId, gameSessionId: 'game-session' }; await analytics.clientEvent(p, { eventId: 'page', eventType: 'page_view', screen: 'home' }); await analytics.clientEvent(p, { eventId: 'start', eventType: 'game_started', matchId: 'match', mode: 'friend' }); await analytics.clientEvent(p, { eventId: 'finish', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' }); await analytics.clientEvent(p, { eventId: 'finish-after-refresh', eventType: 'game_finished', matchId: 'match', mode: 'friend', outcome: 'win', reason: 'hit' }); clock.time++; const report = await getAnalyticsReport(stats.db, { start: new Date(clock.now() - 86400000), end: new Date(clock.now()) }, new Date(clock.now())); assert.equal(report.clientGameplay.source, 'client'); assert.equal(report.clientGameplay.players, 1); assert.equal(report.clientGameplay.startedMatches, 1); assert.equal(report.clientGameplay.resultReports, 1); assert.equal(report.clientGameplay.actions, null); assert.equal(report.clientGameplay.turns, null); assert.equal(report.clientGameplay.serverVerifiedCompletion, null); assert.ok(!('gameplay' in report)); assert.ok(!('sessionConversion' in report.overview)); assert.ok(!('visitOnly' in report.overview)); assert.ok(!('conversion' in report.acquisition.source[0])); } finally { await analytics.close(); try { await stats.db.dropDatabase(); } finally { await stats.close(); } } });