import { beforeEach, describe, expect, it, vi } from "vitest"; import { snapshots, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { pruneSnapshots, type DiskUsage, type SnapshotRetention } from "./snapshot-retention.js"; // Snapshot retention: DISK-PRESSURE prune. No-op unless the DB's filesystem is over the // high-water mark; then delete the OLDEST until ~freeTargetPct of disk is freed (estimated from // the deleted BLOB sizes), honoring a MIN_KEEP floor, then VACUUM once. Disk usage is injected // so the test controls the trigger without touching the real filesystem. let db: Db; beforeEach(() => { ({ db } = createTestDb()); }); /** Insert `n` snapshots, oldest first (s-0 is the oldest), each `bytes` long. */ function seed(n: number, bytes = 1000): void { const t0 = Date.now() - n * 1000; for (let i = 0; i < n; i++) { db.insert(snapshots) .values({ id: `s-${i}`, direction: "entry", deviceId: "cam", identity: `s-${i}`, contentType: "image/jpeg", bytes: Buffer.alloc(bytes, 1), capturedAt: new Date(t0 + i * 1000).toISOString(), // s-0 oldest … s-(n-1) newest }) .run(); } } function count(): number { return db.select().from(snapshots).all().length; } function ids(): string[] { return db.select().from(snapshots).all().map((r) => r.id).sort(); } /** A fake disk at a given used% on a 1 GB volume. */ const disk = (usedPct: number, totalBytes = 1_000_000_000): (() => Promise) => () => Promise.resolve({ usedPct, totalBytes }); const ret = (o: Partial): SnapshotRetention => ({ highPct: 70, freeTargetPct: 10, minKeep: 2, batch: 5, ...o, }); describe("pruneSnapshots (disk-pressure)", () => { it("no-op when disk is below the high-water mark", async () => { seed(10); const res = await pruneSnapshots(db, { retention: ret({}), diskUsage: disk(50) }); expect(res.deletedRows).toBe(0); expect(res.vacuumed).toBe(false); expect(count()).toBe(10); }); it("over the mark: deletes the OLDEST until ~freeTargetPct is freed, then VACUUMs", async () => { // 1 GB disk, target 10% = 100 MB. Each snapshot 20 MB → ~5 deletions reach the target. seed(20, 20 * 1048576); const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec"); const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, batch: 100 }), diskUsage: disk(80) }); expect(res.deletedRows).toBeGreaterThanOrEqual(5); expect(res.freedBytesEst).toBeGreaterThanOrEqual(0.1 * 1_000_000_000); expect(res.vacuumed).toBe(true); expect(vacuumSpy).toHaveBeenCalledWith("VACUUM"); // The survivors are the NEWEST (oldest went first). const survivors = ids(); expect(survivors).toContain(`s-19`); // newest kept expect(survivors).not.toContain(`s-0`); // oldest pruned vacuumSpy.mockRestore(); }); it("honors the MIN_KEEP floor even when still over target", async () => { // Target 10% of 1 GB = 100 MB, but only 3 tiny snapshots exist and minKeep=2 → at most 1 deleted. seed(3, 1000); const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2, freeTargetPct: 90 }), diskUsage: disk(95) }); expect(res.deletedRows).toBe(1); // 3 − minKeep(2) expect(count()).toBe(2); expect(res.floorHitWhileOver).toBe(true); // couldn't reach target without crossing the floor }); it("skips VACUUM when nothing was deleted", async () => { seed(2); // == minKeep, so nothing to delete even over the mark const vacuumSpy = vi.spyOn(db.$client as { exec: (s: string) => void }, "exec"); const res = await pruneSnapshots(db, { retention: ret({ minKeep: 2 }), diskUsage: disk(99) }); expect(res.deletedRows).toBe(0); expect(res.vacuumed).toBe(false); expect(vacuumSpy).not.toHaveBeenCalled(); vacuumSpy.mockRestore(); }); });