import { randomUUID } from 'node:crypto'; import type Database from 'better-sqlite3'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { AuthUser } from '../../src/lib/auth.js'; import { matchApiParams, matchHistoryHref, readMatchQuery } from '../../src/lib/match-history-query.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; let handle: Database.Database; const server = 't5_zombie_theater_1'; const clock = '2026-10-17T12:00:00.000Z'; const end = '2026-10-17T12:01:00.000Z'; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); const { db } = await import('../../src/lib/server/db/index.js'); handle = db(); handle .prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')") .run(server); for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer']) handle .prepare( `INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at) VALUES(?,?,?,'unused',?,0,0)` ) .run(role, role, role, role); handle .prepare( "INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)" ) .run(clock, end); const insert = handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint) VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`); for (const [id, status, round] of [ [1, 'completed', 1], [2, 'interrupted', 1], [3, 'active', 0], [4, 'end-report-missing', 2], [5, 'completed', 8], [6, 'completed', 1] ] as const) { insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round); handle .prepare( `INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at) VALUES(?,'123',?,1,?,?,?)` ) .run(id, round, status === 'completed' ? 1 : 0, clock, end); handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run( id, `message-${id}`, 1, JSON.stringify({ type: 'message', message: { time: '1:00', guid: '123', slot: 1, player: 'Player', message: `Message from match ${id}` }, context: { realTime: null, matchElapsedMs: null, round: null } }) ); } }); afterEach(async () => fixture.cleanup()); async function user(role: string): Promise { const { getUser } = await import('../../src/lib/server/auth/index.js'); return getUser(role)!; } async function api(actor: AuthUser | null, scoped = false, archive = '1') { const url = new URL(`http://localhost/api/matches?archive=${archive}`); if (scoped) { const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js'); return GET({ url, params: { name: server }, locals: { user: actor } }); } const { GET } = await import('../../src/routes/api/matches/+server.js'); return GET({ url, locals: { user: actor } }); } async function page( actor: AuthUser | null, surface: 'list' | 'detail' | 'instance', id = '1', archive = '1' ) { const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`); const setHeaders = vi.fn(); let result; if (surface === 'list') { const { load } = await import('../../src/routes/matches/+page.server.js'); result = load({ url, locals: { user: actor }, setHeaders }); } else if (surface === 'detail') { const { load } = await import('../../src/routes/matches/[id]/+page.server.js'); result = load({ url, params: { id }, locals: { user: actor }, setHeaders }); } else { const { load } = await import('../../src/routes/servers/[name]/+page.server.js'); result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders }); } return { result, setHeaders }; } it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => { expect(readMatchQuery(new URLSearchParams()).archive).toBe(false); expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false); expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true); for (const value of ['true', 'yes', '2']) expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow( 'Invalid match history filters' ); const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted'); const href = matchHistoryHref(url, { archive: '1' }); expect(href).toContain('matchArchive=1'); expect(href).not.toContain('matchPage'); expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1'); }); it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => { const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js'); const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); const publicBefore = getLeaderboard(); expect(publicBefore.entries[0].gamesPlayed).toBe(1); expect(serverMatchHistory().total).toBe(2); const archive = readMatchQuery(new URLSearchParams('archive=1')); expect(serverMatchHistory(undefined, 25, archive).total).toBe(6); expect(serverMatchHistory(server, 25, archive).total).toBe(5); expect( serverMatchHistory( server, 25, readMatchQuery(new URLSearchParams('archive=1&status=interrupted')) ).matches.map((item) => item.id) ).toEqual([2]); for (const id of [1, 2, 3, 4, 6]) { expect(getMatchDetail(id)).toBeNull(); expect(getMatchDetail(id, true)?.chat).toMatchObject([ { message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` } ]); } for (const id of [2, 3, 4]) expect(getMatchDetail(id, true)).toMatchObject({ finalRound: null, durationMs: null, endedAt: null, endPlayers: null, players: [{ presentAtEnd: false }] }); expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 }); expect(getLeaderboard()).toEqual(publicBefore); handle.prepare('DELETE FROM servers WHERE name=?').run(server); expect(getMatchDetail(2, true)?.chat).toHaveLength(1); }); it.each(['admin', 'moderator', 'super_viewer'])( 'allows %s archive access with private responses on every API and page surface', async (role) => { const actor = await user(role); for (const scoped of [false, true]) { const response = await api(actor, scoped); expect(response.status).toBe(200); expect(response.headers.get('Cache-Control')).toBe('no-store'); expect((await response.json()).total).toBe(scoped ? 5 : 6); } for (const surface of ['list', 'detail', 'instance'] as const) { const loaded = await page(actor, surface); expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' }); if (surface === 'detail') expect(loaded.result).toMatchObject({ match: { id: 1, status: 'completed', finalRound: 1 } }); } } ); it.each(['operator', 'viewer', null])( 'rejects %s expanded access before revealing match existence', async (role) => { const actor = role ? await user(role) : null; for (const scoped of [false, true]) { const response = await api(actor, scoped); expect(response.status).toBe(403); expect(response.headers.get('Cache-Control')).toBe('no-store'); } for (const surface of ['list', 'detail', 'instance'] as const) await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 }); await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 }); expect((await api(actor, false, '0')).status).toBe(200); await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 }); expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } }); } ); it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])( 'revalidates stale moderator locals after %s', async (change) => { const actor = await user('moderator'); handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run(); expect((await api(actor)).status).toBe(403); expect((await api(actor, true)).status).toBe(403); for (const surface of ['list', 'detail', 'instance'] as const) await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 }); } ); it('returns validation and not-found errors without silently falling back to the standard history', async () => { const actor = await user('moderator'); expect((await api(actor, false, 'true')).status).toBe(400); for (const surface of ['list', 'detail', 'instance'] as const) await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 }); for (const id of ['0', '01', '-1', '1.5', '999']) await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 }); }); it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => { const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js'); const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js'); const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js'); const entry = getMatchDetail(2, true)!.chat[0]; const { id } = mutateModeration('moderator', { op: 'create-case', game: 't5', guid: '123', title: 'Interrupted match report', evidence: { matchId: 2, messageIds: [entry.messageId] }, idempotencyKey: randomUUID() }); handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run(); const evidence = moderationCaseDetail('moderator', id).case.evidence[0]; expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2'); });