109 lines
5.0 KiB
JavaScript
109 lines
5.0 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createRequire } from "node:module";
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { tmpdir } from "node:os";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const Database = require("better-sqlite3");
|
|
const { createBackupService, safeSegment } = require("../server/backup_service.js");
|
|
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
|
function createTestDb(dataDir) {
|
|
const db = new Database(join(dataDir, "rc_timing.sqlite"));
|
|
db.exec(`
|
|
CREATE TABLE app_state (id INTEGER PRIMARY KEY, state_json TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
CREATE TABLE passings (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL);
|
|
`);
|
|
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?)").run(JSON.stringify({ events: [{ id: "event_1" }] }), "2026-08-31T10:00:00.000Z");
|
|
db.prepare("INSERT INTO passings (session_id) VALUES (?)").run("session_1");
|
|
return db;
|
|
}
|
|
|
|
test("backup service creates consistent SQLite snapshots and metadata", async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-"));
|
|
const db = createTestDb(dataDir);
|
|
try {
|
|
const service = createBackupService({ db, dataDir, maxBackups: 3, now: () => new Date("2026-08-31T10:15:30.123Z") });
|
|
const backup = await service.createBackup({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" });
|
|
assert.match(backup.fileName, /pre_start__session_1\.sqlite$/);
|
|
assert.equal(backup.reason, "pre_start");
|
|
assert.ok(backup.sizeBytes > 0);
|
|
|
|
db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true }));
|
|
const snapshot = new Database(join(dataDir, "backups", backup.fileName), { readonly: true });
|
|
try {
|
|
assert.deepEqual(JSON.parse(snapshot.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] });
|
|
assert.equal(snapshot.prepare("SELECT COUNT(*) AS count FROM passings").get().count, 1);
|
|
} finally {
|
|
snapshot.close();
|
|
}
|
|
|
|
const listed = service.listBackups();
|
|
assert.equal(listed.length, 1);
|
|
assert.equal(listed[0].sessionId, "session_1");
|
|
const metadata = JSON.parse(readFileSync(join(dataDir, "backups", backup.fileName.replace(/\.sqlite$/, ".json")), "utf8"));
|
|
assert.equal(metadata.eventId, "event_1");
|
|
} finally {
|
|
db.close();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("backup service keeps only the configured number of snapshots", async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-retention-"));
|
|
const db = createTestDb(dataDir);
|
|
let index = 0;
|
|
try {
|
|
const service = createBackupService({ db, dataDir, maxBackups: 2, now: () => new Date(1_800_000_000_000 + index++ * 1000) });
|
|
await service.createBackup({ reason: "manual", sessionId: "one" });
|
|
await service.createBackup({ reason: "manual", sessionId: "two" });
|
|
await service.createBackup({ reason: "manual", sessionId: "three" });
|
|
const listed = service.listBackups();
|
|
assert.equal(listed.length, 2);
|
|
assert.ok(listed.some((item) => item.sessionId === "three"));
|
|
assert.ok(!listed.some((item) => item.sessionId === "one"));
|
|
} finally {
|
|
db.close();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("local restore validates and replaces a stopped test database with a safety backup", async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "racecore-restore-"));
|
|
let db = createTestDb(dataDir);
|
|
try {
|
|
const service = createBackupService({ db, dataDir, maxBackups: 5 });
|
|
const backup = await service.createBackup({ reason: "manual", sessionId: "session_1" });
|
|
db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true }));
|
|
db.close();
|
|
db = null;
|
|
|
|
const result = spawnSync(process.execPath, [join(projectRoot, "scripts", "restore-backup.js"), backup.fileName, "--confirm"], {
|
|
cwd: projectRoot,
|
|
env: { ...process.env, RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: join(dataDir, "missing.pid") },
|
|
encoding: "utf8",
|
|
});
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
const restored = new Database(join(dataDir, "rc_timing.sqlite"), { readonly: true });
|
|
try {
|
|
assert.deepEqual(JSON.parse(restored.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] });
|
|
} finally {
|
|
restored.close();
|
|
}
|
|
const safetyFiles = service.listBackups().filter((item) => item.reason === "pre_restore");
|
|
assert.equal(safetyFiles.length, 1);
|
|
} finally {
|
|
if (db) db.close();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("backup file segments are bounded and filesystem safe", () => {
|
|
assert.equal(safeSegment("A final / ../ unsafe"), "a-final-unsafe");
|
|
assert.equal(safeSegment(""), "backup");
|
|
});
|