Files
electric-cherry/tests/unit/telemetry.test.ts
T
josh e5c0fda1ed Add native host management and role-based operator authentication
Introduce an outbound Python host agent, Debian installer, durable remote
operations, immutable game-file libraries, host-aware deployments and ports,
and occupancy-safe campaigns and draining. Native hosts do not require Komodo.

Add administrator, operator and viewer accounts, protected management routes,
secure sessions, password recovery, audit history and separate telemetry tokens.
Document first-admin setup, reporter migration and live-host acceptance checks.

Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type
checking, the production build, compiled SSR/API smoke tests and administrator
recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
2026-10-03 11:42:36 -04:00

189 lines
11 KiB
TypeScript

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('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<typeof gameEvent>[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, 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.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([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 }] })),
{ ...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());
closeDb();
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
expect(listTelemetry()).toEqual([]);
await post(payload);
closeDb();
expect(listTelemetry()[0]).toMatchObject(payload);
});