Files
electric-cherry/tests/unit/leaderboard-report-migration.test.ts
2026-10-12 13:57:34 -04:00

133 lines
6.2 KiB
TypeScript

import Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it } from 'vitest';
import { migrateLeaderboardReports } from '../../src/lib/server/db/leaderboard-reports.js';
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
import { migrations } from '../../src/lib/server/db/migrations.js';
let handle: Database.Database;
const migration = migrations.find(
(entry) => entry.name === 'durable leaderboard report identity and sequences'
)!;
beforeEach(() => {
handle = new Database(':memory:');
handle.pragma('foreign_keys = ON');
runMigrations(handle, migrations.slice(0, migration.version - 1));
handle.exec(`
INSERT INTO leaderboard_matches
(id,server,server_deleted_at,game,map,map_key,map_name,started_at,last_received_at,
status,last_event,last_fingerprint,highest_round)
VALUES (1,'old-server','2026-10-01T15:00:00.000Z','t5','zombie_moon','zombie_moon','Moon',
'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z','completed','game_end','old',32);
INSERT INTO leaderboard_match_players
(match_id,guid,kills,first_seen_at,last_seen_at)
VALUES (1,'player',1000,'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z');
INSERT INTO match_chat(match_id,message_key,occurrence,entry)
VALUES (1,'message',1,'{"message":"Saved chat"}');
`);
});
afterEach(() => handle.close());
function insertMatch(server: string, key: string | null): number {
return Number(
handle
.prepare(
`INSERT INTO leaderboard_matches
(server,game,map,map_key,map_name,started_at,last_received_at,last_event,
last_fingerprint,reporter_match_key)
VALUES (?,'t5','zombie_moon','zombie_moon','Moon',
'2026-10-12T12:00:00.000Z','2026-10-12T12:00:00.000Z','game_start','start',?)`
)
.run(server, key).lastInsertRowid
);
}
it('upgrades version 17 without inventing reporter identity or changing historical matches', () => {
const original = handle.prepare('SELECT * FROM leaderboard_matches').get();
const players = handle.prepare('SELECT * FROM leaderboard_match_players').all();
const chat = handle.prepare('SELECT * FROM match_chat').all();
runMigrations(handle, migrations);
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
expect(handle.prepare('SELECT * FROM leaderboard_matches').get()).toEqual({
...original!,
reporter_match_key: null,
last_seq: 0,
report_count: 0
});
expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual(
players.map((player) => ({ ...(player as Record<string, unknown>), score_total: null }))
);
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('enforces reporter keys per server while preserving unkeyed legacy history', () => {
runMigrations(handle, migrations);
const first = insertMatch('server-a', 'cycle-1');
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
expect(() => insertMatch('server-b', 'cycle-1')).not.toThrow();
expect(() => insertMatch('server-a', 'cycle-2')).not.toThrow();
expect(() => insertMatch('server-a', null)).not.toThrow();
expect(() => insertMatch('server-a', null)).not.toThrow();
handle
.prepare('UPDATE leaderboard_matches SET server_deleted_at=? WHERE id=?')
.run('2026-10-12T13:00:00.000Z', first);
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
});
it('stores received and applied sequence receipts once and removes them with retained matches', () => {
runMigrations(handle, migrations);
const id = insertMatch('server-a', 'cycle-1');
const receipt = handle.prepare(
'INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,?,?)'
);
receipt.run(id, 3, 1);
receipt.run(id, 1, 0);
expect(() => receipt.run(id, 3, 0)).toThrow('UNIQUE constraint failed');
for (const seq of [0, -1, 1.5])
expect(() => receipt.run(id, seq, 1)).toThrow('CHECK constraint failed');
expect(() => receipt.run(id, 2, 2)).toThrow('CHECK constraint failed');
expect(() => receipt.run(id + 100, 1, 1)).toThrow('FOREIGN KEY constraint failed');
expect(
handle.prepare('SELECT seq,applied FROM leaderboard_match_reports ORDER BY seq').all()
).toEqual([
{ seq: 1, applied: 0 },
{ seq: 3, applied: 1 }
]);
handle.prepare('DELETE FROM leaderboard_matches WHERE id=?').run(id);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
it('preserves reporter keys, counters and receipts when migration 18 is replayed', () => {
runMigrations(handle, migrations);
const id = insertMatch('server-a', 'cycle-1');
handle.prepare('UPDATE leaderboard_matches SET last_seq=3,report_count=2 WHERE id=?').run(id);
handle
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,3,1)')
.run(id);
handle
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,0)')
.run(id);
const matches = handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
const reports = handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all();
const schemaVersion = handle.pragma('schema_version', { simple: true });
migrateLeaderboardReports(handle);
handle.pragma(`user_version = ${migration.version - 1}`);
runMigrations(handle, migrations);
expect(handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches);
expect(handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all()).toEqual(
reports
);
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});