diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 90dbee2..087ed1a 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -104,6 +104,7 @@ Scripts: - `npm run status` - `npm restart` - `npm run verify` +- `npm test` - `npm run setup:windows` ### `server.js` @@ -117,6 +118,7 @@ Uses: - `child_process.spawn` - `express` - `better-sqlite3` +- `server/public_training_profile.js` Owns: - HTTP server @@ -129,6 +131,8 @@ Owns: - tokenized public driver training profile API and standalone mobile shell - PDF export endpoint +Delegates public training token validation and response shaping to `server/public_training_profile.js`; `server.js` does not duplicate those rules. + Main API groups: - `/api/health` - `/api/app-version` @@ -154,6 +158,22 @@ Runtime environment variables: - `RC_TIMING_PID_FILE`: optional PID-file path used by process control - `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes +### `server/public_training_profile.js` +Role: +- pure CommonJS backend helper for tokenized driver profiles + +Owns: +- enabled share-token lookup +- selected-driver lap filtering and aggregate statistics +- sanitized branding, class, goal, rank, and recommendation response +- clock-skew-safe personal timer start/window normalization +- bounded current-session lap, best-lap, and average calculations + +Security boundary: +- returns no driver IDs, transponders, raw passings, decoder settings, or other driver names +- disabled, short, unknown, or driverless tokens return no profile +- logo data is restricted to bounded PNG/JPEG/WebP data URLs + ### `scripts/serverctl.js` Role: - local process manager for `server.js` @@ -511,7 +531,8 @@ Owns: - `src/event_views.js` ### Backend/root imports -- `server.js` imports Node core + `express` + `better-sqlite3` +- `server.js` imports Node core + `express` + `better-sqlite3` + `server/public_training_profile.js` +- `server/public_training_profile.js` imports no runtime modules - `scripts/serverctl.js` imports Node core only ## 3.2 Injected dependency graph @@ -883,6 +904,7 @@ sequenceDiagram | `public-training.html` | standalone entry | `public_training.css`, `public_training.js` | `server.js` route | root only | no | no | | `src/public_training.js` | standalone public view | none | `public-training.html` | yes | local timer only | no backend mutation | | `src/public_training.css` | standalone stylesheet | n/a | `public-training.html` | n/a | no | no | +| `server/public_training_profile.js` | backend pure logic | none | `server.js`, tests | no | no | no | | `server.js` | backend runtime | node core + deps | npm/node | n/a | backend process state | yes | | `scripts/serverctl.js` | process control | node core | npm scripts | n/a | no | pid/log files only | @@ -2045,6 +2067,12 @@ npm test - recommendation branch selection - custom goal and automatic milestone progress +`tests/public_training_profile.test.js` covers: +- enabled/disabled/unknown share-token handling +- selected-driver isolation and response sanitization +- personal timer aggregate and frozen-window calculations +- clock-skew-safe query normalization and bounds + 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 diff --git a/package.json b/package.json index 52eba04..e855ec0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "status": "node scripts/serverctl.js status", "restart": "node scripts/serverctl.js restart", "verify": "node scripts/verify-install.js", - "test": "node --experimental-default-type=module tests/training_logic.test.js", + "test": "node --experimental-default-type=module tests/run.js", "setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1" }, "dependencies": { diff --git a/server.js b/server.js index 7b7f27a..be00cf5 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const os = require("os"); const { spawn } = require("child_process"); const express = require("express"); const Database = require("better-sqlite3"); +const publicTrainingProfile = require("./server/public_training_profile"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; @@ -284,9 +285,9 @@ app.get(["/public-overlay", "/public-overlay/:mode"], (req, res) => { app.get("/public-training/api/:shareToken", (req, res) => { try { const stored = readStoredAppState(); - const sinceTimestamp = normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs); - const windowMs = normalizePublicTrainingWindow(req.query.windowMs); - const profile = stored ? buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null; + const sinceTimestamp = publicTrainingProfile.normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs); + const windowMs = publicTrainingProfile.normalizePublicTrainingWindow(req.query.windowMs); + const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null; if (!profile) { res.status(404).json({ error: "Profile not found" }); return; @@ -301,7 +302,7 @@ app.get("/public-training/api/:shareToken", (req, res) => { app.get("/public-training/:shareToken", (req, res) => { try { const stored = readStoredAppState(); - if (!stored || !hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) { + if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, String(req.params.shareToken || ""))) { res.status(404).type("text/plain").send("Profile not found"); return; } @@ -376,194 +377,6 @@ function readStoredAppState() { return { state: JSON.parse(row.state_json), updatedAt: row.updated_at }; } -function findPublicTrainingShare(state, shareToken) { - if (!shareToken || shareToken.length < 24) return null; - const shares = Array.isArray(state?.training?.shares) ? state.training.shares : []; - return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null; -} - -function hasPublicTrainingShare(state, shareToken) { - return Boolean(findPublicTrainingShare(state, shareToken)); -} - -function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) { - const share = findPublicTrainingShare(state, shareToken); - if (!share) return null; - const driverId = String(share.driverId || ""); - const drivers = Array.isArray(state?.drivers) ? state.drivers : []; - const driver = drivers.find((item) => String(item?.id || "") === driverId); - if (!driver) return null; - - const classes = Array.isArray(state?.classes) ? state.classes : []; - const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || "")); - const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; - const driverLaps = ledger - .filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500) - .map((entry) => ({ - timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), - lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), - sessionId: String(entry.sessionId || ""), - })) - .sort((left, right) => left.timestamp - right.timestamp); - const stats = buildPublicTrainingStats(driverLaps); - const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) - .filter((goal) => String(goal?.driverId || "") === driverId) - .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); - const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean))); - const rankIndex = rankedTotals.findIndex(([id]) => id === driverId); - const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; - const currentSession = sinceTimestamp - ? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) - : null; - - return { - updatedAt: String(updatedAt || ""), - profile: { - name: String(driver.name || "Driver").trim(), - brand: String(driver.brand || "").trim(), - className: String(driverClass?.name || "").trim(), - clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, - rankedDrivers: rankedTotals.length, - }, - branding: { - clubName: String(settings.clubName || "JMK RB RaceController").trim(), - clubTagline: String(settings.clubTagline || "RC Timing System").trim(), - logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), - }, - stats, - nextMilestone: getPublicTrainingMilestone(stats.totalLaps), - recommendation: getPublicTrainingRecommendation(stats), - goals, - currentSession, - }; -} - -function normalizePublicTrainingSince(value, startedAgoValue) { - const now = Date.now(); - if (startedAgoValue !== undefined) { - const startedAgoMs = Math.round(Number(startedAgoValue)); - if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) { - return now - startedAgoMs; - } - } - const timestamp = Math.round(Number(value || 0) || 0); - return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0; -} - -function normalizePublicTrainingWindow(value) { - const windowMs = Math.round(Number(value || 0) || 0); - return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs)); -} - -function buildPublicTrainingWindowStats(laps) { - const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); - return { - laps: lapTimes.length, - bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, - averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, - }; -} - -function buildPublicTrainingGoal(goal, currentLaps) { - const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1)); - return { - title: String(goal?.title || "").trim(), - reward: String(goal?.reward || "").trim(), - targetLaps, - currentLaps, - remainingLaps: Math.max(0, targetLaps - currentLaps), - percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), - reached: currentLaps >= targetLaps, - claimed: Boolean(goal?.claimedAt), - }; -} - -function getRankedTrainingTotals(ledger, registeredDriverIds) { - const totals = new Map(); - ledger.forEach((entry) => { - const driverId = String(entry?.driverId || ""); - if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return; - totals.set(driverId, (totals.get(driverId) || 0) + 1); - }); - return [...totals.entries()].sort((left, right) => right[1] - left[1]); -} - -function buildPublicTrainingStats(laps) { - const lapTimes = laps.map((lap) => lap.lapMs); - const recent = lapTimes.slice(-30); - const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; - const sortedRecent = [...recent].sort((left, right) => left - right); - const percentile = (ratio) => sortedRecent.length - ? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))] - : 0; - const recentAverageMs = average(recent); - const daily = new Map(); - laps.forEach((lap) => { - const date = new Date(lap.timestamp); - const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; - const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; - day.laps += 1; - day.totalMs += lap.lapMs; - day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs; - day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp); - daily.set(dateKey, day); - }); - const dailySummaries = [...daily.values()] - .map((day) => ({ - dateKey: day.dateKey, - laps: day.laps, - bestLapMs: day.bestLapMs, - latestTimestamp: day.latestTimestamp, - averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0, - })) - .sort((left, right) => right.latestTimestamp - left.latestTimestamp) - .slice(0, 30); - const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0; - const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; - let controlledLapStreak = 0; - for (let index = lapTimes.length - 1; index >= 0; index -= 1) { - if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; - controlledLapStreak += 1; - } - - return { - totalLaps: laps.length, - totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size, - trainingDays: daily.size, - bestLapMs, - averageLapMs: average(lapTimes), - recentAverageMs, - recentLapCount: recent.length, - consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0, - controlledLapStreak, - dailySummaries, - }; -} - -function normalizePublicLogoDataUrl(value) { - const logo = String(value || "").trim(); - return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; -} - -function getPublicTrainingMilestone(currentLaps) { - const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; - const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; - return { - currentLaps, - targetLaps, - remainingLaps: Math.max(0, targetLaps - currentLaps), - percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), - }; -} - -function getPublicTrainingRecommendation(stats) { - if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 }; - if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { - return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) }; - } - return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) }; -} - function normalizePdfExportPayload(input = {}) { const title = String(input.title || "JMK RB Live Event").trim(); const subtitle = String(input.subtitle || "").trim(); diff --git a/server/public_training_profile.js b/server/public_training_profile.js new file mode 100644 index 0000000..ece6041 --- /dev/null +++ b/server/public_training_profile.js @@ -0,0 +1,195 @@ +function findPublicTrainingShare(state, shareToken) { + if (!shareToken || shareToken.length < 24) return null; + const shares = Array.isArray(state?.training?.shares) ? state.training.shares : []; + return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null; +} + +function hasPublicTrainingShare(state, shareToken) { + return Boolean(findPublicTrainingShare(state, shareToken)); +} + +function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) { + const share = findPublicTrainingShare(state, shareToken); + if (!share) return null; + const driverId = String(share.driverId || ""); + const drivers = Array.isArray(state?.drivers) ? state.drivers : []; + const driver = drivers.find((item) => String(item?.id || "") === driverId); + if (!driver) return null; + + const classes = Array.isArray(state?.classes) ? state.classes : []; + const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || "")); + const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + const driverLaps = ledger + .filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500) + .map((entry) => ({ + timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), + lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), + sessionId: String(entry.sessionId || ""), + })) + .sort((left, right) => left.timestamp - right.timestamp); + const stats = buildPublicTrainingStats(driverLaps); + const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) + .filter((goal) => String(goal?.driverId || "") === driverId) + .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); + const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean))); + const rankIndex = rankedTotals.findIndex(([id]) => id === driverId); + const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; + const currentSession = sinceTimestamp + ? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) + : null; + + return { + updatedAt: String(updatedAt || ""), + profile: { + name: String(driver.name || "Driver").trim(), + brand: String(driver.brand || "").trim(), + className: String(driverClass?.name || "").trim(), + clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, + rankedDrivers: rankedTotals.length, + }, + branding: { + clubName: String(settings.clubName || "JMK RB RaceController").trim(), + clubTagline: String(settings.clubTagline || "RC Timing System").trim(), + logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), + }, + stats, + nextMilestone: getPublicTrainingMilestone(stats.totalLaps), + recommendation: getPublicTrainingRecommendation(stats), + goals, + currentSession, + }; +} + +function normalizePublicTrainingSince(value, startedAgoValue) { + const now = Date.now(); + if (startedAgoValue !== undefined) { + const startedAgoMs = Math.round(Number(startedAgoValue)); + if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) { + return now - startedAgoMs; + } + } + const timestamp = Math.round(Number(value || 0) || 0); + return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0; +} + +function normalizePublicTrainingWindow(value) { + const windowMs = Math.round(Number(value || 0) || 0); + return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs)); +} + +function buildPublicTrainingWindowStats(laps) { + const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); + return { + laps: lapTimes.length, + bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, + averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, + }; +} + +function buildPublicTrainingGoal(goal, currentLaps) { + const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1)); + return { + title: String(goal?.title || "").trim(), + reward: String(goal?.reward || "").trim(), + targetLaps, + currentLaps, + remainingLaps: Math.max(0, targetLaps - currentLaps), + percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), + reached: currentLaps >= targetLaps, + claimed: Boolean(goal?.claimedAt), + }; +} + +function getRankedTrainingTotals(ledger, registeredDriverIds) { + const totals = new Map(); + ledger.forEach((entry) => { + const driverId = String(entry?.driverId || ""); + if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return; + totals.set(driverId, (totals.get(driverId) || 0) + 1); + }); + return [...totals.entries()].sort((left, right) => right[1] - left[1]); +} + +function buildPublicTrainingStats(laps) { + const lapTimes = laps.map((lap) => lap.lapMs); + const recent = lapTimes.slice(-30); + const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; + const sortedRecent = [...recent].sort((left, right) => left - right); + const percentile = (ratio) => sortedRecent.length + ? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))] + : 0; + const recentAverageMs = average(recent); + const daily = new Map(); + laps.forEach((lap) => { + const date = new Date(lap.timestamp); + const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; + day.laps += 1; + day.totalMs += lap.lapMs; + day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs; + day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp); + daily.set(dateKey, day); + }); + const dailySummaries = [...daily.values()] + .map((day) => ({ + dateKey: day.dateKey, + laps: day.laps, + bestLapMs: day.bestLapMs, + latestTimestamp: day.latestTimestamp, + averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0, + })) + .sort((left, right) => right.latestTimestamp - left.latestTimestamp) + .slice(0, 30); + const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0; + const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; + let controlledLapStreak = 0; + for (let index = lapTimes.length - 1; index >= 0; index -= 1) { + if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; + controlledLapStreak += 1; + } + + return { + totalLaps: laps.length, + totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size, + trainingDays: daily.size, + bestLapMs, + averageLapMs: average(lapTimes), + recentAverageMs, + recentLapCount: recent.length, + consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0, + controlledLapStreak, + dailySummaries, + }; +} + +function normalizePublicLogoDataUrl(value) { + const logo = String(value || "").trim(); + return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; +} + +function getPublicTrainingMilestone(currentLaps) { + const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; + const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; + return { + currentLaps, + targetLaps, + remainingLaps: Math.max(0, targetLaps - currentLaps), + percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), + }; +} + +function getPublicTrainingRecommendation(stats) { + if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 }; + if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { + return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) }; + } + return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) }; +} + +module.exports = { + buildPublicTrainingProfile, + findPublicTrainingShare, + hasPublicTrainingShare, + normalizePublicTrainingSince, + normalizePublicTrainingWindow, +}; diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js new file mode 100644 index 0000000..1510320 --- /dev/null +++ b/tests/public_training_profile.test.js @@ -0,0 +1,111 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + buildPublicTrainingProfile, + findPublicTrainingShare, + hasPublicTrainingShare, + normalizePublicTrainingSince, + normalizePublicTrainingWindow, +} = require("../server/public_training_profile.js"); + +const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890"; +const DRIVER_TWO_TOKEN = "driver-two-token-12345678901234567890"; +const DISABLED_TOKEN = "disabled-token-123456789012345678901"; + +function buildState(now = Date.now()) { + return { + drivers: [ + { id: "driver_1", name: "Driver One", brand: "Associated", classId: "class_1", transponder: "secret-transponder-one" }, + { id: "driver_2", name: "Driver Two", brand: "Xray", classId: "class_2", transponder: "secret-transponder-two" }, + ], + classes: [ + { id: "class_1", name: "Modified" }, + { id: "class_2", name: "Stock" }, + ], + settings: { + clubName: "Test Club", + clubTagline: "Practice", + logoDataUrl: "javascript:alert(1)", + wsUrl: "ws://secret-decoder:9000", + }, + training: { + shares: [ + { driverId: "driver_1", token: DRIVER_ONE_TOKEN, enabled: true }, + { driverId: "driver_2", token: DRIVER_TWO_TOKEN, enabled: true }, + { driverId: "driver_1", token: DISABLED_TOKEN, enabled: false }, + ], + goals: [ + { driverId: "driver_1", title: "Bronze", reward: "Badge", targetLaps: 5 }, + { driverId: "driver_2", title: "Other goal", reward: "Other reward", targetLaps: 10 }, + ], + ledger: [ + { driverId: "driver_1", sessionId: "free", timestamp: now - 25 * 60000, lapMs: 19000 }, + { driverId: "driver_1", sessionId: "free", timestamp: now - 120000, lapMs: 18000 }, + { driverId: "driver_1", sessionId: "free", timestamp: now - 12000, lapMs: 16500 }, + { driverId: "driver_1", sessionId: "free", timestamp: now - 4000, lapMs: 16000 }, + { driverId: "driver_1", sessionId: "free", timestamp: now - 2000, lapMs: 500 }, + { driverId: "driver_2", sessionId: "free", timestamp: now - 3000, lapMs: 12000 }, + ], + }, + }; +} + +test("public share lookup rejects short, disabled, and unknown tokens", () => { + const state = buildState(); + assert.equal(findPublicTrainingShare(state, "short"), null); + assert.equal(findPublicTrainingShare(state, DISABLED_TOKEN), null); + assert.equal(hasPublicTrainingShare(state, "unknown-token-123456789012345678"), false); + assert.equal(hasPublicTrainingShare(state, DRIVER_ONE_TOKEN), true); +}); + +test("public profile contains only the selected driver's sanitized aggregates", () => { + const now = Date.now(); + const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "2026-08-24T12:00:00.000Z", now - 30000, 30000); + + assert.equal(profile.profile.name, "Driver One"); + assert.equal(profile.profile.brand, "Associated"); + assert.equal(profile.profile.className, "Modified"); + assert.equal(profile.profile.clubRank, 1); + assert.equal(profile.profile.rankedDrivers, 2); + assert.equal(profile.branding.clubName, "Test Club"); + assert.equal(profile.branding.logoDataUrl, ""); + assert.equal(profile.stats.totalLaps, 4); + assert.equal(profile.stats.bestLapMs, 16000); + assert.deepEqual(profile.currentSession, { laps: 2, bestLapMs: 16000, averageLapMs: 16250 }); + assert.equal(profile.goals.length, 1); + assert.equal(profile.goals[0].title, "Bronze"); + assert.equal(profile.goals[0].percent, 80); + + const serialized = JSON.stringify(profile); + assert.equal(serialized.includes("Driver Two"), false); + assert.equal(serialized.includes("driver_1"), false); + assert.equal(serialized.includes("secret-transponder"), false); + assert.equal(serialized.includes("secret-decoder"), false); +}); + +test("public timer window freezes aggregates after the personal session", () => { + const now = Date.now(); + const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", now - 30 * 60000, 20 * 60000); + + assert.deepEqual(profile.currentSession, { laps: 1, bestLapMs: 19000, averageLapMs: 19000 }); +}); + +test("public profile returns null when token or driver is unavailable", () => { + const state = buildState(); + assert.equal(buildPublicTrainingProfile(state, DISABLED_TOKEN, ""), null); + state.drivers = state.drivers.filter((driver) => driver.id !== "driver_1"); + assert.equal(buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, ""), null); +}); + +test("public timer query normalization bounds age and window values", () => { + const before = Date.now(); + const since = normalizePublicTrainingSince(undefined, "30000"); + const after = Date.now(); + assert.ok(since >= before - 30000 && since <= after - 30000); + assert.equal(normalizePublicTrainingSince(undefined, String(25 * 60 * 60 * 1000)), 0); + assert.equal(normalizePublicTrainingWindow("-100"), 0); + assert.equal(normalizePublicTrainingWindow(String(25 * 60 * 60 * 1000)), 24 * 60 * 60 * 1000); +}); diff --git a/tests/run.js b/tests/run.js new file mode 100644 index 0000000..f2eefac --- /dev/null +++ b/tests/run.js @@ -0,0 +1,2 @@ +import "./training_logic.test.js"; +import "./public_training_profile.test.js";