1234import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5import { unlinkSync, writeFileSync } from "node:fs";6import { backups, makeBackup, startBackupLoop } from "../src/server/backup.ts";7import { setting } from "../src/server/db.ts";8import { Browser, type Instance, instance } from "./browser.ts";9import { makeRepos } from "./fixture.ts";1011describe("the backup workspace (§24)", () => {12 let inst: Instance;13 let operator: Browser;14 let clock = Date.parse("2026-09-27T03:00:00Z");15 const page = async () => (await operator.get("/admin?section=backups")).text();1617 beforeAll(async () => {18 setSystemTime(new Date(clock));19 const repos = await makeRepos(["app"]);20 inst = await instance(repos.bucketDir);21 for (let day = 3; day > 0; day--) {22 makeBackup(inst.kepr, inst.dataDir, clock - day * 86_400_000);23 }24 operator = new Browser(inst.app);25 await operator.signIn();26 });27 afterAll(() => setSystemTime());2829 test("lists every kept backup, newest first, with its size and its own download", async () => {30 const text = await page();31 const listed = [...text.matchAll(/href="\/admin\/backup\?name=(\d{8}T\d{6}Z)"/g)].map(32 (match) => match[1],33 );34 expect(listed).toEqual(["20260926T030000Z", "20260925T030000Z", "20260924T030000Z"]);35 expect(text).toMatch(/2026-09-24 03:00:00 UTC<\/a>\s*<span class="muted">[\d.]+ KB<\/span>/);36 const download = await operator.request("/admin/backup?name=20260925T030000Z");37 expect(download.status).toBe(200);38 expect(download.headers.get("content-disposition")).toBe(39 'attachment; filename="kepr-backup-20260925T030000Z.tar.gz"',40 );41 expect((await operator.request("/admin/backup?name=20260101T000000Z")).status).toBe(404);42 expect((await operator.request("/admin/backup?name=../kepr.db")).status).toBe(404);43 });4445 test("says how to restore one by hand", async () => {46 const text = await page();47 expect(text).toContain("Stop Kepr.");48 expect(text).toContain("kepr.db and assets/");49 });5051 test("back up now makes one at once, even with today's done", async () => {52 clock += 3600_000;53 setSystemTime(new Date(clock));54 const before = backups(inst.dataDir).length;55 const made = await operator.post("/admin/backup", {});56 expect(made.status).toBe(303);57 expect(made.headers.get("location")).toBe("/admin?section=backups&saved=1");58 expect(backups(inst.dataDir).length).toBe(before + 1);59 expect(backups(inst.dataDir).at(-1)).toBe("20260927T040000Z");60 });6162 test("the last failure stays shown until a backup succeeds", async () => {63 const saved = `${inst.dataDir}/backups-kept`;64 Bun.spawnSync(["mv", `${inst.dataDir}/backups`, saved]);65 writeFileSync(`${inst.dataDir}/backups`, "");66 startBackupLoop(inst.kepr, inst.config);67 expect(setting(inst.kepr, "backup_error")).toContain("ENOTDIR");68 const failed = await operator.post("/admin/backup", {});69 expect(failed.status).toBe(500);70 expect(await page()).toContain("The last backup failed:");71 unlinkSync(`${inst.dataDir}/backups`);72 Bun.spawnSync(["mv", saved, `${inst.dataDir}/backups`]);73 expect((await operator.post("/admin/backup", {})).status).toBe(303);74 expect(setting(inst.kepr, "backup_error")).toBeNull();75 expect(await page()).not.toContain("The last backup failed:");76 });77});