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["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(); }); });