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 => ({ 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 }) ); } }); });