Files
Live_RC/tests/server_persistence.integration.test.js
larssand 94794350e5 Add a results archive and purge orphaned passing rows on delete
Until now there was no way to find a completed session without already
knowing which event it belonged to - navigation was strictly Events ->
one event -> its sessions. A new "Resultatarkiv" view lists every
finished session across all events, searchable by event/session name,
class, driver and date range.

Opening a session reuses the existing single-session leaderboard
builder for standings and the existing per-competitor lap lookup for
lap history - no new results logic - plus a session-wide penalties/
corrections list (resultsBySession[id].adjustments), previously only
visible in the live judging action log. Export buttons reuse the
existing per-event PDF and race-package JSON export functions rather
than adding a new format.

Investigated what should happen to raw timing data when a race is
deleted. Training statistics and session replay already read from
their own independent, deliberately-retained copies that survive
deletion by existing design - unaffected either way. The one thing
that outlived deletion with no purpose was the raw `passings` SQLite
rows (write-only; nothing in the app reads them), growing forever with
driver IDs never purged. A new POST /api/passings/purge endpoint now
removes exactly the deleted session(s)' rows, called as a best-effort
side call from both delete-event and delete-session.

Also fixes a real bug found while building this: ensureSessionResult
guarded adjustments/splits/pitEvents/pitStops against being missing on
an already-existing result, but not passings itself, so viewing lap
history for such a result (e.g. an older/imported race package)
crashed outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 15:49:10 +02:00

