Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -3,8 +3,13 @@ import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import type { LeaderboardResponse } from '../../src/lib/leaderboard.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fixture = await newFixture([]);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
async function seedRecords() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
@@ -12,16 +17,47 @@ async function seedRecords() {
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', ?, ?, ?, ?, ?, ?, ?, ?, 'round_change', 'test')`);
|
||||
const player = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
const player =
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, '2026-09-01T00:00:00.000Z', '2026-09-05T00:00:00.000Z')`);
|
||||
const record = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, kills, highest_round, rounds_survived, headshots, revives, downs, best_score,
|
||||
completed, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
|
||||
const iso = (day: number) => `2026-09-0${day}T00:00:00.000Z`;
|
||||
match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed', 10);
|
||||
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'completed', 8);
|
||||
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed', 4);
|
||||
match.run(
|
||||
1,
|
||||
't5',
|
||||
'zombie_theater',
|
||||
't5:theater',
|
||||
'Kino Der Toten',
|
||||
iso(1),
|
||||
iso(2),
|
||||
'completed',
|
||||
10
|
||||
);
|
||||
match.run(
|
||||
2,
|
||||
't5',
|
||||
'zombie_theater',
|
||||
't5:theater',
|
||||
'Kino Der Toten',
|
||||
iso(2),
|
||||
iso(3),
|
||||
'completed',
|
||||
8
|
||||
);
|
||||
match.run(
|
||||
3,
|
||||
't5',
|
||||
'zombie_coast',
|
||||
't5:coast',
|
||||
'Call of the Dead',
|
||||
iso(3),
|
||||
iso(4),
|
||||
'completed',
|
||||
4
|
||||
);
|
||||
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed', 20);
|
||||
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'completed', 15);
|
||||
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'completed', 2);
|
||||
@@ -50,30 +86,56 @@ describe('persisted leaderboard queries', () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
|
||||
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
|
||||
const boards = leaderboardMetrics.map(({ key }) =>
|
||||
getLeaderboard({ metric: key, limit: 2, offset: 1 })
|
||||
);
|
||||
const profile = getPlayerProfile('101');
|
||||
for (const [index, status] of ['active', 'interrupted'].entries()) {
|
||||
const id = index + 10;
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', 't5', ?, ?, 'Unfinished Map',
|
||||
'2026-09-20', '2026-09-20', ?, 99, 'heartbeat', 'unfinished')`)
|
||||
'2026-09-20', '2026-09-20', ?, 99, 'heartbeat', 'unfinished')`
|
||||
)
|
||||
.run(id, status, `t5:${status}`, status);
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t5', ?, ?, '2026-09-20', '2026-09-20')`).run(status, status);
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t5', ?, ?, '2026-09-20', '2026-09-20')`
|
||||
)
|
||||
.run(status, status);
|
||||
for (const guid of ['101', status]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, rounds_survived, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 99, 99, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
VALUES (?, ?, 99, 99, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`
|
||||
)
|
||||
.run(id, guid);
|
||||
}
|
||||
expect(getPlayerProfile(status)).toBeNull();
|
||||
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({
|
||||
entries: [],
|
||||
total: 0,
|
||||
summary: {
|
||||
players: 0,
|
||||
matches: 0,
|
||||
kills: 0,
|
||||
highestRound: 0,
|
||||
firstTrackedAt: null,
|
||||
updatedAt: null
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
|
||||
expect(
|
||||
leaderboardMetrics.map(({ key }) =>
|
||||
getLeaderboard({ metric: key, limit: 2, offset: 1 })
|
||||
)
|
||||
).toEqual(boards);
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
});
|
||||
|
||||
@@ -83,7 +145,9 @@ describe('persisted leaderboard queries', () => {
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
|
||||
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
|
||||
const boards = leaderboardMetrics.map(({ key }) =>
|
||||
getLeaderboard({ metric: key, limit: 2, offset: 1 })
|
||||
);
|
||||
const profile = getPlayerProfile('101');
|
||||
const history = serverMatchHistory(undefined, 2);
|
||||
const scopedHistory = serverMatchHistory('removed-server', 2);
|
||||
@@ -92,24 +156,45 @@ describe('persisted leaderboard queries', () => {
|
||||
for (const game of ['t5', 't6']) {
|
||||
for (const status of ['active', 'completed', 'interrupted']) {
|
||||
for (const round of [0, 1]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', ?, 'reset_map', ?, 'Reset Map',
|
||||
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`).run(id, game, `${game}:reset`, status, round);
|
||||
'2026-09-20', '2026-09-20', ?, ?, 'game_end', 'reset')`
|
||||
)
|
||||
.run(id, game, `${game}:reset`, status, round);
|
||||
// A historical record with large counters must not change rankings or bests.
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
VALUES (?, '101', ?, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`
|
||||
)
|
||||
.run(id++, round);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
|
||||
expect(
|
||||
leaderboardMetrics.map(({ key }) =>
|
||||
getLeaderboard({ metric: key, limit: 2, offset: 1 })
|
||||
)
|
||||
).toEqual(boards);
|
||||
expect(getLeaderboard({ game: 't5', map: 't5:theater' })).toEqual(scopedBoard);
|
||||
expect(getLeaderboard({ map: 't5:reset' })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
expect(getLeaderboard({ map: 't5:reset' })).toMatchObject({
|
||||
entries: [],
|
||||
total: 0,
|
||||
summary: {
|
||||
players: 0,
|
||||
matches: 0,
|
||||
kills: 0,
|
||||
highestRound: 0,
|
||||
firstTrackedAt: null,
|
||||
updatedAt: null
|
||||
}
|
||||
});
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
expect(serverMatchHistory(undefined, 2)).toEqual(history);
|
||||
expect(serverMatchHistory('removed-server', 2)).toEqual(scopedHistory);
|
||||
@@ -119,23 +204,51 @@ describe('persisted leaderboard queries', () => {
|
||||
await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const board = getLeaderboard();
|
||||
expect(board.entries.filter(entry => entry.guid === '101')).toEqual([{
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||
headshotRate: 320 / 770, killsPerDown: 770 / 4, averageKills: 770 / 4,
|
||||
averageRounds: 38 / 4, completionRate: 3 / 4,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||
expect(board.entries.filter((entry) => entry.guid === '101')).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
guid: '101',
|
||||
name: 'Ace',
|
||||
highestRound: 20,
|
||||
kills: 770,
|
||||
roundsSurvived: 38,
|
||||
headshots: 320,
|
||||
revives: 8,
|
||||
downs: 4,
|
||||
gamesPlayed: 4,
|
||||
gamesCompleted: 3,
|
||||
bestKills: 500,
|
||||
bestScore: 5000,
|
||||
headshotRate: 320 / 770,
|
||||
killsPerDown: 770 / 4,
|
||||
averageKills: 770 / 4,
|
||||
averageRounds: 38 / 4,
|
||||
completionRate: 3 / 4,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z',
|
||||
records: {
|
||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||
}
|
||||
}
|
||||
}]);
|
||||
]);
|
||||
expect(board.total).toBe(4);
|
||||
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
|
||||
expect(board.entries.some(entry => entry.guid === '404')).toBe(false);
|
||||
expect(board.summary).toEqual({ players: 4, matches: 5, kills: 1140, roundsSurvived: 68,
|
||||
highestRound: 20, firstTrackedAt: '2026-09-01T00:00:00.000Z', updatedAt: '2026-09-06T00:00:00.000Z' });
|
||||
expect(board.entries.map((entry) => [entry.guid, entry.rank])).toEqual([
|
||||
['101', 1],
|
||||
['505', 2],
|
||||
['202', 3],
|
||||
['303', 4]
|
||||
]);
|
||||
expect(board.entries.some((entry) => entry.guid === '404')).toBe(false);
|
||||
expect(board.summary).toEqual({
|
||||
players: 4,
|
||||
matches: 5,
|
||||
kills: 1140,
|
||||
roundsSurvived: 68,
|
||||
highestRound: 20,
|
||||
firstTrackedAt: '2026-09-01T00:00:00.000Z',
|
||||
updatedAt: '2026-09-06T00:00:00.000Z'
|
||||
});
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(board.entries[0]).toMatchObject(getPlayerProfile('101')!.stats);
|
||||
});
|
||||
@@ -144,77 +257,161 @@ describe('persisted leaderboard queries', () => {
|
||||
await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const t5 = getLeaderboard({ game: 't5' });
|
||||
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
||||
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
||||
headshotRate: 120 / 270, killsPerDown: 270 / 3, averageKills: 270 / 3,
|
||||
averageRounds: 19 / 3, completionRate: 2 / 3,
|
||||
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
|
||||
expect(t5.entries.find((entry) => entry.guid === '101')).toEqual({
|
||||
rank: 1,
|
||||
guid: '101',
|
||||
name: 'Ace',
|
||||
highestRound: 10,
|
||||
kills: 270,
|
||||
roundsSurvived: 19,
|
||||
headshots: 120,
|
||||
revives: 4,
|
||||
downs: 3,
|
||||
gamesPlayed: 3,
|
||||
headshotRate: 120 / 270,
|
||||
killsPerDown: 270 / 3,
|
||||
averageKills: 270 / 3,
|
||||
averageRounds: 19 / 3,
|
||||
completionRate: 2 / 3,
|
||||
gamesCompleted: 2,
|
||||
bestKills: 150,
|
||||
bestScore: 3000,
|
||||
lastSeen: '2026-09-04T00:00:00.000Z',
|
||||
records: {
|
||||
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
|
||||
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
|
||||
bestScore: expect.objectContaining({ matchId: 2, value: 3000 })
|
||||
}
|
||||
});
|
||||
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
|
||||
expect(t5).toMatchObject({
|
||||
total: 3,
|
||||
summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 }
|
||||
});
|
||||
const t6 = getLeaderboard({ game: 't6' });
|
||||
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
||||
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
||||
headshotRate: 200 / 500, killsPerDown: 500, averageKills: 500,
|
||||
averageRounds: 19, completionRate: 1,
|
||||
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||
expect(t6.entries.find((entry) => entry.guid === '101')).toEqual({
|
||||
rank: 1,
|
||||
guid: '101',
|
||||
name: 'Ace',
|
||||
highestRound: 20,
|
||||
kills: 500,
|
||||
roundsSurvived: 19,
|
||||
headshots: 200,
|
||||
revives: 4,
|
||||
downs: 1,
|
||||
gamesPlayed: 1,
|
||||
headshotRate: 200 / 500,
|
||||
killsPerDown: 500,
|
||||
averageKills: 500,
|
||||
averageRounds: 19,
|
||||
completionRate: 1,
|
||||
gamesCompleted: 1,
|
||||
bestKills: 500,
|
||||
bestScore: 5000,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z',
|
||||
records: {
|
||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||
}
|
||||
});
|
||||
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
|
||||
expect(t6).toMatchObject({
|
||||
total: 2,
|
||||
summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 }
|
||||
});
|
||||
});
|
||||
|
||||
it('filters exact map variants and retains historical and custom map options independently of selection', async () => {
|
||||
await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const theater = getLeaderboard({ game: 't5', map: 't5:theater', metric: 'kills' });
|
||||
expect(theater.entries.map(entry => [entry.name, entry.kills])).toEqual([['Ace', 250], ['Blake', 100], ['Casey', 70]]);
|
||||
expect(theater.summary).toMatchObject({ players: 3, matches: 2, kills: 420, roundsSurvived: 32, highestRound: 10 });
|
||||
expect(theater.maps.map(map => map.key)).toEqual(['t5:coast', 't5:custom_map', 't5:theater']);
|
||||
expect(theater.entries.map((entry) => [entry.name, entry.kills])).toEqual([
|
||||
['Ace', 250],
|
||||
['Blake', 100],
|
||||
['Casey', 70]
|
||||
]);
|
||||
expect(theater.summary).toMatchObject({
|
||||
players: 3,
|
||||
matches: 2,
|
||||
kills: 420,
|
||||
roundsSurvived: 32,
|
||||
highestRound: 10
|
||||
});
|
||||
expect(theater.maps.map((map) => map.key)).toEqual([
|
||||
't5:coast',
|
||||
't5:custom_map',
|
||||
't5:theater'
|
||||
]);
|
||||
const town = getLeaderboard({ map: 't6:town' });
|
||||
expect(town.entries.map(entry => [entry.guid, entry.name, entry.kills])).toEqual([['101', 'Ace', 500]]);
|
||||
expect(getLeaderboard({ game: 't5', map: 't6:town' })).toMatchObject({ entries: [], total: 0, summary: { players: 0, kills: 0 } });
|
||||
expect(town.maps).toContainEqual({ key: 't6:farm', game: 't6', map: 'zm_transit', name: 'Farm Survival' });
|
||||
expect(town.entries.map((entry) => [entry.guid, entry.name, entry.kills])).toEqual([
|
||||
['101', 'Ace', 500]
|
||||
]);
|
||||
expect(getLeaderboard({ game: 't5', map: 't6:town' })).toMatchObject({
|
||||
entries: [],
|
||||
total: 0,
|
||||
summary: { players: 0, kills: 0 }
|
||||
});
|
||||
expect(town.maps).toContainEqual({
|
||||
key: 't6:farm',
|
||||
game: 't6',
|
||||
map: 'zm_transit',
|
||||
name: 'Farm Survival'
|
||||
});
|
||||
});
|
||||
|
||||
it('uses competition ranks with deterministic ties and searches after ranking before pagination', async () => {
|
||||
await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const board = getLeaderboard({ game: 't5' });
|
||||
expect(board.entries.map(entry => [entry.name, entry.rank])).toEqual([['Ace', 1], ['Blake', 1], ['Casey', 3]]);
|
||||
expect(board.entries.map((entry) => [entry.name, entry.rank])).toEqual([
|
||||
['Ace', 1],
|
||||
['Blake', 1],
|
||||
['Casey', 3]
|
||||
]);
|
||||
const search = getLeaderboard({ game: 't5', q: 'CASE', limit: 1 });
|
||||
expect(search.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
|
||||
expect(search.entries.map((entry) => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
|
||||
expect(search.total).toBe(1);
|
||||
expect(search.summary).toEqual(board.summary);
|
||||
expect(getLeaderboard({ game: 't5', q: 'no matching player' })).toMatchObject({
|
||||
entries: [], total: 0, summary: board.summary, maps: board.maps
|
||||
entries: [],
|
||||
total: 0,
|
||||
summary: board.summary,
|
||||
maps: board.maps
|
||||
});
|
||||
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
|
||||
.toMatchObject({ name: 'Blake', rank: 2 });
|
||||
expect(
|
||||
getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0]
|
||||
).toMatchObject({ name: 'Blake', rank: 2 });
|
||||
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 4 });
|
||||
});
|
||||
|
||||
it('uses and searches the latest shared name regardless of game or map filters without merging namesakes', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't6' AND guid = '101'").run();
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't6' AND guid = '101'"
|
||||
)
|
||||
.run();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
for (const options of [{}, { game: 't5' }, { game: 't6' }, { map: 't5:theater' }, { game: 't6', map: 't6:town' }]) {
|
||||
for (const options of [
|
||||
{},
|
||||
{ game: 't5' },
|
||||
{ game: 't6' },
|
||||
{ map: 't5:theater' },
|
||||
{ game: 't6', map: 't6:town' }
|
||||
]) {
|
||||
const board = getLeaderboard(options);
|
||||
expect(board.entries.find(entry => entry.guid === '101')?.name).toBe('Other game');
|
||||
expect(getLeaderboard({ ...options, q: 'other GAME' }).entries.map(entry => entry.guid)).toEqual(['101']);
|
||||
expect(board.entries.find((entry) => entry.guid === '101')?.name).toBe('Other game');
|
||||
expect(
|
||||
getLeaderboard({ ...options, q: 'other GAME' }).entries.map((entry) => entry.guid)
|
||||
).toEqual(['101']);
|
||||
expect(getLeaderboard({ ...options, q: 'Ace' }).entries).toEqual([]);
|
||||
}
|
||||
handle.prepare("UPDATE leaderboard_players SET name = 'Other game' WHERE guid = '202'").run();
|
||||
handle
|
||||
.prepare("UPDATE leaderboard_players SET name = 'Other game' WHERE guid = '202'")
|
||||
.run();
|
||||
const namesakes = getLeaderboard({ q: 'Other game' });
|
||||
expect(namesakes.entries.map(entry => [entry.guid, entry.name, entry.rank])).toEqual([
|
||||
['101', 'Other game', 1], ['202', 'Other game', 3]
|
||||
expect(namesakes.entries.map((entry) => [entry.guid, entry.name, entry.rank])).toEqual([
|
||||
['101', 'Other game', 1],
|
||||
['202', 'Other game', 3]
|
||||
]);
|
||||
expect(namesakes).toMatchObject({ total: 2, summary: { players: 4 } });
|
||||
});
|
||||
@@ -224,13 +421,22 @@ describe('persisted leaderboard queries', () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const first = getLeaderboard({ metric: 'kills', limit: 2 });
|
||||
const second = getLeaderboard({ metric: 'kills', limit: 2, offset: 2 });
|
||||
expect(first.entries.map(entry => [entry.guid, entry.kills, entry.rank])).toEqual([['101', 770, 1], ['505', 200, 2]]);
|
||||
expect(second.entries.map(entry => [entry.guid, entry.kills, entry.rank])).toEqual([['202', 100, 3], ['303', 70, 4]]);
|
||||
expect(first.entries.map((entry) => [entry.guid, entry.kills, entry.rank])).toEqual([
|
||||
['101', 770, 1],
|
||||
['505', 200, 2]
|
||||
]);
|
||||
expect(second.entries.map((entry) => [entry.guid, entry.kills, entry.rank])).toEqual([
|
||||
['202', 100, 3],
|
||||
['303', 70, 4]
|
||||
]);
|
||||
expect(first.total).toBe(4);
|
||||
expect(second.total).toBe(4);
|
||||
expect(second.summary).toEqual(first.summary);
|
||||
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 })).toMatchObject({
|
||||
entries: [], total: first.total, summary: first.summary, maps: first.maps
|
||||
entries: [],
|
||||
total: first.total,
|
||||
summary: first.summary,
|
||||
maps: first.maps
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,49 +447,96 @@ describe('persisted leaderboard queries', () => {
|
||||
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
|
||||
const board = getLeaderboard();
|
||||
expect(board.total).toBe(original.total - 1);
|
||||
expect(board.entries.some(entry => entry.guid === '101')).toBe(false);
|
||||
expect(board.entries.some((entry) => entry.guid === '101')).toBe(false);
|
||||
expect(board.summary).toEqual(original.summary);
|
||||
expect(board.maps).toEqual(original.maps);
|
||||
expect(getLeaderboard({ q: '101' })).toMatchObject({ entries: [], total: 0, summary: original.summary });
|
||||
expect(getLeaderboard({ q: '101' })).toMatchObject({
|
||||
entries: [],
|
||||
total: 0,
|
||||
summary: original.summary
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores unsupported games in identities, totals, rankings and map options', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const original = getLeaderboard();
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (7, 'removed-server', 't7', 'excluded', 't7:excluded', 'Excluded', '2020-01-01', '2030-01-01', 'completed', 99, 'game_end', 'test')`).run();
|
||||
VALUES (7, 'removed-server', 't7', 'excluded', 't7:excluded', 'Excluded', '2020-01-01', '2030-01-01', 'completed', 99, 'game_end', 'test')`
|
||||
)
|
||||
.run();
|
||||
for (const guid of ['101', 'unsupported-only']) {
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, kills, highest_round, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (7, ?, 99999, 99, 1, '2020-01-01', '2030-01-01')`).run(guid);
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`).run(guid);
|
||||
VALUES (7, ?, 99999, 99, 1, '2020-01-01', '2030-01-01')`
|
||||
)
|
||||
.run(guid);
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`
|
||||
)
|
||||
.run(guid);
|
||||
}
|
||||
expect(getLeaderboard()).toEqual(original);
|
||||
expect(getLeaderboard({ q: 'Unsupported' }).entries).toEqual([]);
|
||||
expect(getLeaderboard({ game: 't7' })).toMatchObject({ entries: [], maps: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getLeaderboard({ game: 't7' })).toMatchObject({
|
||||
entries: [],
|
||||
maps: [],
|
||||
total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
it('treats SQL and LIKE punctuation as literal input and allows only known ranking columns', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_players SET name = '100%_Blake' WHERE guid = '202'").run();
|
||||
handle
|
||||
.prepare("UPDATE leaderboard_players SET name = '100%_Blake' WHERE guid = '202'")
|
||||
.run();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard({ q: '%' }).entries.map(entry => entry.guid)).toEqual(['202']);
|
||||
expect(getLeaderboard({ q: '%' }).entries.map((entry) => entry.guid)).toEqual(['202']);
|
||||
expect(getLeaderboard({ map: "' OR 1=1 --" }).entries).toEqual([]);
|
||||
expect(getLeaderboard({ q: "' OR 1=1 --" }).entries).toEqual([]);
|
||||
expect(getLeaderboard({ metric: 'kills; DROP TABLE leaderboard_players' as never }).metric).toBe('highestRound');
|
||||
expect(getLeaderboard({ metric: 'downs', game: 't5' }).entries.map(entry => entry.downs)).toEqual([3, 2, 0]);
|
||||
expect(
|
||||
getLeaderboard({ metric: 'kills; DROP TABLE leaderboard_players' as never }).metric
|
||||
).toBe('highestRound');
|
||||
expect(
|
||||
getLeaderboard({ metric: 'downs', game: 't5' }).entries.map((entry) => entry.downs)
|
||||
).toEqual([3, 2, 0]);
|
||||
});
|
||||
|
||||
it('returns an empty summary and bounds pagination for direct callers', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard()).toEqual({ entries: [], maps: [], total: 0, limit: 50, offset: 0, metric: 'highestRound',
|
||||
summary: { players: 0, matches: 0, kills: 0, roundsSurvived: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
expect(getLeaderboard({ limit: 1000, offset: -1 })).toMatchObject({ limit: 100, offset: 0 });
|
||||
expect(getLeaderboard({ limit: NaN, offset: 2_000_000 })).toMatchObject({ limit: 50, offset: 1_000_000 });
|
||||
expect(getLeaderboard()).toEqual({
|
||||
entries: [],
|
||||
maps: [],
|
||||
total: 0,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
metric: 'highestRound',
|
||||
summary: {
|
||||
players: 0,
|
||||
matches: 0,
|
||||
kills: 0,
|
||||
roundsSurvived: 0,
|
||||
highestRound: 0,
|
||||
firstTrackedAt: null,
|
||||
updatedAt: null
|
||||
}
|
||||
});
|
||||
expect(getLeaderboard({ limit: 1000, offset: -1 })).toMatchObject({
|
||||
limit: 100,
|
||||
offset: 0
|
||||
});
|
||||
expect(getLeaderboard({ limit: NaN, offset: 2_000_000 })).toMatchObject({
|
||||
limit: 50,
|
||||
offset: 1_000_000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,39 +547,102 @@ describe('player profiles', () => {
|
||||
const profile = getPlayerProfile('101')!;
|
||||
expect(profile).toMatchObject({ name: 'Ace', guid: '101', offset: 0, limit: 25 });
|
||||
expect(profile).not.toHaveProperty('game');
|
||||
expect(profile.stats).toEqual({ highestRound: 20, kills: 770, roundsSurvived: 38,
|
||||
headshots: 320, revives: 8, downs: 4, gamesPlayed: 4, gamesCompleted: 3,
|
||||
bestKills: 500, bestScore: 5000 });
|
||||
expect(profile.stats).toEqual({
|
||||
highestRound: 20,
|
||||
kills: 770,
|
||||
roundsSurvived: 38,
|
||||
headshots: 320,
|
||||
revives: 8,
|
||||
downs: 4,
|
||||
gamesPlayed: 4,
|
||||
gamesCompleted: 3,
|
||||
bestKills: 500,
|
||||
bestScore: 5000
|
||||
});
|
||||
expect(profile.maps).toHaveLength(3);
|
||||
expect(profile.maps[0]).toMatchObject({ game: 't6', key: 't6:town', highestRound: 20, kills: 500, gamesPlayed: 1, gamesCompleted: 1 });
|
||||
expect(profile.maps[1]).toMatchObject({ game: 't5', key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||
expect(profile.matches.map(match => [match.id, match.game])).toEqual([[4, 't6'], [3, 't5'], [2, 't5'], [1, 't5']]);
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'completed', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
|
||||
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
|
||||
expect(profile.maps[0]).toMatchObject({
|
||||
game: 't6',
|
||||
key: 't6:town',
|
||||
highestRound: 20,
|
||||
kills: 500,
|
||||
gamesPlayed: 1,
|
||||
gamesCompleted: 1
|
||||
});
|
||||
expect(profile.maps[1]).toMatchObject({
|
||||
game: 't5',
|
||||
key: 't5:theater',
|
||||
highestRound: 10,
|
||||
kills: 250,
|
||||
gamesPlayed: 2,
|
||||
gamesCompleted: 1
|
||||
});
|
||||
expect(profile.matches.map((match) => [match.id, match.game])).toEqual([
|
||||
[4, 't6'],
|
||||
[3, 't5'],
|
||||
[2, 't5'],
|
||||
[1, 't5']
|
||||
]);
|
||||
expect(profile.matches[2]).toMatchObject({
|
||||
status: 'completed',
|
||||
completed: 0,
|
||||
server: 'removed-server',
|
||||
kills: 150
|
||||
});
|
||||
expect(getPlayerProfile('505')).toMatchObject({
|
||||
name: 'Eve',
|
||||
stats: { kills: 200, gamesPlayed: 1 },
|
||||
maps: [expect.objectContaining({ game: 't6' })],
|
||||
matches: [expect.objectContaining({ game: 't6' })]
|
||||
});
|
||||
expect(getPlayerProfile('404')).toBeNull();
|
||||
expect(getPlayerProfile("' OR 1=1 --")).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the latest reported name and the full identity date range across supported games', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-08-01', last_seen_at = '2026-09-07'
|
||||
WHERE game = 't5' AND guid = '101'`).run();
|
||||
handle.prepare(`UPDATE leaderboard_players SET first_seen_at = '2026-07-01', last_seen_at = '2026-09-08'
|
||||
WHERE game = 't6' AND guid = '101'`).run();
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE leaderboard_players SET first_seen_at = '2026-08-01', last_seen_at = '2026-09-07'
|
||||
WHERE game = 't5' AND guid = '101'`
|
||||
)
|
||||
.run();
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE leaderboard_players SET first_seen_at = '2026-07-01', last_seen_at = '2026-09-08'
|
||||
WHERE game = 't6' AND guid = '101'`
|
||||
)
|
||||
.run();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')).toMatchObject({ name: 'Other game', firstSeen: '2026-07-01', lastSeen: '2026-09-08' });
|
||||
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-08' WHERE game = 't5' AND guid = '101'").run();
|
||||
expect(getPlayerProfile('101')).toMatchObject({
|
||||
name: 'Other game',
|
||||
firstSeen: '2026-07-01',
|
||||
lastSeen: '2026-09-08'
|
||||
});
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE leaderboard_players SET last_seen_at = '2026-09-08' WHERE game = 't5' AND guid = '101'"
|
||||
)
|
||||
.run();
|
||||
expect(getPlayerProfile('101')?.name).toBe('Ace');
|
||||
handle.prepare("UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't5' AND guid = '101'").run();
|
||||
expect(getPlayerProfile('101')).toMatchObject({ name: 'Ace', firstSeen: '2026-07-01', lastSeen: '2026-09-09' });
|
||||
handle
|
||||
.prepare(
|
||||
"UPDATE leaderboard_players SET last_seen_at = '2026-09-09' WHERE game = 't5' AND guid = '101'"
|
||||
)
|
||||
.run();
|
||||
expect(getPlayerProfile('101')).toMatchObject({
|
||||
name: 'Ace',
|
||||
firstSeen: '2026-07-01',
|
||||
lastSeen: '2026-09-09'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps map records separate across games even when their stored keys are equal', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_matches SET map_key = 'shared-map' WHERE id IN (1, 2, 4)").run();
|
||||
handle
|
||||
.prepare("UPDATE leaderboard_matches SET map_key = 'shared-map' WHERE id IN (1, 2, 4)")
|
||||
.run();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const records = getPlayerProfile('101')!.maps.filter(map => map.key === 'shared-map');
|
||||
const records = getPlayerProfile('101')!.maps.filter((map) => map.key === 'shared-map');
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({ game: 't6', highestRound: 20, kills: 500, gamesPlayed: 1 }),
|
||||
expect.objectContaining({ game: 't5', highestRound: 10, kills: 250, gamesPlayed: 2 })
|
||||
@@ -347,8 +663,12 @@ describe('player profiles', () => {
|
||||
record.run(10, '101', 99, 1);
|
||||
record.run(10, 'unsupported-only', 99, 1);
|
||||
for (const guid of ['101', 'unsupported-only']) {
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`).run(guid);
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t7', ?, 'Unsupported name', '2020-01-01', '2030-01-01')`
|
||||
)
|
||||
.run(guid);
|
||||
}
|
||||
match.run(11, 't5', 99);
|
||||
record.run(11, '101', 1, 1);
|
||||
@@ -363,12 +683,20 @@ describe('player profiles', () => {
|
||||
it('paginates one history across both games without changing lifetime totals and clamps invalid offsets', async () => {
|
||||
const handle = await seedRecords();
|
||||
for (let id = 10; id < 40; id++) {
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
SELECT ?, server, game, map, map_key, map_name, started_at, last_received_at, status, highest_round, last_event, last_fingerprint
|
||||
FROM leaderboard_matches WHERE id = ?`).run(id, id % 2 === 0 ? 1 : 4);
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-06')`).run(id);
|
||||
FROM leaderboard_matches WHERE id = ?`
|
||||
)
|
||||
.run(id, id % 2 === 0 ? 1 : 4);
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players (match_id, guid, has_stats, highest_round, first_seen_at, last_seen_at)
|
||||
VALUES (?, '101', 1, 2, '2026-09-01', '2026-09-06')`
|
||||
)
|
||||
.run(id);
|
||||
}
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const first = getPlayerProfile('101')!;
|
||||
@@ -377,69 +705,128 @@ describe('player profiles', () => {
|
||||
expect(second.matches).toHaveLength(9);
|
||||
expect(second.stats).toEqual(first.stats);
|
||||
expect(first.stats).toMatchObject({ gamesPlayed: 34, kills: 770 });
|
||||
expect(first.matches.map(match => [match.id, match.game])).toEqual(Array.from({ length: 25 }, (_, index) => {
|
||||
const id = 39 - index;
|
||||
return [id, id % 2 === 0 ? 't5' : 't6'];
|
||||
}));
|
||||
expect(second.matches.map(match => match.id)).toEqual([14, 13, 12, 11, 10, 4, 3, 2, 1]);
|
||||
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(34);
|
||||
expect(first.matches.map((match) => [match.id, match.game])).toEqual(
|
||||
Array.from({ length: 25 }, (_, index) => {
|
||||
const id = 39 - index;
|
||||
return [id, id % 2 === 0 ? 't5' : 't6'];
|
||||
})
|
||||
);
|
||||
expect(second.matches.map((match) => match.id)).toEqual([14, 13, 12, 11, 10, 4, 3, 2, 1]);
|
||||
expect(new Set([...first.matches, ...second.matches].map((match) => match.id)).size).toBe(
|
||||
34
|
||||
);
|
||||
expect(getPlayerProfile('101', 9999)?.offset).toBe(25);
|
||||
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
|
||||
for (const offset of [-1, NaN, 0.5])
|
||||
expect(getPlayerProfile('101', offset)?.offset).toBe(0);
|
||||
const { load } =
|
||||
await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
expect(
|
||||
(
|
||||
await load({
|
||||
params: { guid: '101' },
|
||||
url: new URL('http://localhost/leaderboard/players/101?offset=25')
|
||||
})
|
||||
).profile
|
||||
).toEqual(second);
|
||||
|
||||
// Historical same-round departures retain both pages even with no counted games.
|
||||
handle.prepare(`UPDATE leaderboard_match_players SET first_observed_round = highest_round,
|
||||
completed = 0 WHERE guid = '101'`).run();
|
||||
handle
|
||||
.prepare(
|
||||
`UPDATE leaderboard_match_players SET first_observed_round = highest_round,
|
||||
completed = 0 WHERE guid = '101'`
|
||||
)
|
||||
.run();
|
||||
const departures = getPlayerProfile('101', 25)!;
|
||||
expect(departures).toMatchObject({ stats: { gamesPlayed: 0, kills: 770 }, matchesTotal: 34, offset: 25 });
|
||||
expect(departures).toMatchObject({
|
||||
stats: { gamesPlayed: 0, kills: 770 },
|
||||
matchesTotal: 34,
|
||||
offset: 25
|
||||
});
|
||||
expect(departures.matches).toHaveLength(9);
|
||||
expect(departures.maps.every(map => map.gamesPlayed === 0)).toBe(true);
|
||||
expect(departures.maps.every((map) => map.gamesPlayed === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
|
||||
await seedRecords();
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const { load } =
|
||||
await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/101');
|
||||
expect((await load({ params: { guid: '101' }, url })).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
|
||||
await expect(load({ params: { guid: 'missing' }, url })).rejects.toThrow(expect.objectContaining({ status: 404 }));
|
||||
expect((await load({ params: { guid: '101' }, url })).profile).toMatchObject({
|
||||
name: 'Ace',
|
||||
stats: { kills: 770, gamesPlayed: 4 }
|
||||
});
|
||||
await expect(load({ params: { guid: 'missing' }, url })).rejects.toThrow(
|
||||
expect.objectContaining({ status: 404 })
|
||||
);
|
||||
const { playerProfileHref } = await import('../../src/lib/leaderboard.js');
|
||||
expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23');
|
||||
});
|
||||
|
||||
it('permanently redirects both legacy game URLs while preserving the GUID and pagination query', async () => {
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
|
||||
const { load } =
|
||||
await import('../../src/routes/leaderboard/players/[game]/[guid]/+page.server.js');
|
||||
for (const game of ['t5', 't6']) {
|
||||
const url = new URL(`http://localhost/leaderboard/players/${game}/001%2F2%3F%23?offset=25&source=old%20link`);
|
||||
expect(() => load({ params: { game, guid: '001/2?#' }, url })).toThrow(expect.objectContaining({
|
||||
status: 308, location: '/leaderboard/players/001%2F2%3F%23?offset=25&source=old%20link'
|
||||
}));
|
||||
const url = new URL(
|
||||
`http://localhost/leaderboard/players/${game}/001%2F2%3F%23?offset=25&source=old%20link`
|
||||
);
|
||||
expect(() => load({ params: { game, guid: '001/2?#' }, url })).toThrow(
|
||||
expect.objectContaining({
|
||||
status: 308,
|
||||
location: '/leaderboard/players/001%2F2%3F%23?offset=25&source=old%20link'
|
||||
})
|
||||
);
|
||||
}
|
||||
expect(() => load({ params: { game: 't7', guid: '101' }, url: new URL('http://localhost/leaderboard/players/t7/101') }))
|
||||
.toThrow(expect.objectContaining({ status: 404 }));
|
||||
expect(() =>
|
||||
load({
|
||||
params: { game: 't7', guid: '101' },
|
||||
url: new URL('http://localhost/leaderboard/players/t7/101')
|
||||
})
|
||||
).toThrow(expect.objectContaining({ status: 404 }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('leaderboard API', () => {
|
||||
it('returns one combined ranking row when a search matches the same player in both games', async () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'").run();
|
||||
handle
|
||||
.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'")
|
||||
.run();
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = await GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') });
|
||||
const response = await GET({
|
||||
url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills')
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json() as LeaderboardResponse;
|
||||
expect(result).toMatchObject({ total: 1, summary: { players: 4, matches: 5, kills: 1140 } });
|
||||
expect(result.entries).toEqual([expect.objectContaining({ guid: '101', name: 'JoshWright', rank: 1,
|
||||
kills: 770, gamesPlayed: 4, gamesCompleted: 3, highestRound: 20, bestKills: 500, bestScore: 5000 })]);
|
||||
const result = (await response.json()) as LeaderboardResponse;
|
||||
expect(result).toMatchObject({
|
||||
total: 1,
|
||||
summary: { players: 4, matches: 5, kills: 1140 }
|
||||
});
|
||||
expect(result.entries).toEqual([
|
||||
expect.objectContaining({
|
||||
guid: '101',
|
||||
name: 'JoshWright',
|
||||
rank: 1,
|
||||
kills: 770,
|
||||
gamesPlayed: 4,
|
||||
gamesCompleted: 3,
|
||||
highestRound: 20,
|
||||
bestKills: 500,
|
||||
bestScore: 5000
|
||||
})
|
||||
]);
|
||||
expect(result.entries[0]).not.toHaveProperty('game');
|
||||
});
|
||||
|
||||
it('returns the requested ranking and pagination', async () => {
|
||||
await seedRecords();
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = await GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') });
|
||||
const response = await GET({
|
||||
url: new URL(
|
||||
'http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1'
|
||||
)
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json() as LeaderboardResponse;
|
||||
const result = (await response.json()) as LeaderboardResponse;
|
||||
expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 });
|
||||
expect(result.entries).toHaveLength(1);
|
||||
expect(result.entries[0]).toMatchObject({ guid: '202', rank: 2, kills: 100 });
|
||||
@@ -447,10 +834,25 @@ describe('leaderboard API', () => {
|
||||
|
||||
it('rejects invalid filters and non-integer or out-of-range pagination', async () => {
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=',
|
||||
'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5',
|
||||
`q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) {
|
||||
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) });
|
||||
for (const query of [
|
||||
'game=t7',
|
||||
'game=',
|
||||
'metric=unknown',
|
||||
'limit=0',
|
||||
'limit=101',
|
||||
'limit=',
|
||||
'limit=1.5',
|
||||
'limit=1e1',
|
||||
'offset=-1',
|
||||
'offset=1000001',
|
||||
'offset=Infinity',
|
||||
'offset=0.5',
|
||||
`q=${'a'.repeat(101)}`,
|
||||
`map=${'a'.repeat(1025)}`
|
||||
]) {
|
||||
const response = await GET({
|
||||
url: new URL(`http://localhost/api/leaderboard?${query}`)
|
||||
});
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' });
|
||||
}
|
||||
@@ -460,13 +862,23 @@ describe('leaderboard API', () => {
|
||||
const handle = await seedRecords();
|
||||
const map = 'a'.repeat(255);
|
||||
const key = `t5:${map}`;
|
||||
handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1')
|
||||
handle
|
||||
.prepare(
|
||||
'UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1'
|
||||
)
|
||||
.run(map, key, map);
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const all = await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json() as LeaderboardResponse;
|
||||
const returnedKey = all.maps.find(option => option.map === map)!.key;
|
||||
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) });
|
||||
const all = (await (
|
||||
await GET({ url: new URL('http://localhost/api/leaderboard') })
|
||||
).json()) as LeaderboardResponse;
|
||||
const returnedKey = all.maps.find((option) => option.map === map)!.key;
|
||||
const response = await GET({
|
||||
url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`)
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } });
|
||||
expect(await response.json()).toMatchObject({
|
||||
total: 2,
|
||||
summary: { matches: 1, kills: 200 }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user