This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
let received = 0;
|
||||
const server = 't5_zombie_theater_1';
|
||||
const otherServer = 't5_zombie_theater_2';
|
||||
const player = (guid = 'ace', stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
|
||||
guid,
|
||||
name: guid,
|
||||
kills: 0,
|
||||
headshots: 0,
|
||||
downs: 0,
|
||||
revives: 0,
|
||||
score: 0,
|
||||
...stats
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
received = 0;
|
||||
fixture = await newFixture([server, otherServer]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
async function report(
|
||||
match: string,
|
||||
seq: number | undefined,
|
||||
event: GameEvent['event'],
|
||||
round = 0,
|
||||
players = [player()],
|
||||
source = server
|
||||
) {
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const common = {
|
||||
match,
|
||||
...(seq === undefined ? {} : { seq }),
|
||||
server: source,
|
||||
map: 'zombie_theater',
|
||||
receivedAt: new Date(Date.UTC(2026, 9, 10, 12, 0, received++)).toISOString()
|
||||
};
|
||||
return saveTelemetry(
|
||||
event === 'game_start'
|
||||
? { ...common, event, players: players.map(({ name, guid }) => ({ name, guid })) }
|
||||
: { ...common, event, round, players }
|
||||
);
|
||||
}
|
||||
|
||||
async function records() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
return db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all() as Array<{
|
||||
id: number;
|
||||
server: string;
|
||||
reporter_match_key: string;
|
||||
status: string;
|
||||
highest_round: number;
|
||||
start_observed: number;
|
||||
start_players: number | null;
|
||||
last_event: string;
|
||||
last_seq: number | null;
|
||||
report_count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function participation(id: number) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
return db()
|
||||
.prepare('SELECT * FROM leaderboard_match_players WHERE match_id = ? ORDER BY guid')
|
||||
.all(id);
|
||||
}
|
||||
|
||||
async function live() {
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const state = getServer(server)!;
|
||||
return {
|
||||
telemetry: listTelemetry().find((entry) => entry.server === server),
|
||||
timers: {
|
||||
active: state.active_since,
|
||||
inactive: state.inactive_since,
|
||||
started: state.game_started_at,
|
||||
ended: state.game_ended_at
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('deduplicates a retried sequence after intervening reports and a database reopen', async () => {
|
||||
await report('one', 1, 'game_start');
|
||||
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||
await report('one', 3, 'heartbeat', 5, [player('ace', { kills: 30 })]);
|
||||
const before = await records();
|
||||
const beforePlayers = await participation(before[0].id);
|
||||
const beforeLive = await live();
|
||||
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
|
||||
const identities = db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all();
|
||||
closeDb();
|
||||
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||
expect(await records()).toEqual(before);
|
||||
expect(await participation(before[0].id)).toEqual(beforePlayers);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
expect(db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all()).toEqual(
|
||||
identities
|
||||
);
|
||||
expect(db().prepare('SELECT seq FROM leaderboard_match_reports ORDER BY seq').all()).toEqual([
|
||||
{ seq: 1 },
|
||||
{ seq: 2 },
|
||||
{ seq: 3 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('records a lower sequence gap fill without rewinding rounds, presence, or live timers', async () => {
|
||||
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||
const before = (await records())[0];
|
||||
const beforePlayers = await participation(before.id);
|
||||
const beforeLive = await live();
|
||||
await report('one', 2, 'round_change', 2, [player('departed', { kills: 2 })]);
|
||||
const matches = await records();
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]).toMatchObject({
|
||||
id: before.id,
|
||||
status: 'active',
|
||||
highest_round: 8,
|
||||
last_event: 'heartbeat',
|
||||
last_seq: 4,
|
||||
report_count: 3
|
||||
});
|
||||
expect(await participation(before.id)).toEqual(beforePlayers);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(
|
||||
db().prepare('SELECT applied FROM leaderboard_match_reports WHERE seq = 2').get()
|
||||
).toEqual({ applied: 0 });
|
||||
});
|
||||
|
||||
it('opens matches by reporter key when starts are missing and isolates late old-key heartbeats', async () => {
|
||||
await report('old', 2, 'heartbeat', 8, [player('old-player', { kills: 80 })]);
|
||||
const first = (await records())[0];
|
||||
expect(first).toMatchObject({ reporter_match_key: 'old', start_observed: 0 });
|
||||
await report('new', 2, 'heartbeat', 8, [player('new-player', { kills: 40 })]);
|
||||
const before = await records();
|
||||
expect(before).toHaveLength(2);
|
||||
expect(before[0]).toMatchObject({ status: 'end-report-missing' });
|
||||
expect(before[1]).toMatchObject({
|
||||
reporter_match_key: 'new',
|
||||
status: 'active',
|
||||
start_observed: 0
|
||||
});
|
||||
const beforeLive = await live();
|
||||
const beforePlayers = await participation(before[1].id);
|
||||
const receipt = await report('old', 3, 'heartbeat', 9, []);
|
||||
expect(receipt.timing?.matchId).toBe(first.id);
|
||||
expect(await records()).toHaveLength(2);
|
||||
expect((await records())[1]).toEqual(before[1]);
|
||||
expect(await participation(before[1].id)).toEqual(beforePlayers);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
});
|
||||
|
||||
it('applies a delayed start only to start observation and the original roster', async () => {
|
||||
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||
const before = (await records())[0];
|
||||
const beforeLive = await live();
|
||||
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||
expect(await records()).toHaveLength(1);
|
||||
expect((await records())[0]).toMatchObject({
|
||||
id: before.id,
|
||||
start_observed: 1,
|
||||
start_players: 2,
|
||||
highest_round: 8,
|
||||
last_event: 'heartbeat',
|
||||
status: 'active',
|
||||
last_seq: 4,
|
||||
report_count: 2
|
||||
});
|
||||
expect(await participation(before.id)).toEqual([
|
||||
expect.objectContaining({
|
||||
guid: 'ace',
|
||||
highest_round: 8,
|
||||
last_round: 7,
|
||||
present: 1,
|
||||
has_stats: 1,
|
||||
kills: 80,
|
||||
present_at_start: 1
|
||||
}),
|
||||
expect.objectContaining({
|
||||
guid: 'departed',
|
||||
present: 0,
|
||||
has_stats: 0,
|
||||
kills: 0,
|
||||
present_at_start: 1
|
||||
})
|
||||
]);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
});
|
||||
|
||||
it('marks a missing end when the next key starts and gives a late end credit only to its own match', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { serverMatchHistory, getMatchDetail } =
|
||||
await import('../../src/lib/server/fleet/match-history.js');
|
||||
await report('old', 1, 'game_start');
|
||||
await report('old', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||
await report('old', 3, 'heartbeat', 8, [player('ace', { kills: 70 })]);
|
||||
await report('new', 1, 'game_start', 0, [player('other')]);
|
||||
await report('new', 2, 'heartbeat', 3, [player('other', { kills: 15 })]);
|
||||
const before = await records();
|
||||
const beforeLive = await live();
|
||||
expect(before[0].status).toBe('end-report-missing');
|
||||
expect(serverMatchHistory().matches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: before[0].id,
|
||||
status: 'end-report-missing',
|
||||
reporterMatchKey: 'old'
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(getLeaderboard().entries).toEqual([]);
|
||||
await report('old', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||
const after = await records();
|
||||
expect(after).toHaveLength(2);
|
||||
expect(after[0]).toMatchObject({ status: 'completed', highest_round: 9 });
|
||||
expect(after[1]).toEqual(before[1]);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
expect(getMatchDetail(before[0].id)).toMatchObject({
|
||||
status: 'completed',
|
||||
reporterMatchKey: 'old',
|
||||
missingReports: 0,
|
||||
finalRound: 9
|
||||
});
|
||||
expect(getLeaderboard().entries).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', kills: 90, gamesCompleted: 1 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts missing unique sequences and reduces gaps when out-of-order reports arrive', async () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
await report('one', 1, 'game_start');
|
||||
await report('one', 5, 'heartbeat', 8);
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||
reporterMatchKey: 'one',
|
||||
missingReports: 3
|
||||
});
|
||||
await report('one', 3, 'heartbeat', 5);
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
|
||||
await report('one', 3, 'heartbeat', 5);
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
|
||||
await report('one', 2, 'round_change', 2);
|
||||
await report('one', 4, 'heartbeat', 7);
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 0 });
|
||||
expect((await records())[0]).toMatchObject({ last_seq: 5, report_count: 5 });
|
||||
});
|
||||
|
||||
it('does not claim gap knowledge for a keyed reporter that omits sequence numbers', async () => {
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
await report('one', undefined, 'game_start');
|
||||
await report('one', undefined, 'heartbeat', 4);
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||
reporterMatchKey: 'one',
|
||||
missingReports: null
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a lower-round key-only report without inflating observed rounds survived', async () => {
|
||||
await report('one', undefined, 'game_start');
|
||||
await report('one', undefined, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||
const match = (await records())[0];
|
||||
const beforePlayers = await participation(match.id);
|
||||
const beforeLive = await live();
|
||||
await report('one', undefined, 'heartbeat', 4, [player('ace', { kills: 40 })]);
|
||||
expect(await participation(match.id)).toEqual(beforePlayers);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
await report('one', undefined, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||
expect(await records()).toHaveLength(1);
|
||||
expect(await participation(match.id)).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
|
||||
]);
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard().entries).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', roundsSurvived: 8 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not recount completed rounds when a newer sequence reports a lower round', async () => {
|
||||
await report('one', 1, 'game_start');
|
||||
await report('one', 2, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||
await report('one', 3, 'heartbeat', 4, [player('ace', { kills: 40 })]);
|
||||
const match = (await records())[0];
|
||||
expect(await participation(match.id)).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', rounds_survived: 7, last_round: 7 })
|
||||
]);
|
||||
await report('one', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||
expect(await records()).toHaveLength(1);
|
||||
expect(await participation(match.id)).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks both existing and new start-roster players after an initial round-zero heartbeat', async () => {
|
||||
await report('one', 1, 'heartbeat', 0);
|
||||
const match = (await records())[0];
|
||||
expect(match.start_observed).toBe(0);
|
||||
await report('one', 2, 'game_start', 0, [player('ace'), player('new-player')]);
|
||||
expect(await records()).toHaveLength(1);
|
||||
expect((await records())[0]).toMatchObject({
|
||||
id: match.id,
|
||||
start_observed: 1,
|
||||
start_players: 2
|
||||
});
|
||||
expect(await participation(match.id)).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', present_at_start: 1, first_observed_round: 0 }),
|
||||
expect.objectContaining({
|
||||
guid: 'new-player',
|
||||
present_at_start: 1,
|
||||
first_observed_round: 0
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes opaque reporter match keys and sequences to the reporting server', async () => {
|
||||
await report('same-key', 1, 'game_start');
|
||||
await report('same-key', 1, 'game_start', 0, [player('other')], otherServer);
|
||||
await report('same-key', 2, 'game_end', 4, [player('ace', { kills: 10 })]);
|
||||
await report('same-key', 2, 'heartbeat', 6, [player('other')], otherServer);
|
||||
const matches = await records();
|
||||
expect(matches).toHaveLength(2);
|
||||
expect(matches[0]).toMatchObject({ server, status: 'completed', report_count: 2 });
|
||||
expect(matches[1]).toMatchObject({
|
||||
server: otherServer,
|
||||
status: 'active',
|
||||
highest_round: 6,
|
||||
report_count: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('allows a delayed lower-sequence end to complete a match without rewinding its latest roster', async () => {
|
||||
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||
const before = (await records())[0];
|
||||
const beforeLive = await live();
|
||||
await report('one', 3, 'game_end', 7, [
|
||||
player('departed', { kills: 5 }),
|
||||
player('final-new', { kills: 20 })
|
||||
]);
|
||||
expect(await records()).toHaveLength(1);
|
||||
expect((await records())[0]).toMatchObject({
|
||||
id: before.id,
|
||||
highest_round: 8,
|
||||
status: 'completed',
|
||||
last_seq: 4,
|
||||
report_count: 3
|
||||
});
|
||||
expect(await participation(before.id)).toEqual([
|
||||
expect.objectContaining({ guid: 'ace', present: 1, highest_round: 8, kills: 80 }),
|
||||
expect.objectContaining({ guid: 'departed', present: 0, completed: 1 }),
|
||||
expect.objectContaining({
|
||||
guid: 'final-new',
|
||||
present: 0,
|
||||
completed: 1,
|
||||
has_stats: 1,
|
||||
highest_round: 7,
|
||||
rounds_survived: 0,
|
||||
kills: 20
|
||||
})
|
||||
]);
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(getMatchDetail(before.id)).toMatchObject({
|
||||
endPlayers: 2,
|
||||
players: expect.arrayContaining([
|
||||
expect.objectContaining({ guid: 'ace', presentAtEnd: false }),
|
||||
expect.objectContaining({ guid: 'departed', presentAtEnd: true }),
|
||||
expect.objectContaining({ guid: 'final-new', presentAtEnd: true })
|
||||
])
|
||||
});
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard().entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ guid: 'final-new', kills: 20, gamesCompleted: 1 })
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('acknowledges retries and old keys with their own match ID without broadcasting stale telemetry', async () => {
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
try {
|
||||
const oldStart = { ...gameEvent('game_start'), match: 'old', seq: 1 };
|
||||
const oldHeartbeat = { ...gameEvent('heartbeat', 5), match: 'old', seq: 2 };
|
||||
const started = await (await postTelemetry(oldStart)).json();
|
||||
await postTelemetry(oldHeartbeat);
|
||||
await postTelemetry({ ...gameEvent('game_start'), match: 'new', seq: 1 });
|
||||
await postTelemetry({ ...gameEvent('heartbeat', 3), match: 'new', seq: 2 });
|
||||
const beforeLive = await live();
|
||||
listener.mockClear();
|
||||
for (const stale of [oldStart, oldHeartbeat, { ...oldHeartbeat, seq: 3 }]) {
|
||||
const response = await postTelemetry(stale);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
ok: true,
|
||||
telemetry: {
|
||||
match: 'old',
|
||||
seq: stale.seq,
|
||||
timing: { matchId: started.telemetry.timing.matchId }
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(await live()).toEqual(beforeLive);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||
import { migrateLeaderboardReports } from '../../src/lib/server/db/leaderboard-reports.js';
|
||||
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
|
||||
import { migrations } from '../../src/lib/server/db/migrations.js';
|
||||
|
||||
let handle: Database.Database;
|
||||
const migration = migrations.find(
|
||||
(entry) => entry.name === 'durable leaderboard report identity and sequences'
|
||||
)!;
|
||||
|
||||
beforeEach(() => {
|
||||
handle = new Database(':memory:');
|
||||
handle.pragma('foreign_keys = ON');
|
||||
runMigrations(handle, migrations.slice(0, migration.version - 1));
|
||||
handle.exec(`
|
||||
INSERT INTO leaderboard_matches
|
||||
(id,server,server_deleted_at,game,map,map_key,map_name,started_at,last_received_at,
|
||||
status,last_event,last_fingerprint,highest_round)
|
||||
VALUES (1,'old-server','2026-10-01T15:00:00.000Z','t5','zombie_moon','zombie_moon','Moon',
|
||||
'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z','completed','game_end','old',32);
|
||||
INSERT INTO leaderboard_match_players
|
||||
(match_id,guid,kills,first_seen_at,last_seen_at)
|
||||
VALUES (1,'player',1000,'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z');
|
||||
INSERT INTO match_chat(match_id,message_key,occurrence,entry)
|
||||
VALUES (1,'message',1,'{"message":"Saved chat"}');
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => handle.close());
|
||||
|
||||
function insertMatch(server: string, key: string | null): number {
|
||||
return Number(
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(server,game,map,map_key,map_name,started_at,last_received_at,last_event,
|
||||
last_fingerprint,reporter_match_key)
|
||||
VALUES (?,'t5','zombie_moon','zombie_moon','Moon',
|
||||
'2026-10-12T12:00:00.000Z','2026-10-12T12:00:00.000Z','game_start','start',?)`
|
||||
)
|
||||
.run(server, key).lastInsertRowid
|
||||
);
|
||||
}
|
||||
|
||||
it('upgrades version 17 without inventing reporter identity or changing historical matches', () => {
|
||||
const original = handle.prepare('SELECT * FROM leaderboard_matches').get();
|
||||
const players = handle.prepare('SELECT * FROM leaderboard_match_players').all();
|
||||
const chat = handle.prepare('SELECT * FROM match_chat').all();
|
||||
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_matches').get()).toEqual({
|
||||
...original!,
|
||||
reporter_match_key: null,
|
||||
last_seq: 0,
|
||||
report_count: 0
|
||||
});
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual(players);
|
||||
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('enforces reporter keys per server while preserving unkeyed legacy history', () => {
|
||||
runMigrations(handle, migrations);
|
||||
const first = insertMatch('server-a', 'cycle-1');
|
||||
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
|
||||
expect(() => insertMatch('server-b', 'cycle-1')).not.toThrow();
|
||||
expect(() => insertMatch('server-a', 'cycle-2')).not.toThrow();
|
||||
expect(() => insertMatch('server-a', null)).not.toThrow();
|
||||
expect(() => insertMatch('server-a', null)).not.toThrow();
|
||||
|
||||
handle
|
||||
.prepare('UPDATE leaderboard_matches SET server_deleted_at=? WHERE id=?')
|
||||
.run('2026-10-12T13:00:00.000Z', first);
|
||||
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
|
||||
});
|
||||
|
||||
it('stores received and applied sequence receipts once and removes them with retained matches', () => {
|
||||
runMigrations(handle, migrations);
|
||||
const id = insertMatch('server-a', 'cycle-1');
|
||||
const receipt = handle.prepare(
|
||||
'INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,?,?)'
|
||||
);
|
||||
receipt.run(id, 3, 1);
|
||||
receipt.run(id, 1, 0);
|
||||
expect(() => receipt.run(id, 3, 0)).toThrow('UNIQUE constraint failed');
|
||||
for (const seq of [0, -1, 1.5])
|
||||
expect(() => receipt.run(id, seq, 1)).toThrow('CHECK constraint failed');
|
||||
expect(() => receipt.run(id, 2, 2)).toThrow('CHECK constraint failed');
|
||||
expect(() => receipt.run(id + 100, 1, 1)).toThrow('FOREIGN KEY constraint failed');
|
||||
expect(
|
||||
handle.prepare('SELECT seq,applied FROM leaderboard_match_reports ORDER BY seq').all()
|
||||
).toEqual([
|
||||
{ seq: 1, applied: 0 },
|
||||
{ seq: 3, applied: 1 }
|
||||
]);
|
||||
|
||||
handle.prepare('DELETE FROM leaderboard_matches WHERE id=?').run(id);
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
|
||||
it('preserves reporter keys, counters and receipts when migration 18 is replayed', () => {
|
||||
runMigrations(handle, migrations);
|
||||
const id = insertMatch('server-a', 'cycle-1');
|
||||
handle.prepare('UPDATE leaderboard_matches SET last_seq=3,report_count=2 WHERE id=?').run(id);
|
||||
handle
|
||||
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,3,1)')
|
||||
.run(id);
|
||||
handle
|
||||
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,0)')
|
||||
.run(id);
|
||||
const matches = handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
|
||||
const reports = handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all();
|
||||
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
||||
|
||||
migrateLeaderboardReports(handle);
|
||||
handle.pragma(`user_version = ${migration.version - 1}`);
|
||||
runMigrations(handle, migrations);
|
||||
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches);
|
||||
expect(handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all()).toEqual(
|
||||
reports
|
||||
);
|
||||
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
@@ -104,6 +104,44 @@ it('keeps unknown durations last in either direction and retains historical and
|
||||
});
|
||||
expect(data.matches.find((m) => m.id === 5)?.uniquePlayers).toBe(0);
|
||||
});
|
||||
it('filters missing end reports and exposes sequence gaps without completed results', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db()
|
||||
.prepare(
|
||||
`UPDATE leaderboard_matches
|
||||
SET status = 'end-report-missing', reporter_match_key = 'game-a', last_seq = 8, report_count = 6
|
||||
WHERE id = 4`
|
||||
)
|
||||
.run();
|
||||
const { serverMatchHistory, getMatchDetail } =
|
||||
await import('../../src/lib/server/fleet/match-history.js');
|
||||
const data = serverMatchHistory(undefined, 25, query('status=end-report-missing'));
|
||||
expect(data).toMatchObject({
|
||||
total: 1,
|
||||
matches: [
|
||||
{
|
||||
id: 4,
|
||||
status: 'end-report-missing',
|
||||
reporterMatchKey: 'game-a',
|
||||
missingReports: 2,
|
||||
observedRound: 5,
|
||||
finalRound: null,
|
||||
durationMs: null,
|
||||
endedAt: null,
|
||||
endPlayers: null
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(getMatchDetail(4)).toBeNull();
|
||||
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||
reporterMatchKey: null,
|
||||
missingReports: null
|
||||
});
|
||||
db().prepare('UPDATE leaderboard_matches SET report_count = 8 WHERE id = 4').run();
|
||||
expect(
|
||||
serverMatchHistory(undefined, 25, query('status=end-report-missing')).matches[0]
|
||||
).toMatchObject({ missingReports: 0 });
|
||||
});
|
||||
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
|
||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||
const { GET: instanceGET } =
|
||||
|
||||
@@ -255,15 +255,19 @@ it('prunes metrics and diagnostics while leaving unlimited histories intact', as
|
||||
expect(a.db.prepare('SELECT count(*) AS n FROM metric_samples').get()).toEqual({ n: 0 });
|
||||
});
|
||||
|
||||
it('prunes closed matches and audit events without deleting active matches or protected audit records', async () => {
|
||||
it('prunes closed matches and their receipts without deleting active matches or protected audit records', async () => {
|
||||
const a = await api();
|
||||
for (const status of ['active', 'completed', 'interrupted'])
|
||||
a.db
|
||||
for (const status of ['active', 'completed', 'interrupted', 'end-report-missing']) {
|
||||
const match = a.db
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
||||
VALUES('s','t5','kino','kino','Kino',?,?,?,'game_end','test')`
|
||||
)
|
||||
.run(old, old, status);
|
||||
a.db
|
||||
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,1)')
|
||||
.run(match.lastInsertRowid);
|
||||
}
|
||||
// Preview initializes the optional authentication schema.
|
||||
a.retentionPreview();
|
||||
a.db
|
||||
@@ -274,11 +278,14 @@ it('prunes closed matches and audit events without deleting active matches or pr
|
||||
.run(Date.parse(old));
|
||||
a.setRetentionControl({ category: 'audit', id: '1', protected: true });
|
||||
a.saveFleetSettings({ ...a.fleetSettings(), matchKeepDays: 1, auditKeepDays: 1 });
|
||||
expect(a.pruneHistory('match')).toBe(2);
|
||||
expect(a.pruneHistory('match')).toBe(3);
|
||||
expect(a.pruneHistory('audit')).toBe(1);
|
||||
expect(a.db.prepare('SELECT status FROM leaderboard_matches').all()).toEqual([
|
||||
{ status: 'active' }
|
||||
]);
|
||||
expect(a.db.prepare('SELECT match_id,seq FROM leaderboard_match_reports').all()).toEqual([
|
||||
{ match_id: 1, seq: 1 }
|
||||
]);
|
||||
expect(a.db.pragma('foreign_key_check')).toEqual([]);
|
||||
});
|
||||
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
|
||||
|
||||
@@ -18,6 +18,54 @@ afterEach(async () => fixture.cleanup());
|
||||
|
||||
const payload = gameEvent('round_change', 17);
|
||||
|
||||
it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)(
|
||||
'preserves optional reporter identity and sequence for %s',
|
||||
async (event) => {
|
||||
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
|
||||
const report = gameEvent(event);
|
||||
const match = ' opaque/Match:123 ';
|
||||
expect(parseGameEvent({ ...report, match, seq: 7 })).toEqual({
|
||||
...report,
|
||||
match,
|
||||
seq: 7
|
||||
});
|
||||
expect(parseGameEvent({ ...report, match })).toEqual({ ...report, match });
|
||||
expect(parseGameEvent({ ...report, seq: 7 })).toEqual({ ...report, seq: 7 });
|
||||
expect(parseGameEvent(report)).toEqual(report);
|
||||
const response = await post({ ...report, match, seq: 7 });
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).telemetry).toMatchObject({ match, seq: 7 });
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()[0]).toMatchObject({ match, seq: 7 });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ match: '' },
|
||||
{ match: ' \t\n' },
|
||||
{ match: 'x'.repeat(65) },
|
||||
{ match: null },
|
||||
{ match: 123 },
|
||||
{ seq: 0 },
|
||||
{ seq: -1 },
|
||||
{ seq: 1.5 },
|
||||
{ seq: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ seq: null },
|
||||
{ seq: '1' }
|
||||
])('rejects malformed optional reporter metadata: %j', async (metadata) => {
|
||||
const response = await post({ ...payload, ...metadata });
|
||||
expect(response.status).toBe(400);
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts the maximum reporter key length and safe sequence number', async () => {
|
||||
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
|
||||
expect(
|
||||
parseGameEvent({ ...payload, match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER })
|
||||
).toMatchObject({ match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER });
|
||||
});
|
||||
|
||||
it.each(['game_end', 'round reset', 'map change'])(
|
||||
'drops the previous match timing when game_start is missed after %s',
|
||||
async (boundary) => {
|
||||
|
||||
@@ -17,6 +17,8 @@ const match: MatchDetail = {
|
||||
observedRound: 4,
|
||||
finalRound: 4,
|
||||
status: 'completed',
|
||||
reporterMatchKey: null,
|
||||
missingReports: null,
|
||||
startObserved: true,
|
||||
startedAt: '2026-10-07T00:00:00Z',
|
||||
lastReportedAt: '2026-10-07T00:01:00Z',
|
||||
|
||||
@@ -16,6 +16,8 @@ const match: ServerMatch = {
|
||||
observedRound: 20,
|
||||
finalRound: 20,
|
||||
status: 'completed',
|
||||
reporterMatchKey: null,
|
||||
missingReports: null,
|
||||
startedAt: '2026-10-06T17:26:00Z',
|
||||
lastReportedAt: '2026-10-06T19:59:59Z',
|
||||
endedAt: '2026-10-06T18:04:00Z',
|
||||
@@ -154,6 +156,39 @@ it('labels unfinished rounds and missing durations without inventing final resul
|
||||
expect(body).toContain('>—</span>');
|
||||
expect(body).toContain('>0</td>');
|
||||
});
|
||||
it('labels and filters missing end reports and shows sequence gaps on desktop and mobile', () => {
|
||||
scope('?matchStatus=end-report-missing');
|
||||
const data = report();
|
||||
data.matches = [
|
||||
{
|
||||
...match,
|
||||
status: 'end-report-missing',
|
||||
reporterMatchKey: 'game-a',
|
||||
missingReports: 2,
|
||||
finalRound: null,
|
||||
durationMs: null
|
||||
}
|
||||
];
|
||||
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
|
||||
/<!--.*?-->/g,
|
||||
''
|
||||
);
|
||||
for (const suffix of ['', '-mobile']) {
|
||||
const select = body.match(
|
||||
new RegExp(`<select\\b[^>]*id="match-status${suffix}"[^>]*>([\\s\\S]*?)</select>`)
|
||||
)?.[1];
|
||||
expect(select).toContain(
|
||||
'<option value="end-report-missing" selected="">End report missing</option>'
|
||||
);
|
||||
}
|
||||
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||||
expect(rows.match(/End report missing/g)).toHaveLength(2);
|
||||
expect(rows.match(/2 reports missing/g)).toHaveLength(2);
|
||||
expect(rows).toContain('Highest observed');
|
||||
expect(rows).not.toContain('<a');
|
||||
expect(rows).not.toContain('In progress');
|
||||
expect(rows).not.toContain('38m');
|
||||
});
|
||||
it.each([
|
||||
'2026-10-06T19:58:29Z',
|
||||
'2026-10-05T18:00:00Z',
|
||||
|
||||
@@ -143,7 +143,7 @@ it('renders a known player without qualifying history with identity and unavaila
|
||||
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||
expect(text).toContain('New player');
|
||||
expect(text).toContain('No qualifying match history yet');
|
||||
expect(text).toContain('Stats appear after a recorded game ends');
|
||||
expect(text).toContain("Stats appear after a game's end report arrives");
|
||||
expect(text).toContain('observed at round 2 or later');
|
||||
expect(text).toContain('Lifetime best round —');
|
||||
expect(text).toContain('Best game kills —');
|
||||
|
||||
Reference in New Issue
Block a user