408 lines
17 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import QRCode from "qrcode";
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
function reservePort() {
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}
function waitForServer(child, timeoutMs = 10000) {
return new Promise((resolve, reject) => {
let output = "";
const timeout = setTimeout(() => {
reject(new Error(`Server startup timed out. Output:
${output}`));
}, timeoutMs);
const onData = (chunk) => {
output += chunk.toString();
if (output.includes("RC Timing server listening")) {
clearTimeout(timeout);
child.stdout.off("data", onData);
resolve();
}
};
child.stdout.on("data", onData);
child.stderr.on("data", (chunk) => {
output += chunk.toString();
});
child.once("exit", (code, signal) => {
clearTimeout(timeout);
reject(new Error(`Server exited before startup (code=${code}, signal=${signal}). Output:
${output}`));
});
});
}
function stopServer(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve();
}
return new Promise((resolve) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
}, 3000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.kill("SIGTERM");
});
}
test("SQLite API persists state, passings, and overlay session results", { timeout: 20000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-"));
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [join(projectRoot, "server.js")], {
cwd: projectRoot,
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
RC_TIMING_DATA_DIR: dataDir,
RC_TIMING_PID_FILE: "",
RC_ALLOWED_ORIGINS: "https://trusted.example",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
const healthResponse = await fetch(`${baseUrl}/api/health`);
const health = await healthResponse.json();
assert.equal(healthResponse.status, 200);
assert.equal(health.ok, true);
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
const versionResponse = await fetch(`${baseUrl}/api/app-version`);
assert.equal(versionResponse.status, 200);
const appVersion = await versionResponse.json();
assert.equal(appVersion.version, JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8")).version);
assert.equal(typeof appVersion.revision, "number");
assert.ok(appVersion.updatedAt);
const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } });
assert.equal(sameOriginResponse.status, 200);
assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl);
const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } });
assert.equal(allowedOriginResponse.status, 200);
assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example");
const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, {
method: "POST",
headers: { Origin: "https://untrusted.example" },
});
assert.equal(rejectedOriginResponse.status, 403);
const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
const soundManifest = await soundManifestResponse.json();
assert.equal(soundManifestResponse.status, 200);
assert.equal(soundManifest.events.start_tone.effect, "effects/start_beep.wav");
assert.equal(soundManifest.voice_events.sv.track_call, "Stopp på banan. Stopp på banan.");
const ammcConfigResponse = await fetch(`${baseUrl}/api/ammc/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
managedEnabled: true,
decoderHost: "192.168.1.10",
wsPort: 9000,
splitEnabled: true,
splitDecoderHost: "192.168.1.11",
splitWsPort: 9001,
pitEnabled: true,
pitEntryDecoderHost: "192.168.1.12",
pitEntryWsPort: 9002,
pitExitDecoderHost: "192.168.1.13",
pitExitWsPort: 9003,
}),
});
const ammcConfigPayload = await ammcConfigResponse.json();
assert.equal(ammcConfigResponse.status, 200);
assert.equal(ammcConfigPayload.config.splitWsPort, 9001);
assert.equal(ammcConfigPayload.config.pitExitDecoderHost, "192.168.1.13");
assert.equal(ammcConfigPayload.status.role, "main");
assert.equal(ammcConfigPayload.status.split.role, "split");
assert.equal(ammcConfigPayload.status.pitEntry.wsPort, 9002);
const duplicatePortResponse = await fetch(`${baseUrl}/api/ammc/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splitEnabled: true, splitWsPort: 9000 }),
});
const duplicatePortPayload = await duplicatePortResponse.json();
assert.equal(duplicatePortResponse.status, 400);
assert.match(duplicatePortPayload.error, /unique WebSocket port/);
const emptyResponse = await fetch(`${baseUrl}/api/state`);
assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null });
const initialState = {
events: [{ id: "event_1", name: "Club race" }],
classes: [{ id: "class_1", name: "Buggy" }],
drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }],
sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }],
resultsBySession: {},
settings: { publicBaseUrl: "https://racecore.example.org" },
training: {
shares: [{ driverId: "driver_1", token: "test-share-token-1234567890", enabled: true }],
},
};
const saveResponse = await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(initialState),
});
const savePayload = await saveResponse.json();
assert.equal(saveResponse.status, 200);
assert.equal(savePayload.ok, true);
assert.equal(typeof savePayload.updatedAt, "string");
const shareToken = initialState.training.shares[0].token;
const publicQrResponse = await fetch(
`${baseUrl}/public-training/qr/${shareToken}?lang=en&baseUrl=${encodeURIComponent("https://public.example.test")}`,
);
const publicQrSvg = await publicQrResponse.text();
const expectedPublicQrSvg = await QRCode.toString(
`https://public.example.test/public-training/${shareToken}?lang=en`,
{ type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 },
);
assert.equal(publicQrResponse.status, 200);
assert.match(publicQrResponse.headers.get("content-type"), /^image\/svg\+xml/);
assert.equal(publicQrSvg, expectedPublicQrSvg);
const localQrResponse = await fetch(`${baseUrl}/public-training/qr/${shareToken}?lang=sv&baseUrl=local`);
const localQrSvg = await localQrResponse.text();
assert.equal(localQrResponse.status, 200);
assert.match(localQrResponse.headers.get("content-type"), /^image\/svg\+xml/);
assert.notEqual(localQrSvg, publicQrSvg);
const missingQrResponse = await fetch(`${baseUrl}/public-training/qr/unknown-share-token-123456?baseUrl=local`);
assert.equal(missingQrResponse.status, 404);
const backupResponse = await fetch(`${baseUrl}/api/backups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }),
});
const backupPayload = await backupResponse.json();
assert.equal(backupResponse.status, 201);
assert.equal(backupPayload.ok, true);
assert.equal(backupPayload.backup.reason, "pre_start");
assert.equal(backupPayload.backup.sessionId, "session_1");
assert.ok(backupPayload.backup.sizeBytes > 0);
const backupsResponse = await fetch(`${baseUrl}/api/backups`);
const backupsPayload = await backupsResponse.json();
assert.equal(backupsResponse.status, 200);
assert.equal(backupsPayload.backups.length, 1);
assert.equal(backupsPayload.backups[0].fileName, backupPayload.backup.fileName);
const storedResponse = await fetch(`${baseUrl}/api/state`);
const storedPayload = await storedResponse.json();
assert.deepEqual(storedPayload.state, initialState);
const markerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId: "class_1", type: "car", note: "Ride height change" }),
});
const markerPayload = await markerResponse.json();
assert.equal(markerResponse.status, 201);
assert.equal(markerPayload.marker.type, "car");
const markerStateResponse = await fetch(baseUrl + "/api/state");
const markerState = await markerStateResponse.json();
assert.equal(markerState.state.training.markers.length, 1);
assert.equal(markerState.state.training.markers[0].driverId, "driver_1");
assert.equal(markerState.state.training.markers[0].classId, "class_1");
assert.equal(markerState.state.training.markers[0].note, "Ride height change");
const invalidMarkerResponse = await fetch(baseUrl + "/public-training/api/" + shareToken + "/markers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ classId: "unknown_class", type: "car" }),
});
assert.equal(invalidMarkerResponse.status, 404);
const sessionResult = {
competitors: {
driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16500 },
},
passings: [{ competitorKey: "driver_1", timestamp: 17500, lapMs: 16500, validLap: true }],
adjustments: [],
};
const passing = {
timestamp: 17500,
transponder: "1234567",
driverId: "driver_1",
driverName: "Driver One",
carId: "car_1",
carName: "Buggy",
strength: -52.5,
loopId: "loop_1",
resend: false,
};
const passingResponse = await fetch(`${baseUrl}/api/passings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: "session_1", passing, sessionResult }),
});
assert.deepEqual(await passingResponse.json(), { ok: true });
const hotStateResponse = await fetch(`${baseUrl}/api/state`);
const hotState = await hotStateResponse.json();
assert.deepEqual(hotState.state.resultsBySession.session_1, sessionResult);
assert.deepEqual(hotState.state.events, initialState.events);
const rowsResponse = await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`);
const rowsPayload = await rowsResponse.json();
assert.equal(rowsPayload.rows.length, 1);
assert.equal(rowsPayload.rows[0].session_id, "session_1");
assert.equal(rowsPayload.rows[0].timestamp_ms, 17500);
assert.equal(rowsPayload.rows[0].transponder, "1234567");
assert.equal(rowsPayload.rows[0].driver_id, "driver_1");
assert.equal(rowsPayload.rows[0].car_id, "car_1");
assert.equal(rowsPayload.rows[0].strength, -52.5);
assert.deepEqual(JSON.parse(rowsPayload.rows[0].raw_json), passing);
// A second, unrelated session's passing must survive purging session_1's rows.
await fetch(`${baseUrl}/api/passings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId: "session_other", passing: { ...passing, timestamp: 18000 } }),
});
const purgeResponse = await fetch(`${baseUrl}/api/passings/purge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionIds: ["session_1"] }),
});
const purgePayload = await purgeResponse.json();
assert.equal(purgeResponse.status, 200);
assert.equal(purgePayload.ok, true);
assert.equal(purgePayload.deleted, 1);
const afterPurgeSession1 = await (await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`)).json();
assert.equal(afterPurgeSession1.rows.length, 0, "the deleted session's rows are gone");
const afterPurgeOtherSession = await (await fetch(`${baseUrl}/api/passings?sessionId=session_other&limit=10`)).json();
assert.equal(afterPurgeOtherSession.rows.length, 1, "an unrelated session's rows must be untouched");
const emptyPurgeResponse = await fetch(`${baseUrl}/api/passings/purge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionIds: [] }),
});
assert.deepEqual(await emptyPurgeResponse.json(), { ok: true, deleted: 0 });
} finally {
await stopServer(child);
rmSync(dataDir, { recursive: true, force: true });
}
});
test("a stale full-state save from a second browser cannot revert a session's just-arrived live results", { timeout: 20000 }, async () => {
const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-concurrent-"));
const port = await reservePort();
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, [join(projectRoot, "server.js")], {
cwd: projectRoot,
env: {
...process.env,
HOST: "127.0.0.1",
PORT: String(port),
RC_TIMING_DATA_DIR: dataDir,
RC_TIMING_PID_FILE: "",
RC_RESULT_PROTECTION_WINDOW_MS: "300",
},
stdio: ["ignore", "pipe", "pipe"],
});
try {
await waitForServer(child);
const staleSessionResult = { competitors: {}, passings: [], adjustments: [] };
const baseState = {
events: [{ id: "event_1", name: "Club race" }],
classes: [{ id: "class_1", name: "Buggy" }],
drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }],
sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }],
resultsBySession: { session_1: staleSessionResult },
settings: {},
training: {},
};
await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(baseState),
});
// Browser A is actively timing: a live passing arrives and updates session_1's result.
const liveSessionResult = {
competitors: { driver_1: { key: "driver_1", driverName: "Driver One", laps: 1, bestLapMs: 16000 } },
passings: [{ competitorKey: "driver_1", timestamp: 5000, lapMs: 16000, validLap: true }],
adjustments: [],
};
await fetch(`${baseUrl}/api/passings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "session_1",
passing: { timestamp: 5000, transponder: "1234567", driverId: "driver_1" },
sessionResult: liveSessionResult,
}),
});
// Browser B has been idle since before the race and still has driver_1's old name and the
// stale (empty) session_1 result in memory. It now saves an unrelated edit (renaming the
// driver) and pushes its entire stale snapshot along with it.
const staleBrowserState = {
...baseState,
drivers: [{ id: "driver_1", name: "Driver One (renamed)", classId: "class_1" }],
};
const staleSaveResponse = await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(staleBrowserState),
});
assert.equal(staleSaveResponse.status, 200);
const afterStaleSave = await (await fetch(`${baseUrl}/api/state`)).json();
// The unrelated edit went through...
assert.equal(afterStaleSave.state.drivers[0].name, "Driver One (renamed)");
// ...but the live race result survived the stale overwrite instead of reverting to empty.
assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult);
// The protection is time-bounded, not permanent: once the window passes, a save is trusted
// again, so a legitimate later change to that same session's results (e.g. an admin
// deleting the session) still takes effect.
await new Promise((resolve) => setTimeout(resolve, 400));
const afterWindowState = { ...staleBrowserState, resultsBySession: {} };
await fetch(`${baseUrl}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(afterWindowState),
});
const afterWindow = await (await fetch(`${baseUrl}/api/state`)).json();
assert.deepEqual(afterWindow.state.resultsBySession, {});
} finally {
await stopServer(child);
rmSync(dataDir, { recursive: true, force: true });
}
});