387 lines
16 KiB
TypeScript
387 lines
16 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,
|
|
serverNow: startedAt,
|
|
entries: expect.any(Array)
|
|
});
|
|
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('serializes each live server event once across connections and releases cancelled subscribers', async () => {
|
|
const { GET } = await import('../../src/routes/api/events/servers/+server.js');
|
|
const { emit } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const readers = await Promise.all(
|
|
[GET(), GET()].map(async (response) => (await response).body!.getReader())
|
|
);
|
|
const stringify = vi.spyOn(JSON, 'stringify');
|
|
try {
|
|
for (const reader of readers) {
|
|
await reader.read(); // Initial comment and per-connection snapshots.
|
|
await reader.read();
|
|
await reader.read();
|
|
}
|
|
for (const event of [
|
|
{ type: 'server-status' as const, delta: true, entries: [] },
|
|
{
|
|
type: 'server-telemetry' as const,
|
|
entries: [{ ...payload, receivedAt: new Date().toISOString() }]
|
|
}
|
|
]) {
|
|
stringify.mockClear();
|
|
emit(event);
|
|
expect(stringify).toHaveBeenCalledTimes(1);
|
|
const [first, second] = await Promise.all(readers.map((reader) => reader.read()));
|
|
expect(first.value).toBe(second.value);
|
|
const frame = new TextDecoder().decode(first.value);
|
|
expect(frame.startsWith(`event: ${event.type}\ndata: `)).toBe(true);
|
|
expect(JSON.parse(frame.split('\n')[1].slice(6))).toEqual({
|
|
...event,
|
|
serverNow: expect.any(Number)
|
|
});
|
|
}
|
|
await readers[0].cancel();
|
|
stringify.mockClear();
|
|
emit({ type: 'server-status', entries: [] });
|
|
expect(stringify).toHaveBeenCalledTimes(1);
|
|
expect((await readers[1].read()).done).toBe(false);
|
|
await readers[1].cancel();
|
|
stringify.mockClear();
|
|
emit({ type: 'server-telemetry', entries: [] });
|
|
expect(stringify).not.toHaveBeenCalled();
|
|
} finally {
|
|
stringify.mockRestore();
|
|
await Promise.all(readers.map((reader) => reader.cancel()));
|
|
}
|
|
});
|
|
|
|
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(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)(
|
|
'preserves %s player pings through ingestion, streaming and reload',
|
|
async (event) => {
|
|
const payload = gameEvent(event, 17);
|
|
payload.players = payload.players.map((player, i) => ({
|
|
...player,
|
|
ping: [0, 42, 999, null][i]
|
|
}));
|
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribe(listener);
|
|
try {
|
|
const response = await post(payload);
|
|
expect(response.status).toBe(200);
|
|
expect((await response.json()).telemetry.players).toEqual(payload.players);
|
|
expect(listener).toHaveBeenCalledWith({
|
|
type: 'server-telemetry',
|
|
entries: [expect.objectContaining({ players: payload.players })]
|
|
});
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
const { telemetryReport } =
|
|
await import('../../src/lib/server/fleet/status-evidence.js');
|
|
expect(listTelemetry()[0].players).toEqual(payload.players);
|
|
expect(telemetryReport(payload.server)?.players).toEqual(payload.players);
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
}
|
|
);
|
|
|
|
it.each([-1, 1.5, '42', false, {}, [], Number.MAX_SAFE_INTEGER + 1])(
|
|
'rejects invalid player ping: %j',
|
|
async (ping) => {
|
|
for (const event of ['game_start', 'heartbeat'] as const) {
|
|
const body = gameEvent(event, 17, 1);
|
|
expect((await post({ ...body, players: [{ ...body.players[0], ping }] })).status).toBe(
|
|
400
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
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 }]
|
|
})),
|
|
// eslint-disable-next-line no-loss-of-precision -- Deliberately reject GUIDs decoded as unsafe JSON numbers.
|
|
{ ...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);
|
|
});
|