import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import { gameEvent, postTelemetry as post } from '../helpers/telemetry.js'; const server = 't5_zombie_theater_1'; const otherServer = 't5_zombie_theater_2'; let fixture: Fixture; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([server, otherServer]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); await seedServerRegistry(); }); afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); }); async function diagnostics(name = server) { const { GET } = await import('../../src/routes/api/servers/[name]/telemetry-requests/+server.js'); return GET({ params: { name } }); } it('returns uncached, empty diagnostics for a registered server before any requests arrive', async () => { const response = await diagnostics(); expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toContain('no-store'); expect(await response.json()).toEqual({ ok: true, requests: [], retention: { maxPerServer: 200, maxAgeDays: 7 } }); expect((await diagnostics('unknown')).status).toBe(404); }); it('persists the received payload and response, isolates each server, and lists newest requests first', async () => { const first = gameEvent('round_change', 1); const latest = { ...gameEvent('round_change', 3), scriptVersion: 'diagnostic-version' }; expect((await post(first)).status).toBe(200); expect((await post(gameEvent('round_change', 2, 1, otherServer))).status).toBe(200); expect((await post(latest)).status).toBe(200); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); const body = await (await diagnostics()).json(); expect(body.requests).toHaveLength(2); expect(body.requests[0]).toEqual({ id: expect.any(Number), server, receivedAt: expect.any(String), status: 200, error: null, event: 'round_change', map: 'zombie_theater', round: 3, playerCount: 4, contentType: 'application/json', payload: JSON.stringify(latest), payloadTruncated: false }); expect(body.requests[0].id).toBeGreaterThan(body.requests[1].id); expect(body.requests[1].payload).toBe(JSON.stringify(first)); expect((await (await diagnostics(otherServer)).json()).requests).toEqual([ expect.objectContaining({ server: otherServer, round: 2, playerCount: 1 }) ]); }); it('records rejected player data with the same validation reason returned to the telemetry client', async () => { const invalid = { ...gameEvent('round_change', 5), players: [{ name: 'Player', guid: '123' }] }; const response = await post(invalid); expect(response.status).toBe(400); const result = await response.json(); expect(result.error).toContain('players[0].kills'); const { requests } = await (await diagnostics()).json(); expect(requests).toEqual([expect.objectContaining({ server, status: 400, error: result.error, event: 'round_change', round: 5, playerCount: 1, payload: JSON.stringify(invalid), payloadTruncated: false })]); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([]); }); it('records identifiable JSON sent with an unsupported or missing Content-Type', async () => { const payload = gameEvent('round_change', 2); expect((await post(payload, 'text/plain')).status).toBe(415); const { POST } = await import('../../src/routes/api/telemetry/+server.js'); // A byte body avoids Request automatically assigning text/plain to a string body. const request = new Request('http://localhost/api/telemetry', { method: 'POST', body: new TextEncoder().encode(JSON.stringify(payload)) }); expect(request.headers.get('content-type')).toBeNull(); expect((await POST({ request })).status).toBe(415); const { requests } = await (await diagnostics()).json(); expect(requests).toEqual([ expect.objectContaining({ status: 415, contentType: null, error: 'Content-Type must be application/json' }), expect.objectContaining({ status: 415, contentType: 'text/plain', payload: JSON.stringify(payload) }) ]); }); it('distinguishes a zero round and an empty scoreboard from absent or invalid fields', async () => { await post(gameEvent('round_change', 0, 0)); await post(gameEvent('game_start', 0, 0)); await post({ server, players: 4 }); const { requests } = await (await diagnostics()).json(); expect(requests).toEqual([ expect.objectContaining({ status: 400, event: null, map: null, round: null, playerCount: null }), expect.objectContaining({ status: 200, event: 'game_start', round: null, playerCount: 0 }), expect.objectContaining({ status: 200, event: 'round_change', round: 0, playerCount: 0 }) ]); }); it('keeps unknown and destroyed server rejections and matches the submitted server name exactly', async () => { const unknown = 't5_zombie_theater_99'; const spaced = ` ${server} `; expect((await post(gameEvent('game_start', 0, 0, unknown))).status).toBe(404); expect((await post(gameEvent('game_start', 0, 0, spaced))).status).toBe(404); await post(gameEvent('game_start')); const { setServerField } = await import('../../src/lib/server/fleet/servers.js'); setServerField(server, 'destroyed_at', new Date().toISOString()); expect((await post(gameEvent('game_end', 1))).status).toBe(404); const { listTelemetryRequests } = await import('../../src/lib/server/fleet/telemetry-requests.js'); expect(listTelemetryRequests(unknown)).toEqual([expect.objectContaining({ server: unknown, status: 404 })]); expect(listTelemetryRequests(spaced)).toEqual([expect.objectContaining({ server: spaced, status: 404 })]); expect((await diagnostics(unknown)).status).toBe(404); const response = await diagnostics(); expect(response.status).toBe(200); expect((await response.json()).requests).toEqual([ expect.objectContaining({ server, status: 404, error: 'Server is not registered or has been destroyed' }), expect.objectContaining({ server, status: 200 }) ]); }); it('does not attribute malformed JSON or unusable server names to a server', async () => { const { POST } = await import('../../src/routes/api/telemetry/+server.js'); const malformed = new Request('http://localhost/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: `{"server":"${server}",` }); expect((await POST({ request: malformed })).status).toBe(400); for (const value of [undefined, null, '', ' ', 123, 's'.repeat(256)]) { expect((await post({ ...gameEvent('game_start'), server: value })).status).toBe(400); } const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({ count: 0 }); }); it('bounds stored payloads and marks truncation without rejecting a valid event', async () => { const payload = { ...gameEvent('round_change', 4), diagnostic: 'x'.repeat(20_000) }; expect((await post(payload)).status).toBe(200); const { requests } = await (await diagnostics()).json(); expect(requests[0]).toMatchObject({ status: 200, round: 4, playerCount: 4, payloadTruncated: true }); expect(requests[0].payload).toBe(JSON.stringify(payload).slice(0, 16 * 1024)); }); it('records processing failures after rollback without changing the scoreboard, match state, or broadcasts', async () => { const first = gameEvent('round_change', 3); await post(first); const { db } = await import('../../src/lib/server/db/index.js'); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const beta = await import('../../src/lib/server/fleet/beta-tests.js'); const snapshot = listTelemetry(); const matches = db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all(); const listener = vi.fn(); const unsubscribe = subscribe(listener); vi.spyOn(beta, 'recordGameEvent').mockImplementationOnce(() => { throw new Error('Injected beta processing failure'); }); const response = await post(gameEvent('game_end', 99)); unsubscribe(); expect(response.status).toBe(500); expect(await response.json()).toEqual({ ok: false, error: 'Could not process telemetry' }); expect(listTelemetry()).toEqual(snapshot); expect(getServer(server)?.game_ended_at).toBeNull(); expect(db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches); expect(listener).not.toHaveBeenCalled(); expect((await (await diagnostics()).json()).requests).toEqual([ expect.objectContaining({ status: 500, event: 'game_end', round: 99, error: 'Could not process telemetry: Injected beta processing failure' }), expect.objectContaining({ status: 200, round: 3 }) ]); }); it('still accepts and broadcasts telemetry if storing diagnostics fails', async () => { const { db } = await import('../../src/lib/server/db/index.js'); db().exec(`CREATE TRIGGER reject_diagnostics BEFORE INSERT ON telemetry_requests BEGIN SELECT RAISE(FAIL, 'Injected diagnostic storage failure'); END`); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const listener = vi.fn(); const unsubscribe = subscribe(listener); const payload = gameEvent('round_change', 7); const response = await post(payload); unsubscribe(); expect(response.status).toBe(200); const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); expect(listTelemetry()).toEqual([expect.objectContaining({ ...payload, receivedAt: expect.any(String) })]); expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [expect.objectContaining({ ...payload, receivedAt: expect.any(String) })] }); }); it('retains only the latest 200 requests for a server and removes requests older than seven days', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { recordTelemetryRequest, listTelemetryRequests } = await import('../../src/lib/server/fleet/telemetry-requests.js'); const now = new Date().toISOString(); const old = new Date(Date.now() - 8 * 86_400_000).toISOString(); const insert = db().prepare(`INSERT INTO telemetry_requests (server, received_at, status, payload, payload_truncated) VALUES (?, ?, 200, '{}', 0)`); db().transaction(() => { insert.run(otherServer, old); insert.run(otherServer, now); for (let i = 0; i < 205; i++) insert.run(server, now); })(); recordTelemetryRequest({ value: gameEvent('round_change', 9), receivedAt: now, status: 200, error: null, contentType: 'application/json' }); const requests = listTelemetryRequests(server); expect(requests).toHaveLength(200); expect(requests[0]).toMatchObject({ round: 9 }); expect(requests.map(entry => entry.id)).toEqual([...requests.map(entry => entry.id)].sort((a, b) => b - a)); expect(listTelemetryRequests(otherServer)).toHaveLength(1); expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests WHERE received_at = ?').get(old)).toEqual({ count: 0 }); }); it('caps global diagnostic history even when rejected requests name many unknown servers', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { recordTelemetryRequest } = await import('../../src/lib/server/fleet/telemetry-requests.js'); const now = new Date().toISOString(); const insert = db().prepare(`INSERT INTO telemetry_requests (server, received_at, status, payload, payload_truncated) VALUES (?, ?, 404, '{}', 0)`); db().transaction(() => { for (let i = 0; i < 10_005; i++) insert.run(`unknown-${i}`, now); })(); recordTelemetryRequest({ value: gameEvent('game_start'), receivedAt: now, status: 200, error: null, contentType: 'application/json' }); expect(db().prepare('SELECT COUNT(*) AS count FROM telemetry_requests').get()).toEqual({ count: 10_000 }); expect(db().prepare('SELECT server FROM telemetry_requests ORDER BY id DESC LIMIT 1').get()).toEqual({ server }); expect(db().prepare("SELECT id FROM telemetry_requests WHERE server = 'unknown-0'").get()).toBeUndefined(); });