Files
electric-cherry/tests/unit/match-history-query.test.ts
T

146 lines
6.4 KiB
TypeScript

import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import {
readMatchQuery,
matchHistoryHref,
matchApiParams
} from '../../src/lib/match-history-query.js';
let fixture: Fixture;
const now = Date.parse('2026-10-06T20:00:00Z');
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db();
const match =
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', 'test')`);
const player = handle.prepare(
`INSERT INTO leaderboard_match_players (match_id, guid, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?)`
);
for (let id = 1; id <= 40; id++) {
const start = new Date(now - id * 86400000).toISOString();
const end = new Date(now - id * 86400000 + id * 60000).toISOString();
const game = id % 2 ? 't5' : 't6';
const name = game === 't5' ? 'Kino Der Toten' : 'Town Survival';
const map = game === 't5' ? 'theater' : 'town';
match.run(
id,
`${game}_zombie_${map}_1`,
game,
map,
`${game}:${map}`,
name,
start,
end,
end,
id === 2 ? 'active' : id === 4 ? 'interrupted' : 'completed',
id === 3 ? 0 : 1,
id + 1
);
for (let n = 0; n < id % 5; n++) player.run(id, String(n), start, end);
}
handle
.prepare('UPDATE leaderboard_matches SET server_deleted_at = ? WHERE id = 1')
.run(new Date(now).toISOString());
});
afterEach(async () => fixture.cleanup());
const query = (value = '') => readMatchQuery(new URLSearchParams(value));
it('sorts the whole history before paging, keeps ties deterministic, and clamps empty pages', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const first = serverMatchHistory(undefined, 25, query());
const second = serverMatchHistory(undefined, 25, query('page=2'));
expect(first.total).toBe(40);
expect(first.matches.map((m) => m.id)).toEqual(Array.from({ length: 25 }, (_, i) => i + 1));
expect(second.matches.map((m) => m.id)).toEqual(Array.from({ length: 15 }, (_, i) => i + 26));
expect(serverMatchHistory(undefined, 25, query('page=999')).page).toBe(2);
expect(serverMatchHistory(undefined, 25, query('sort=round')).matches[0].id).toBe(40);
expect(
serverMatchHistory(undefined, 25, query('sort=players'))
.matches.slice(0, 3)
.map((m) => m.id)
).toEqual([39, 34, 29]);
});
it('combines game, map, readable instance search, status and date filters before counting', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
const data = serverMatchHistory(
undefined,
25,
query('game=t6&map=t6%3Atown&q=Town+Lobby+1&status=completed&days=7'),
now
);
expect(data.total).toBe(1);
expect(data.matches.map((m) => m.id)).toEqual([6]);
expect(data.maps).toEqual([{ key: 't6:town', name: 'Town Survival', game: 't6' }]);
expect(serverMatchHistory(undefined, 25, query('q=%25')).total).toBe(0);
expect(serverMatchHistory('t5_zombie_theater_1', 25, query()).total).toBe(20);
const empty = serverMatchHistory(undefined, 25, query('q=missing&page=5'));
expect(empty).toMatchObject({ total: 0, matches: [], page: 1 });
});
it('keeps unknown durations last in either direction and retains historical and unfinished semantics', async () => {
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
for (const direction of ['asc', 'desc']) {
const data = serverMatchHistory(
undefined,
100,
query(`sort=duration&direction=${direction}`)
);
expect(data.matches.slice(-3).map((m) => m.durationMs)).toEqual([null, null, null]);
}
const data = serverMatchHistory();
expect(data.matches[0].server_deleted_at).toBeTruthy();
expect(data.matches[1]).toMatchObject({
status: 'active',
observedRound: 3,
finalRound: null,
durationMs: null
});
expect(data.matches[3]).toMatchObject({
status: 'interrupted',
observedRound: 5,
finalRound: null,
durationMs: null
});
expect(data.matches.find((m) => m.id === 5)?.uniquePlayers).toBe(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 } =
await import('../../src/routes/api/servers/[name]/matches/+server.js');
for (const value of [
'page=-1',
'page=1.5',
'sort=sql',
'direction=sideways',
'game=t7',
'days=3',
'status=deleted',
'limit=0'
]) {
const url = new URL(`http://localhost/api/matches?${value}`);
expect(GET({ url }).status).toBe(400);
expect(instanceGET({ url, params: { name: 'anything' } }).status).toBe(400);
}
const { load } = await import('../../src/routes/matches/+page.server.js');
const url = new URL('http://localhost/matches?matchPage=2&matchSort=round');
const loaded = load({ url }).history;
const body = await GET({
url: new URL(`http://localhost/api/matches?${matchApiParams(url)}`)
}).json();
expect(loaded).toEqual(body);
expect(body.matches[0].id).toBe(15);
});
it('keeps return context and sorting while resetting page for a filter change', () => {
const url = new URL(
'http://localhost/servers/t5_zombie_theater_1?tab=matches&returnTo=%2Fhosts&matchSort=duration&matchDirection=asc&matchPage=3&matches=100'
);
const href = matchHistoryHref(url, { status: 'completed' });
expect(href).toContain('tab=matches');
expect(href).toContain('returnTo=%2Fhosts');
expect(href).toContain('matchSort=duration');
expect(href).toContain('matchDirection=asc');
expect(href).not.toContain('matchPage');
expect(href).not.toContain('matches=100');
expect(matchHistoryHref(url, { page: '4' })).toContain('matchPage=4');
});