import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { gameEvent, postTelemetry as post, roster, telemetryAuthHeaders } from '../helpers/telemetry.js'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture(); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); }); afterEach(async () => fixture.cleanup()); const payload = gameEvent('round_change', 17); it.each(['round_change', 'heartbeat', 'game_end'] as const)( 'accepts and stores score_total separately from the balance for %s', async (event) => { const body = gameEvent(event, 17, 1); const response = await post({ ...body, players: [{ ...body.players[0], score: 28000, score_total: 63000 }] }); expect(response.status).toBe(200); expect((await response.json()).telemetry.players[0]).toMatchObject({ score: 28000, score_total: 63000 }); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()[0].players[0]).toMatchObject({ score: 28000, score_total: 63000 }); const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare('SELECT best_score, score_total FROM leaderboard_match_players').get() ).toEqual({ best_score: 28000, score_total: 63000 }); } ); it.each([0, Number.MAX_SAFE_INTEGER])( 'accepts cumulative score boundary %s', async (score_total) => { const { parseGameEvent } = await import('../../src/lib/telemetry.js'); expect( parseGameEvent({ ...payload, players: [{ ...roster(1)[0], score_total }] }).players[0] ).toMatchObject({ score_total }); } ); it.each([null, -1, 1.5, '63000', true, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid score_total without saving it: %j', async (score_total) => { expect( (await post({ ...payload, players: [{ ...roster(1)[0], score_total }] })).status ).toBe(400); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([]); } ); it('accepts older reporters without fabricating a cumulative score', async () => { const response = await post(gameEvent('game_end', 17, 1)); expect(response.status).toBe(200); expect((await response.json()).telemetry.players[0]).not.toHaveProperty('score_total'); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT score_total FROM leaderboard_match_players').get()).toEqual({ score_total: null }); }); it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)( 'preserves optional reporter identity and sequence for %s', async (event) => { const { parseGameEvent } = await import('../../src/lib/telemetry.js'); const report = gameEvent(event); const match = ' opaque/Match:123 '; expect(parseGameEvent({ ...report, match, seq: 7 })).toEqual({ ...report, match, seq: 7 }); expect(parseGameEvent({ ...report, match })).toEqual({ ...report, match }); expect(parseGameEvent({ ...report, seq: 7 })).toEqual({ ...report, seq: 7 }); expect(parseGameEvent(report)).toEqual(report); const response = await post({ ...report, match, seq: 7 }); expect(response.status).toBe(200); expect((await response.json()).telemetry).toMatchObject({ match, seq: 7 }); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()[0]).toMatchObject({ match, seq: 7 }); } ); it.each([ { match: '' }, { match: ' \t\n' }, { match: 'x'.repeat(65) }, { match: null }, { match: 123 }, { seq: 0 }, { seq: -1 }, { seq: 1.5 }, { seq: Number.MAX_SAFE_INTEGER + 1 }, { seq: null }, { seq: '1' } ])('rejects malformed optional reporter metadata: %j', async (metadata) => { const response = await post({ ...payload, ...metadata }); expect(response.status).toBe(400); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([]); }); it('accepts the maximum reporter key length and safe sequence number', async () => { const { parseGameEvent } = await import('../../src/lib/telemetry.js'); expect( parseGameEvent({ ...payload, match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER }) ).toMatchObject({ match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER }); }); it.each(['game_end', 'round reset', 'map change'])( 'drops the previous match timing when game_start is missed after %s', async (boundary) => { const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const at = (seconds: number) => new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString(); saveTelemetry({ ...gameEvent('game_start', 0, 1), receivedAt: at(0) }); const previous = saveTelemetry({ ...gameEvent('heartbeat', 7, 1), receivedAt: at(30) }); if (boundary === 'game_end') { const ended = saveTelemetry({ ...gameEvent('game_end', 7, 0), receivedAt: at(31) }); expect(ended.timing?.server?.gameEndedAt).toBe(at(31)); } const next = saveTelemetry({ ...gameEvent('heartbeat', boundary === 'map change' ? 8 : 1, 1), ...(boundary === 'map change' ? { map: 'zombie_moon' } : {}), receivedAt: at(60) }); expect(next.timing?.matchId).not.toBe(previous.timing?.matchId); expect(next.timing?.server).toMatchObject({ gameStartedAt: null, gameEndedAt: null }); expect(getServer(payload.server)).toMatchObject({ game_started_at: null, game_ended_at: null }); } ); it('repairs a previously discarded start from an observed match without inventing one', async () => { const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { db } = await import('../../src/lib/server/db/index.js'); const at = (seconds: number) => new Date(Date.parse('2026-10-09T12:00:00Z') + seconds * 1000).toISOString(); saveTelemetry({ ...gameEvent('game_start', 0, 0), receivedAt: at(0) }); // Simulate the old ingestion behavior for an empty start. db().prepare('UPDATE servers SET game_started_at = NULL WHERE name = ?').run(payload.server); const report = saveTelemetry({ ...gameEvent('heartbeat', 4, 1), receivedAt: at(120) }); expect(report.timing?.server?.gameStartedAt).toBe(at(0)); }); it('does not let a request received before a lifecycle submission restore cleared timers', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const at = (seconds: number) => new Date(Date.parse('2026-09-20T12:00:00Z') + seconds * 1000).toISOString(); db() .prepare('INSERT INTO restart_rollout (server, requested_at) VALUES (?, ?)') .run(payload.server, at(10)); for (const seconds of [9, 10]) { saveTelemetry({ ...gameEvent('game_start'), receivedAt: at(seconds) }); expect(getServer(payload.server)).toMatchObject({ active_since: null, game_started_at: null }); } const current = saveTelemetry({ ...gameEvent('heartbeat', 2), receivedAt: at(11) }); expect(current.timing?.server).toEqual({ activeSince: at(11), inactiveSince: null, gameStartedAt: null, gameEndedAt: null }); }); it('generates timer anchors on the server instead of accepting client-supplied timing', async () => { const response = await post({ ...gameEvent('game_start'), receivedAt: '2000-01-01T00:00:00Z', timing: { server: { activeSince: '2000-01-01T00:00:00Z' } } }); const { telemetry } = await response.json(); expect(response.status).toBe(200); expect(telemetry.receivedAt).not.toBe('2000-01-01T00:00:00Z'); expect(telemetry.timing.server).toEqual({ activeSince: telemetry.receivedAt, inactiveSince: null, gameStartedAt: telemetry.receivedAt, gameEndedAt: null }); }); it('persists player timing across matches and resets after absence or a telemetry gap', async () => { const { saveTelemetry, listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { closeDb } = await import('../../src/lib/server/db/index.js'); const guid = roster(1)[0].guid; const at = (seconds: number) => new Date(Date.parse('2026-09-20T12:00:00Z') + seconds * 1000).toISOString(); const save = ( event: Parameters[0], seconds: number, round = 1, players = 1 ) => saveTelemetry({ ...gameEvent(event, round, players), receivedAt: at(seconds) }); save('game_start', 0); save('heartbeat', 60, 5); const end = save('game_end', 90, 6); expect(end.timing?.players[guid]).toEqual({ serverJoinedAt: at(0), matchJoinedAt: at(0), roundStarted: 1 }); const next = save('game_start', 100); expect(next.timing?.players[guid]).toEqual({ serverJoinedAt: at(0), matchJoinedAt: at(100), roundStarted: 1 }); closeDb(); expect(listTelemetry()[0].timing).toEqual(next.timing); save('heartbeat', 120, 2, 0); expect(save('heartbeat', 140, 3).timing?.players[guid]).toEqual({ serverJoinedAt: at(140), matchJoinedAt: at(140), roundStarted: 3 }); expect(save('heartbeat', 400, 8).timing?.players[guid]).toEqual({ serverJoinedAt: at(400), matchJoinedAt: at(400), roundStarted: 8 }); expect(save('heartbeat', 420, 1).timing?.players[guid]).toEqual({ serverJoinedAt: at(400), matchJoinedAt: at(420), roundStarted: 1 }); }); it('timestamps initial telemetry and subsequent status and telemetry events with the server clock', async () => { const { GET } = await import('../../src/routes/api/events/servers/+server.js'); const { emit } = await import('../../src/lib/server/pipeline/bus.js'); const startedAt = Date.parse('2026-09-19T12:00:00Z'); const clock = vi.spyOn(Date, 'now').mockReturnValue(startedAt); const reader = (await GET()).body!.getReader(); const nextEvent = async () => { const frame = new TextDecoder().decode((await reader.read()).value); return JSON.parse( frame .split('\n') .find((line) => line.startsWith('data: '))! .slice(6) ); }; try { await reader.read(); // Initial SSE comment. expect(await nextEvent()).toMatchObject({ snapshot: true, serverNow: startedAt, entries: expect.any(Array) }); expect(await nextEvent()).toMatchObject({ snapshot: true, entries: [], serverNow: startedAt }); clock.mockReturnValue(startedAt + 30_000); emit({ type: 'server-status', entries: [] }); expect(await nextEvent()).toEqual({ type: 'server-status', entries: [], serverNow: startedAt + 30_000 }); clock.mockReturnValue(startedAt + 31_000); emit({ type: 'server-telemetry', entries: [] }); expect(await nextEvent()).toEqual({ type: 'server-telemetry', entries: [], serverNow: startedAt + 31_000 }); } finally { await reader.cancel(); clock.mockRestore(); } }); it('serializes each live server event once across connections and releases cancelled subscribers', async () => { const { GET } = await import('../../src/routes/api/events/servers/+server.js'); const { emit } = await import('../../src/lib/server/pipeline/bus.js'); const readers = await Promise.all( [GET(), GET()].map(async (response) => (await response).body!.getReader()) ); const stringify = vi.spyOn(JSON, 'stringify'); try { for (const reader of readers) { await reader.read(); // Initial comment and per-connection snapshots. await reader.read(); await reader.read(); } for (const event of [ { type: 'server-status' as const, delta: true, entries: [] }, { type: 'server-telemetry' as const, entries: [{ ...payload, receivedAt: new Date().toISOString() }] } ]) { stringify.mockClear(); emit(event); expect(stringify).toHaveBeenCalledTimes(1); const [first, second] = await Promise.all(readers.map((reader) => reader.read())); expect(first.value).toBe(second.value); const frame = new TextDecoder().decode(first.value); expect(frame.startsWith(`event: ${event.type}\ndata: `)).toBe(true); expect(JSON.parse(frame.split('\n')[1].slice(6))).toEqual({ ...event, serverNow: expect.any(Number) }); } await readers[0].cancel(); stringify.mockClear(); emit({ type: 'server-status', entries: [] }); expect(stringify).toHaveBeenCalledTimes(1); expect((await readers[1].read()).done).toBe(false); await readers[1].cancel(); stringify.mockClear(); emit({ type: 'server-telemetry', entries: [] }); expect(stringify).not.toHaveBeenCalled(); } finally { stringify.mockRestore(); await Promise.all(readers.map((reader) => reader.cancel())); } }); it.each(['round_change', 'heartbeat'] as const)( 'persists %s stats, broadcasts updates, and leaves RCON safety data untouched', async (event) => { const payload = gameEvent(event, 17); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const listener = vi.fn(); const unsubscribe = subscribe(listener); expect(await (await post(payload)).json()).toEqual({ ok: true, telemetry: { ...payload, receivedAt: expect.any(String), timing: expect.any(Object) } }); expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [{ ...payload, receivedAt: expect.any(String), timing: expect.any(Object) }] }); unsubscribe(); await post({ ...payload, round: 1 }); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([ { ...payload, round: 1, receivedAt: expect.any(String), timing: expect.any(Object) } ]); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); expect(getServer(payload.server)?.last_players).toBeNull(); const { GET } = await import('../../src/routes/api/events/servers/+server.js'); const reader = (await GET()).body!.getReader(); await reader.read(); const snapshot = new TextDecoder().decode((await reader.read()).value); expect(snapshot).toContain('"round":1'); expect(snapshot).toContain('"headshots":50'); expect(snapshot).toContain('"guid":"12345678901234560"'); await reader.cancel(); } ); it('accepts all four events without an active beta and updates match lifecycle', async () => { const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const start = gameEvent('game_start'); expect((await post(start)).status).toBe(200); expect(listTelemetry()).toEqual([ { ...start, receivedAt: expect.any(String), timing: expect.any(Object) } ]); expect(getServer(start.server)?.game_started_at).toBeTruthy(); expect(getServer(start.server)?.game_ended_at).toBeNull(); expect((await post(payload)).status).toBe(200); const startedAt = getServer(start.server)?.game_started_at; const heartbeat = gameEvent('heartbeat', 17); expect((await post(heartbeat)).status).toBe(200); expect(listTelemetry()[0]).toMatchObject(heartbeat); expect(getServer(start.server)?.game_started_at).toBe(startedAt); expect(getServer(start.server)?.game_ended_at).toBeNull(); const end = gameEvent('game_end', 18); expect((await post(end)).status).toBe(200); expect(getServer(start.server)?.game_ended_at).toBeTruthy(); expect(listTelemetry()[0]).toMatchObject(end); await post(start); expect(listTelemetry()[0]).not.toHaveProperty('round'); expect(getServer(start.server)?.game_ended_at).toBeNull(); expect((await post(gameEvent('game_start', 0, 0))).status).toBe(200); expect((await post(gameEvent('game_end', 0, 0))).status).toBe(200); }); it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)( 'preserves %s player pings through ingestion, streaming and reload', async (event) => { const payload = gameEvent(event, 17); payload.players = payload.players.map((player, i) => ({ ...player, ping: [0, 42, 999, null][i] })); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const listener = vi.fn(); const unsubscribe = subscribe(listener); try { const response = await post(payload); expect(response.status).toBe(200); expect((await response.json()).telemetry.players).toEqual(payload.players); expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [expect.objectContaining({ players: payload.players })] }); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { telemetryReport } = await import('../../src/lib/server/fleet/status-evidence.js'); expect(listTelemetry()[0].players).toEqual(payload.players); expect(telemetryReport(payload.server)?.players).toEqual(payload.players); } finally { unsubscribe(); } } ); it.each([-1, 1.5, '42', false, {}, [], Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid player ping: %j', async (ping) => { for (const event of ['game_start', 'heartbeat'] as const) { const body = gameEvent(event, 17, 1); expect((await post({ ...body, players: [{ ...body.players[0], ping }] })).status).toBe( 400 ); } } ); it.each([ null, [], {}, ...['boot', 'session_start', 'match_end', 'feature', 'other'].map((event) => ({ ...payload, event })), { ...payload, map: '' }, { ...payload, map: undefined }, { ...payload, round: -1 }, { ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: 4 }, { ...payload, players: null }, { ...payload, players: [null] }, { ...payload, players: [{ name: 'Josh', guid: '123' }] }, ...['name', 'guid', 'kills', 'headshots', 'downs', 'revives', 'score'].map((field) => ({ ...payload, players: [{ ...roster(1)[0], [field]: null }] })), // eslint-disable-next-line no-loss-of-precision -- Deliberately reject GUIDs decoded as unsafe JSON numbers. { ...payload, players: [{ ...roster(1)[0], guid: 12345678901234567 }] }, { ...payload, players: [{ ...roster(1)[0], score: -1 }] } ])('rejects invalid telemetry: %j', async (body) => { expect((await post(body)).status).toBe(400); }); it.each([ { ...gameEvent('heartbeat'), round: undefined }, { ...gameEvent('heartbeat'), round: -1 }, { ...gameEvent('heartbeat'), players: [{ name: 'Player', guid: '123' }] }, { ...gameEvent('heartbeat'), players: [{ ...roster(1)[0], kills: -1 }] } ])('requires round and full stats for heartbeats: %j', async (body) => { expect((await post(body)).status).toBe(400); }); it('rejects unknown and destroyed servers and non-JSON requests', async () => { expect((await post({ ...payload, server: 'unknown' })).status).toBe(404); expect((await post(payload, 'text/plain')).status).toBe(415); const { setServerField } = await import('../../src/lib/server/fleet/servers.js'); setServerField(payload.server, 'destroyed_at', new Date().toISOString()); expect((await post(payload)).status).toBe(404); }); it('rejects malformed JSON', async () => { const { POST } = await import('../../src/routes/api/telemetry/+server.js'); expect( ( await POST({ request: new Request('http://localhost/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json', ...telemetryAuthHeaders() }, body: '{' }) }) ).status ).toBe(400); }); it('migrates old round-only storage without fabricating player identities', async () => { const { db, closeDb } = await import('../../src/lib/server/db/index.js'); db().exec( 'DROP TABLE server_telemetry; CREATE TABLE server_telemetry (server TEXT PRIMARY KEY, round INTEGER NOT NULL, players INTEGER NOT NULL, received_at TEXT NOT NULL)' ); db() .prepare('INSERT INTO server_telemetry VALUES (?, ?, ?, ?)') .run(payload.server, 99, 4, new Date().toISOString()); db().pragma('user_version = 0'); closeDb(); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([]); await post(payload); closeDb(); expect(listTelemetry()[0]).toMatchObject(payload); });