Files
electric-cherry/tests/unit/telemetry.test.ts
T
josh 2fa3792482 Fix active and match timer drift between status polls
Persist occupancy transitions with telemetry and carry timer anchors through live updates. Cover delayed polling, lobby rejoining, and lifecycle boundaries with regression tests.

Fixes #47
2026-09-27 23:29:04 -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 } 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' }, 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);
});