1234import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5import { writeFileSync } from "node:fs";6import { startBackupLoop } from "../src/server/backup.ts";7import { notify, recordSync } from "../src/server/db.ts";8import { Browser, type Instance, instance } from "./browser.ts";9import { makeRepos } from "./fixture.ts";1011let clock = Date.parse("2026-09-29T12:00:00Z");12const tick = (seconds: number) => {13 clock += seconds * 1000;14 setSystemTime(new Date(clock));15};1617describe("operator notifications (§24)", () => {18 let inst: Instance;19 let operator: Browser;20 let followUpLink: string;21 const unread = () =>22 inst.kepr23 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")24 .get()?.n;25 const page = async () => (await operator.get("/admin/notifications")).text();26 const clear = () => inst.kepr.run("DELETE FROM notifications");2728 beforeAll(async () => {29 setSystemTime(new Date(clock));30 const repos = await makeRepos(["app"]);31 inst = await instance(repos.bucketDir);32 operator = new Browser(inst.app);33 await operator.signIn();34 await operator.post("/admin/repos/app", { public: "1" });35 clear();36 const reporter = new Browser(inst.app, "198.51.100.41");37 const form = await (await reporter.request("/app/issues/new")).text();38 tick(4);39 const filed = await reporter.request("/app/issues/new", {40 method: "POST",41 body: new URLSearchParams({42 stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "",43 website: "",44 title: "Crash <on> start",45 body: "It crashed.",46 type: "bug",47 }),48 });49 followUpLink = /href="https:\/\/kepr\.example(\/app\/issues\/1\?token=[0-9a-f]+)"/.exec(50 await filed.text(),51 )?.[1] as string;52 });53 afterAll(() => setSystemTime());5455 test("a filed issue raises one, unread, shown in the sidebar and escaped", async () => {56 expect(unread()).toBe(1);57 const text = await page();58 expect(text).toMatch(59 /href="\/admin\/notifications"[^>]*>notifications <span class="admin-unread">1<\/span>/,60 );61 expect(text).toContain("New issue app #1: Crash <on> start");62 });6364 test("opening one marks it read and goes to what it is about", async () => {65 const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id;66 const opened = await operator.get(`/admin/notifications/${id}`);67 expect(opened.status).toBe(303);68 expect(opened.headers.get("location")).toBe("/admin/issues/app/1");69 expect(unread()).toBe(0);70 expect(await page()).not.toContain('class="admin-unread"');71 expect((await operator.get("/admin/notifications/999999")).status).toBe(404);72 });7374 test("a reporter's follow-up raises one", async () => {75 const reporter = new Browser(inst.app, "198.51.100.41");76 const thread = await (await reporter.request(followUpLink)).text();77 tick(4);78 const posted = await reporter.request("/app/issues/1", {79 method: "POST",80 body: new URLSearchParams({81 token: new URL(followUpLink, "https://x").searchParams.get("token") ?? "",82 stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "",83 body: "Still broken.",84 }),85 });86 expect(posted.status).toBe(303);87 expect(await page()).toContain("Follow-up on app #1: Crash <on> start");88 });8990 test("a sync starting to fail raises one; a failure that continues does not", () => {91 clear();92 recordSync(inst.kepr, "app", 1, "bucket unreachable");93 recordSync(inst.kepr, "app", 2, "bucket unreachable");94 expect(unread()).toBe(1);95 recordSync(inst.kepr, "app", 3, null);96 recordSync(inst.kepr, "app", 4, "format changed");97 expect(unread()).toBe(2);98 });99100 test("a backup starting to fail raises one", () => {101 clear();102 103 writeFileSync(`${inst.dataDir}/backups`, "");104 startBackupLoop(inst.kepr, inst.config);105 const row = inst.kepr106 .query<{ text: string; href: string }, []>("SELECT text, href FROM notifications")107 .get();108 expect(row?.text).toStartWith("Backup failed:");109 expect(row?.href).toBe("/admin?section=backups");110 });111112 test("mark all read, and a kind switched off raises nothing", async () => {113 notify(inst.kepr, "issue_filed", "one", "/admin/issues");114 expect((await operator.post("/admin/notifications", { action: "read_all" })).status).toBe(303);115 expect(unread()).toBe(0);116 const off = await operator.post("/admin/notifications", {117 action: "preferences",118 sync_failed: "1",119 backup_failed: "1",120 issue_followup: "1",121 });122 expect(off.status).toBe(303);123 expect(await page()).toMatch(/name="issue_filed" value="1">/);124 notify(inst.kepr, "issue_filed", "two", "/admin/issues");125 expect(unread()).toBe(0);126 notify(inst.kepr, "sync_failed", "three", "/admin/repos");127 expect(unread()).toBe(1);128 });129130 test("keeps the newest 200", () => {131 clear();132 for (let n = 0; n < 205; n++) notify(inst.kepr, "sync_failed", `n${n}`, "/admin/repos");133 const rows = inst.kepr134 .query<{ text: string }, []>("SELECT text FROM notifications ORDER BY id")135 .all();136 expect(rows.length).toBe(200);137 expect(rows[0]?.text).toBe("n5");138 });139});