228 lines
12 KiB
TypeScript
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 } 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();
|
|
});
|