2910672b5a
BackupService tracked last-success/last-error as plain in-process fields and scheduled the daily backup via setInterval measured from process start — so any server restart (deploy/crash/OOM/reboot, routine under `restart: always`) silently reset the admin UI to "last successful backup: Never" and drifted the actual cadence, independent of whether backups were writing correctly to disk (they were — a real field incident at park-buzi showed 7 valid rotating backups on disk with the status stuck on "Never"). Persist last-success/error to new site_config columns (migration 0025) and add BackupService.isDue(), computed from the persisted timestamp instead of process uptime; server.ts now polls every 15 min and lets isDue() gate the actual run. No API/UI contract change. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { eq, siteConfig } from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { BackupService } from "./backup-service.js";
|
|
|
|
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
|
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
|
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
|
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
|
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
|
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
|
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
|
// See wiki/concepts/backup-recovery.md.
|
|
|
|
const KEY = "a-test-backup-key-that-is-long-enough";
|
|
|
|
let workDir: string;
|
|
let target: string;
|
|
|
|
beforeEach(() => {
|
|
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
|
target = join(workDir, "target");
|
|
process.env.BACKUP_KEY = KEY;
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
delete process.env.BACKUP_KEY;
|
|
});
|
|
|
|
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
|
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
if (existing) {
|
|
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
|
} else {
|
|
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
|
}
|
|
}
|
|
|
|
describe("BackupService — persisted status survives a restart", () => {
|
|
it("a fresh instance sees the previous instance's last success", async () => {
|
|
const t = createTestDb();
|
|
setTargetDir(t.db, target);
|
|
|
|
const first = new BackupService(t.db);
|
|
expect(first.status().lastSuccessAt).toBeNull();
|
|
const result = await first.run("manual");
|
|
|
|
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
|
// production this would be a fresh process re-opening the same sqlite file).
|
|
const second = new BackupService(t.db);
|
|
const status = second.status();
|
|
expect(status.lastSuccessAt).not.toBeNull();
|
|
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
|
expect(status.lastError).toBeNull();
|
|
|
|
t.close();
|
|
});
|
|
|
|
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
|
const t = createTestDb();
|
|
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
|
// throw, giving us a real, deterministic failure without needing to mock anything.
|
|
const badTarget = join(workDir, "not-a-dir");
|
|
writeFileSync(badTarget, "x");
|
|
setTargetDir(t.db, badTarget);
|
|
|
|
const first = new BackupService(t.db);
|
|
await expect(first.run("manual")).rejects.toThrow();
|
|
|
|
const second = new BackupService(t.db);
|
|
const status = second.status();
|
|
expect(status.lastError).not.toBeNull();
|
|
expect(status.lastErrorAt).not.toBeNull();
|
|
expect(status.lastSuccessAt).toBeNull();
|
|
|
|
// Now point at a real directory and succeed — the persisted error must clear.
|
|
setTargetDir(t.db, target);
|
|
await second.run("manual");
|
|
const third = new BackupService(t.db);
|
|
const finalStatus = third.status();
|
|
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
|
expect(finalStatus.lastError).toBeNull();
|
|
expect(finalStatus.lastErrorAt).toBeNull();
|
|
|
|
t.close();
|
|
});
|
|
});
|
|
|
|
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
|
it("is due immediately when no success has ever been recorded", () => {
|
|
const t = createTestDb();
|
|
const svc = new BackupService(t.db);
|
|
expect(svc.isDue()).toBe(true);
|
|
t.close();
|
|
});
|
|
|
|
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
|
const t = createTestDb();
|
|
setTargetDir(t.db, target);
|
|
const first = new BackupService(t.db);
|
|
await first.run("manual");
|
|
|
|
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
|
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
|
const second = new BackupService(t.db);
|
|
expect(second.isDue()).toBe(false);
|
|
t.close();
|
|
});
|
|
|
|
it("is due once the persisted last-success timestamp is old enough", async () => {
|
|
const t = createTestDb();
|
|
setTargetDir(t.db, target);
|
|
const svc = new BackupService(t.db);
|
|
await svc.run("manual");
|
|
|
|
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
|
expect(svc.isDue(almostADayLater)).toBe(false);
|
|
|
|
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
|
expect(svc.isDue(overADayLater)).toBe(true);
|
|
t.close();
|
|
});
|
|
|
|
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
|
const t = createTestDb();
|
|
setTargetDir(t.db, target);
|
|
const svc = new BackupService(t.db);
|
|
await svc.run("manual");
|
|
const afterFirst = svc.status().lastSuccessAt;
|
|
|
|
await svc.runScheduled(); // not due yet — must not run again
|
|
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
|
t.close();
|
|
});
|
|
});
|