Files
electric-cherry/tests/unit/telemetry-diagnostics.test.ts
T

228 lines
12 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent, postTelemetry as post, telemetryAuthHeaders } 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, maxTotal: 10000 }
});
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', headers: telemetryAuthHeaders(), 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', ...telemetryAuthHeaders() }, 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();
});