331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
let received = 0;
|
|
const server = 't5_zombie_theater_1';
|
|
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
|
|
const player = (guid: string, stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
|
|
guid,
|
|
name: `Player ${guid}`,
|
|
kills: 0,
|
|
headshots: 0,
|
|
downs: 0,
|
|
revives: 0,
|
|
score: 0,
|
|
...stats
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
received = 0;
|
|
fixture = await newFixture([]);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db()
|
|
.prepare("INSERT INTO servers (name, game, map) VALUES (?, 't5', 'zombie_theater')")
|
|
.run(server);
|
|
});
|
|
afterEach(async () => fixture.cleanup());
|
|
|
|
async function report(event: GameEvent['event'], round = 0, players = [player('101')]) {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { recordLeaderboardTelemetry } =
|
|
await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
|
const common = { server, map: 'zombie_theater', receivedAt: timestamp(received++) };
|
|
recordLeaderboardTelemetry(
|
|
db(),
|
|
event === 'game_start'
|
|
? { ...common, event, players: players.map(({ guid, name }) => ({ guid, name })) }
|
|
: { ...common, event, round, players }
|
|
);
|
|
}
|
|
|
|
describe('completed match details', () => {
|
|
it('returns the history summary and every participant with recorded counters, including departed and late players', async () => {
|
|
await report('game_start', 0, [player('101'), player('202'), player('303')]);
|
|
await report('round_change', 2, [
|
|
player('101', { kills: 30, headshots: 10, downs: 1, revives: 2, score: 2000 }),
|
|
player('202', { kills: 15, score: 1500 })
|
|
]);
|
|
await report('heartbeat', 2, [
|
|
player('101', { kills: 40, headshots: 12, downs: 1, revives: 2, score: 1000 })
|
|
]);
|
|
await report('game_end', 4, [
|
|
player('101', { kills: 50, headshots: 20, downs: 2, revives: 3, score: 500 }),
|
|
player('404')
|
|
]);
|
|
const { getMatchDetail, serverMatchHistory } =
|
|
await import('../../src/lib/server/fleet/match-history.js');
|
|
const match = getMatchDetail(1)!;
|
|
const { game, startObserved, players, chat, ...summary } = match;
|
|
expect(chat).toEqual([]);
|
|
expect({ ...summary, game }).toEqual(serverMatchHistory(server).matches[0]);
|
|
expect({ ...summary, game, startObserved }).toMatchObject({
|
|
id: 1,
|
|
game: 't5',
|
|
startObserved: true,
|
|
server,
|
|
status: 'completed',
|
|
startedAt: timestamp(0),
|
|
endedAt: timestamp(3),
|
|
durationMs: 180_000,
|
|
startPlayers: 3,
|
|
uniquePlayers: 4,
|
|
endPlayers: 2,
|
|
finalRound: 4
|
|
});
|
|
expect(players.map((entry) => entry.guid)).toEqual(['101', '202', '404', '303']);
|
|
expect(players[0]).toEqual({
|
|
guid: '101',
|
|
name: 'Player 101',
|
|
hasStats: true,
|
|
presentAtEnd: true,
|
|
canViewProfile: true,
|
|
firstSeenAt: timestamp(0),
|
|
lastSeenAt: timestamp(3),
|
|
highestRound: 4,
|
|
roundsSurvived: 3,
|
|
kills: 50,
|
|
headshots: 20,
|
|
downs: 2,
|
|
revives: 3,
|
|
bestScore: 2000,
|
|
scoreTotal: null
|
|
});
|
|
expect(players[1]).toEqual({
|
|
guid: '202',
|
|
name: 'Player 202',
|
|
hasStats: true,
|
|
presentAtEnd: false,
|
|
canViewProfile: true,
|
|
firstSeenAt: timestamp(0),
|
|
lastSeenAt: timestamp(1),
|
|
highestRound: 2,
|
|
roundsSurvived: 1,
|
|
kills: 15,
|
|
headshots: 0,
|
|
downs: 0,
|
|
revives: 0,
|
|
bestScore: 1500,
|
|
scoreTotal: null
|
|
});
|
|
expect(players[2]).toMatchObject({
|
|
guid: '404',
|
|
presentAtEnd: true,
|
|
highestRound: 4,
|
|
roundsSurvived: 0,
|
|
firstSeenAt: timestamp(3),
|
|
lastSeenAt: timestamp(3),
|
|
kills: 0,
|
|
bestScore: 0
|
|
});
|
|
expect(players[3]).toEqual({
|
|
guid: '303',
|
|
name: 'Player 303',
|
|
hasStats: false,
|
|
presentAtEnd: false,
|
|
canViewProfile: true,
|
|
firstSeenAt: timestamp(0),
|
|
lastSeenAt: timestamp(0),
|
|
highestRound: null,
|
|
roundsSurvived: null,
|
|
kills: null,
|
|
headshots: null,
|
|
downs: null,
|
|
revives: null,
|
|
bestScore: null,
|
|
scoreTotal: null
|
|
});
|
|
});
|
|
|
|
it('preserves an unknown start and an observed empty ending roster across database reopen', async () => {
|
|
await report('heartbeat', 5, [player('101', { kills: 40 })]);
|
|
await report('game_end', 6, []);
|
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
|
closeDb();
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
expect(getMatchDetail(1)).toMatchObject({
|
|
startObserved: false,
|
|
startedAt: timestamp(0),
|
|
startPlayers: null,
|
|
durationMs: null,
|
|
endedAt: timestamp(1),
|
|
endPlayers: 0,
|
|
uniquePlayers: 1,
|
|
finalRound: 6,
|
|
players: [
|
|
expect.objectContaining({
|
|
guid: '101',
|
|
presentAtEnd: false,
|
|
highestRound: 5,
|
|
kills: 40,
|
|
roundsSurvived: 0
|
|
})
|
|
]
|
|
});
|
|
});
|
|
|
|
it('loads an empty completed record without inventing player rows', async () => {
|
|
await report('game_start', 0, []);
|
|
await report('game_end', 2, []);
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
expect(getMatchDetail(1)).toMatchObject({
|
|
startPlayers: 0,
|
|
uniquePlayers: 0,
|
|
endPlayers: 0,
|
|
startObserved: true,
|
|
durationMs: 60_000,
|
|
finalRound: 2,
|
|
players: []
|
|
});
|
|
});
|
|
|
|
it('preserves original match and server identity after deletion and reuse of the server name', async () => {
|
|
await report('game_start');
|
|
await report('game_end', 8, [player('101', { kills: 80 })]);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
const before = getMatchDetail(1)!;
|
|
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
|
|
const deleted = getMatchDetail(1)!;
|
|
expect(deleted).toEqual({ ...before, server_deleted_at: expect.any(String) });
|
|
db()
|
|
.prepare("INSERT INTO servers (name, game, map) VALUES (?, 't6', 'zm_transit')")
|
|
.run(server);
|
|
const { recordLeaderboardTelemetry } =
|
|
await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
|
recordLeaderboardTelemetry(db(), {
|
|
event: 'game_end',
|
|
server,
|
|
map: 'zm_transit',
|
|
round: 20,
|
|
players: [player('999', { kills: 999 })],
|
|
receivedAt: timestamp(4)
|
|
});
|
|
expect(getMatchDetail(1)).toEqual(deleted);
|
|
expect(getMatchDetail(2)).toMatchObject({
|
|
id: 2,
|
|
game: 't6',
|
|
server_deleted_at: null,
|
|
finalRound: 20,
|
|
players: [expect.objectContaining({ guid: '999', kills: 999 })]
|
|
});
|
|
});
|
|
|
|
it('uses the latest shared player name and only offers profiles that actually exist', async () => {
|
|
await report('game_start', 0, [player('101'), player('202'), player('303')]);
|
|
await report('round_change', 1, [player('101'), player('202')]);
|
|
await report('game_end', 2, [player('101')]);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const handle = db();
|
|
handle
|
|
.prepare(
|
|
`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
|
VALUES ('t6', '101', 'Latest name', ?, ?), ('t7', '101', 'Unsupported name', ?, ?)`
|
|
)
|
|
.run(timestamp(0), timestamp(4), timestamp(0), timestamp(5));
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
|
let participants = getMatchDetail(1)!.players;
|
|
expect(participants.find((entry) => entry.guid === '101')).toMatchObject({
|
|
name: 'Latest name',
|
|
canViewProfile: true
|
|
});
|
|
expect(participants.find((entry) => entry.guid === '202')).toMatchObject({
|
|
hasStats: true,
|
|
highestRound: 1,
|
|
canViewProfile: true
|
|
});
|
|
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
|
|
hasStats: false,
|
|
canViewProfile: true
|
|
});
|
|
for (const participant of participants)
|
|
expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null);
|
|
|
|
// A start-only participant has an empty profile until another match qualifies.
|
|
await report('game_start', 0, [player('303')]);
|
|
await report('heartbeat', 3, [player('303')]);
|
|
expect(getMatchDetail(1)!.players.find((entry) => entry.guid === '303')).toMatchObject({
|
|
canViewProfile: true
|
|
});
|
|
expect(getPlayerProfile('303')).toMatchObject({ records: null, matches: [] });
|
|
await report('game_end', 3, [player('303')]);
|
|
participants = getMatchDetail(1)!.players;
|
|
expect(participants.find((entry) => entry.guid === '303')).toMatchObject({
|
|
hasStats: false,
|
|
canViewProfile: true
|
|
});
|
|
expect(getPlayerProfile('303')).toMatchObject({ stats: { gamesPlayed: 1 } });
|
|
|
|
// Missing identities must not discard a saved roster row or produce a dead link.
|
|
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
|
|
expect(getMatchDetail(1)!.players.find((entry) => entry.guid === '101')).toMatchObject({
|
|
name: '101',
|
|
canViewProfile: false
|
|
});
|
|
handle.prepare("UPDATE leaderboard_matches SET game = 'custom' WHERE id = 2").run();
|
|
handle.prepare("UPDATE leaderboard_players SET game = 'custom' WHERE guid = '303'").run();
|
|
expect(getMatchDetail(2)!.players[0]).toMatchObject({
|
|
name: 'Player 303',
|
|
hasStats: true,
|
|
canViewProfile: false
|
|
});
|
|
});
|
|
|
|
it('only exposes completed records at round 2 or higher and rejects invalid IDs', async () => {
|
|
await report('game_start');
|
|
await report('game_end', 1);
|
|
await report('game_start');
|
|
await report('round_change', 2);
|
|
await report('game_start');
|
|
await report('round_change', 3);
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
for (const id of [1, 2, 3, 999, 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
|
expect(getMatchDetail(id), String(id)).toBeNull();
|
|
}
|
|
await report('game_end', 3);
|
|
expect(getMatchDetail(3)).toMatchObject({ id: 3, status: 'completed', finalRound: 3 });
|
|
});
|
|
});
|
|
|
|
describe('match detail page load', () => {
|
|
it('loads a completed match directly by its durable ID', async () => {
|
|
await report('game_end', 2);
|
|
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
|
expect(load({ params: { id: '1' } })).toEqual({ match: getMatchDetail(1) });
|
|
});
|
|
|
|
it('returns 404 for malformed, missing, active, interrupted, and nonqualifying matches', async () => {
|
|
await report('game_end', 1);
|
|
await report('game_start');
|
|
await report('round_change', 2);
|
|
await report('game_start');
|
|
await report('round_change', 2);
|
|
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
|
for (const id of [
|
|
'1',
|
|
'2',
|
|
'3',
|
|
'999',
|
|
'',
|
|
'0',
|
|
'-1',
|
|
'1.0',
|
|
'01',
|
|
'1e0',
|
|
'1x',
|
|
' 1',
|
|
'Infinity',
|
|
'9007199254740992',
|
|
"1' OR 1=1 --"
|
|
]) {
|
|
expect(() => load({ params: { id } }), id).toThrow(
|
|
expect.objectContaining({ status: 404 })
|
|
);
|
|
}
|
|
});
|
|
});
|