A fresh Windows install reported the server failing around backups. createBackupService created the backups subdirectory once, synchronously, at startup with no error handling - any failure there (permissions, a path beyond Windows' MAX_PATH, a OneDrive/cloud-sync lock, antivirus) crashed the entire Node process before it started listening, taking down timing with it instead of just leaving backups unavailable. Directory creation is now retried lazily and non-fatally on each backup attempt, and a post-backup file rename retries briefly on EBUSY/EPERM since a freshly written file is sometimes momentarily locked by antivirus/cloud-sync scanning on Windows. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
127 lines
5.9 KiB
JavaScript
127 lines
5.9 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 { chmodSync, 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("an unwritable backups directory never crashes server startup, and surfaces a clear error on backup instead", async () => {
|
|
const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-blocked-"));
|
|
const db = createTestDb(dataDir);
|
|
try {
|
|
chmodSync(dataDir, 0o500); // read/execute only: mkdir("backups") inside it fails with EACCES
|
|
let service;
|
|
assert.doesNotThrow(() => {
|
|
service = createBackupService({ db, dataDir, maxBackups: 3 });
|
|
}, "constructing the service must not throw even if the backups directory cannot be created");
|
|
assert.deepEqual(service.listBackups(), [], "listing degrades to empty instead of throwing");
|
|
await assert.rejects(() => service.createBackup({ reason: "manual" }), /backup directory/i);
|
|
} finally {
|
|
chmodSync(dataDir, 0o700);
|
|
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");
|
|
});
|