From 5a6e51c51b58c58fddf2573b205c9988fee153f6 Mon Sep 17 00:00:00 2001 From: larssand Date: Mon, 24 Aug 2026 19:08:21 +0200 Subject: [PATCH] Add persistence and SQLite integration tests --- README.md | 4 +- README.sv.md | 4 +- docs/javascript-architecture.md | 17 +- tests/run.js | 2 + tests/runtime_persistence.test.js | 269 +++++++++++++++++++ tests/server_persistence.integration.test.js | 156 +++++++++++ 6 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 tests/runtime_persistence.test.js create mode 100644 tests/server_persistence.integration.test.js diff --git a/README.md b/README.md index 04d15e8..cd838de 100644 --- a/README.md +++ b/README.md @@ -212,12 +212,14 @@ For internet publishing, proxy only these paths: Do not expose `/api/state` or the full admin application. Static serving is restricted to `/src` and `/docs`; `data/`, server scripts and bundled binaries are not public static files. -Training logic regression tests use Node's built-in test runner and require no extra dependency: +Regression tests use Node's built-in test runner and require no extra test-framework dependency. The suite covers training profiles, race generation and timing, judging corrections, frontend/backend state synchronization, and an isolated SQLite API round-trip using a temporary data directory: ```bash npm test ``` +The integration test starts its own backend on a temporary localhost port and never reads or writes the normal `data/rc_timing.sqlite` database. + ## Create Race Wizard - `Race Setup` now starts with a four-step wizard for new races: diff --git a/README.sv.md b/README.sv.md index 493eb3d..b29a2ef 100644 --- a/README.sv.md +++ b/README.sv.md @@ -201,12 +201,14 @@ Vid extern publicering ska bara dessa paths proxas: Exponera inte `/api/state` eller hela admin-appen. Statisk servering är begränsad till `/src` och `/docs`; `data/`, serverscripts och bundlade binärer publiceras inte som statiska filer. -Regressionstesterna för träningslogiken använder Node:s inbyggda testverktyg och kräver ingen extra dependency: +Regressionstesterna använder Node:s inbyggda testverktyg och kräver inget extra testramverk. Sviten täcker träningsprofiler, racegenerering och tidtagning, domarkorrigeringar, frontend/backend-synk samt ett isolerat SQLite API-test med en temporär datamapp: ```bash npm test ``` +Integrationstestet startar en egen backend på en temporär localhost-port och läser eller skriver aldrig den vanliga databasen `data/rc_timing.sqlite`. + ## Create Race Wizard - `Race Setup` startar nu med en fyrstegs wizard för nya race: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 403d6f5..f0974f2 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -2090,6 +2090,21 @@ npm test - safe handling when no counted lap exists - competitor and judging-log filters +`tests/runtime_persistence.test.js` covers: +- persisted entity/settings normalization during backend hydration +- invalid-theme fallback and retention of local setting defaults +- hydration success/error behavior and no-write-back handling +- full-state POST payloads and synced-version advancement +- dirty-state protection when newer local edits occur during a request +- failed-sync behavior without false version advancement + +`tests/server_persistence.integration.test.js` starts an isolated backend with a temporary SQLite data directory and covers: +- health/database path reporting +- empty-state response and full app-state round-trip +- passing persistence and session-scoped passing queries +- atomic hot-update of `resultsBySession[sessionId]` for overlay hydration +- preservation of the rest of the stored state during a passing hot-update + The suite uses Node's built-in `node:test` runner and does not add a test-framework dependency. Windows PowerShell/CMD can run the same suite with `npm.cmd test`. ## 19. Recommended Future Additions @@ -2099,4 +2114,4 @@ If the project grows further, the next useful additions to this document are: - exact `settings` schema snapshot - SQLite table contract section - controller-to-view ID map for critical forms/buttons -- additional test checklists for persistence modules +- browser-level smoke tests for critical create/edit workflows diff --git a/tests/run.js b/tests/run.js index fd9204b..b5ab881 100644 --- a/tests/run.js +++ b/tests/run.js @@ -2,3 +2,5 @@ import "./training_logic.test.js"; import "./public_training_profile.test.js"; import "./race_timing_logic.test.js"; import "./judging_logic.test.js"; +import "./runtime_persistence.test.js"; +import "./server_persistence.integration.test.js"; diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js new file mode 100644 index 0000000..26325a3 --- /dev/null +++ b/tests/runtime_persistence.test.js @@ -0,0 +1,269 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + applyPersistedStateHelper, + hydrateFromBackendHelper, + syncStateToBackendHelper, +} from "../src/runtime_services.js"; + +function withMockFetch(mock, callback) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock; + return Promise.resolve() + .then(callback) + .finally(() => { + globalThis.fetch = originalFetch; + }); +} + +function createState() { + return { + classes: [], + drivers: [], + cars: [], + events: [], + sessions: [], + resultsBySession: {}, + training: {}, + activeSessionId: null, + settings: { + wsUrl: "ws://old:9000", + backendUrl: "http://old:8081", + language: "sv", + theme: "dark", + passingSoundMode: "beep", + clubName: "Old club", + clubTagline: "Old tagline", + pdfFooter: "Old footer", + pdfTheme: "classic", + logoDataUrl: "", + racePresets: [], + obsOverlay: {}, + }, + }; +} + +test("backend hydration normalizes persisted entities and settings", () => { + const state = createState(); + let themeApplied = 0; + const normalizeNamed = (value) => ({ ...value, name: String(value?.name || "").trim() }); + + applyPersistedStateHelper( + { + classes: [{ id: "class_1", name: "Modified" }], + drivers: [{ id: "driver_1", name: " Driver One " }, { id: "driver_empty", name: " " }], + cars: [{ id: "car_1", name: " Car One " }], + events: [{ id: "event_1", name: "Race" }], + sessions: [{ id: "session_1", name: "Final" }], + resultsBySession: { session_1: { passings: [] } }, + training: { goals: [{ id: "goal_1" }] }, + activeSessionId: "session_1", + settings: { + wsUrl: "ws://decoder:9000", + backendUrl: "http://race:8081", + language: "en", + theme: "light", + autoReconnect: false, + audioEnabled: false, + speakerPassingCueEnabled: true, + racePresets: [{ id: "preset_1", name: " Club " }, { id: "preset_empty", name: " " }], + obsOverlay: { rows: 8 }, + }, + }, + { + state, + getDefaultBackendUrl: () => "http://default:8081", + DEFAULT_LANGUAGE: "sv", + DEFAULT_THEME: "dark", + AVAILABLE_THEMES: ["dark", "nord", "light"], + normalizeDriver: normalizeNamed, + normalizeCar: normalizeNamed, + normalizeEvent: (event) => ({ ...event, normalized: true }), + normalizeSession: (session) => ({ ...session, normalized: true }), + normalizeStoredRacePreset: normalizeNamed, + normalizeObsOverlaySettings: (settings) => ({ rows: Number(settings?.rows || 10) }), + normalizeTrainingState: (training) => ({ ...training, normalized: true }), + applyTheme: () => { themeApplied += 1; }, + } + ); + + assert.deepEqual(state.classes, [{ id: "class_1", name: "Modified" }]); + assert.deepEqual(state.drivers.map((driver) => driver.name), ["Driver One"]); + assert.deepEqual(state.cars.map((car) => car.name), ["Car One"]); + assert.equal(state.events[0].normalized, true); + assert.equal(state.sessions[0].normalized, true); + assert.equal(state.training.normalized, true); + assert.equal(state.activeSessionId, "session_1"); + assert.equal(state.settings.theme, "light"); + assert.equal(state.settings.autoReconnect, false); + assert.equal(state.settings.audioEnabled, false); + assert.equal(state.settings.speakerPassingCueEnabled, true); + assert.deepEqual(state.settings.racePresets.map((preset) => preset.name), ["Club"]); + assert.deepEqual(state.settings.obsOverlay, { rows: 8 }); + assert.equal(themeApplied, 1); +}); + +test("invalid persisted theme falls back without discarding local setting defaults", () => { + const state = createState(); + state.settings.theme = "nord"; + state.settings.clubName = "Local club"; + + applyPersistedStateHelper( + { settings: { theme: "unknown" } }, + { + state, + getDefaultBackendUrl: () => "http://default:8081", + DEFAULT_LANGUAGE: "sv", + DEFAULT_THEME: "dark", + AVAILABLE_THEMES: ["dark", "nord", "light"], + normalizeDriver: (value) => value, + normalizeCar: (value) => value, + normalizeEvent: (value) => value, + normalizeSession: (value) => value, + normalizeStoredRacePreset: (value) => value, + normalizeObsOverlaySettings: (value) => value || {}, + normalizeTrainingState: (value) => value || {}, + applyTheme: () => {}, + } + ); + + assert.equal(state.settings.theme, "dark"); + assert.equal(state.settings.clubName, "Local club"); + assert.equal(state.settings.language, "sv"); +}); + +test("successful backend hydration applies state without scheduling a write-back", async () => { + const backend = { available: false, lastError: "old", lastSyncAt: null }; + const applied = []; + const saves = []; + + await withMockFetch( + async (url, options) => { + assert.equal(url, "http://race:8081/api/state"); + assert.equal(options, undefined); + return { + ok: true, + json: async () => ({ state: { activeSessionId: "session_1" }, updatedAt: "2026-08-24T10:00:00.000Z" }), + }; + }, + () => hydrateFromBackendHelper({ + backend, + t: (key, vars) => `${key}:${vars.msg}`, + getBackendUrl: () => "http://race:8081", + applyPersistedState: (state) => applied.push(state), + saveState: (options) => saves.push(options), + }) + ); + + assert.deepEqual(applied, [{ activeSessionId: "session_1" }]); + assert.deepEqual(saves, [{ skipBackend: true }]); + assert.equal(backend.available, true); + assert.equal(backend.lastError, ""); + assert.equal(backend.lastSyncAt, "2026-08-24T10:00:00.000Z"); +}); + +test("failed hydration preserves local state and reports backend offline", async () => { + const backend = { available: true, lastError: "" }; + let applied = false; + let saved = false; + + await withMockFetch( + async () => ({ ok: false, status: 503, json: async () => ({}) }), + () => hydrateFromBackendHelper({ + backend, + t: (key, vars) => `${key}:${vars.msg}`, + getBackendUrl: () => "http://race:8081", + applyPersistedState: () => { applied = true; }, + saveState: () => { saved = true; }, + }) + ); + + assert.equal(applied, false); + assert.equal(saved, false); + assert.equal(backend.available, false); + assert.equal(backend.lastError, "error.backend_offline:HTTP 503"); +}); + +test("state sync posts one snapshot and clears dirty only when no newer edit exists", async () => { + const backend = { available: false, lastError: "" }; + const payload = { events: [{ id: "event_1" }] }; + let syncedVersion = 2; + let dirty = true; + let posted = null; + + await withMockFetch( + async (url, options) => { + posted = { url, options }; + return { ok: true }; + }, + () => syncStateToBackendHelper({ + backend, + t: (key, vars) => `${key}:${vars.msg}`, + getBackendUrl: () => "http://race:8081", + buildPersistableState: () => payload, + getLocalStateVersion: () => 4, + getSyncedStateVersion: () => syncedVersion, + setSyncedStateVersion: (value) => { syncedVersion = value; }, + setLocalStateDirty: (value) => { dirty = value; }, + }) + ); + + assert.equal(posted.url, "http://race:8081/api/state"); + assert.equal(posted.options.method, "POST"); + assert.deepEqual(JSON.parse(posted.options.body), payload); + assert.equal(syncedVersion, 4); + assert.equal(dirty, false); + assert.equal(backend.available, true); + assert.equal(backend.lastError, ""); +}); + +test("completed state sync keeps dirty when an edit occurs during the request", async () => { + const backend = { available: false, lastError: "" }; + let versionReadCount = 0; + let syncedVersion = 0; + let dirtyWrites = 0; + + await withMockFetch( + async () => ({ ok: true }), + () => syncStateToBackendHelper({ + backend, + t: (key, vars) => `${key}:${vars.msg}`, + getBackendUrl: () => "http://race:8081", + buildPersistableState: () => ({ revision: 4 }), + getLocalStateVersion: () => (++versionReadCount === 1 ? 4 : 5), + getSyncedStateVersion: () => syncedVersion, + setSyncedStateVersion: (value) => { syncedVersion = value; }, + setLocalStateDirty: () => { dirtyWrites += 1; }, + }) + ); + + assert.equal(syncedVersion, 4); + assert.equal(dirtyWrites, 0); + assert.equal(backend.available, true); +}); + +test("failed state sync leaves version and dirty state unchanged", async () => { + const backend = { available: true, lastError: "" }; + let syncedVersion = 3; + let dirtyWrites = 0; + + await withMockFetch( + async () => { throw new Error("connection refused"); }, + () => syncStateToBackendHelper({ + backend, + t: (key, vars) => `${key}:${vars.msg}`, + getBackendUrl: () => "http://race:8081", + buildPersistableState: () => ({ revision: 4 }), + getLocalStateVersion: () => 4, + getSyncedStateVersion: () => syncedVersion, + setSyncedStateVersion: (value) => { syncedVersion = value; }, + setLocalStateDirty: () => { dirtyWrites += 1; }, + }) + ); + + assert.equal(syncedVersion, 3); + assert.equal(dirtyWrites, 0); + assert.equal(backend.available, false); + assert.equal(backend.lastError, "error.sync_failed:connection refused"); +}); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js new file mode 100644 index 0000000..eb67ffe --- /dev/null +++ b/tests/server_persistence.integration.test.js @@ -0,0 +1,156 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +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:\n${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:\n${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: "", + }, + 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 emptyResponse = await fetch(`${baseUrl}/api/state`); + assert.deepEqual(await emptyResponse.json(), { state: null, updatedAt: null }); + + const initialState = { + events: [{ id: "event_1", name: "Club race" }], + sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }], + resultsBySession: {}, + }; + 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 storedResponse = await fetch(`${baseUrl}/api/state`); + const storedPayload = await storedResponse.json(); + assert.deepEqual(storedPayload.state, initialState); + + 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); + } finally { + await stopServer(child); + rmSync(dataDir, { recursive: true, force: true }); + } +});