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";1011type Feed = {12 unread: number;13 notifications: { id: number; text: string; read: number; when: string }[];14};1516let clock = Date.parse("2026-09-29T12:00:00Z");17const tick = (seconds: number) => {18 clock += seconds * 1000;19 setSystemTime(new Date(clock));20};2122describe("operator notifications (§24)", () => {23 let inst: Instance;24 let operator: Browser;25 let followUpLink: string;26 const unread = () =>27 inst.kepr28 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")29 .get()?.n;30 const page = async () => (await operator.get("/admin/notifications")).text();31 const clear = () => inst.kepr.run("DELETE FROM notifications");3233 beforeAll(async () => {34 setSystemTime(new Date(clock));35 const repos = await makeRepos(["app"]);36 inst = await instance(repos.bucketDir);37 operator = new Browser(inst.app);38 await operator.signIn();39 await operator.post("/admin/repos/app", { public: "1" });40 clear();41 const reporter = new Browser(inst.app, "198.51.100.41");42 const form = await (await reporter.request("/app/issues/new")).text();43 tick(4);44 const filed = await reporter.request("/app/issues/new", {45 method: "POST",46 body: new URLSearchParams({47 stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "",48 website: "",49 title: "Crash <on> start",50 body: "It crashed.",51 type: "bug",52 }),53 });54 followUpLink = /href="https:\/\/kepr\.example(\/app\/issues\/1\?token=[0-9a-f]+)"/.exec(55 await filed.text(),56 )?.[1] as string;57 });58 afterAll(() => setSystemTime());5960 test("a filed issue raises one, unread, shown in the sidebar and escaped", async () => {61 expect(unread()).toBe(1);62 const text = await page();63 expect(text).toMatch(64 /href="\/admin\/notifications"[^>]*>notifications <span class="admin-unread">1<\/span>/,65 );66 expect(text).toContain("New issue app #1: Crash <on> start");67 });6869 test("the navbar feed is private, bounded and leaves unread state unchanged", async () => {70 expect((await inst.app.request("/admin/notifications?format=json")).status).toBe(401);71 const response = await operator.get("/admin/notifications?format=json");72 expect(response.headers.get("cache-control")).toContain("no-store");73 const feed = (await response.json()) as Feed;74 expect(feed.unread).toBe(1);75 expect(feed.notifications).toHaveLength(1);76 expect(feed.notifications[0].text).toContain("Crash <on> start");77 expect(feed.notifications[0].read).toBe(0);78 expect(unread()).toBe(1);79 });8081 test("opening one marks it read and goes to what it is about", async () => {82 const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id;83 const opened = await operator.get(`/admin/notifications/${id}`);84 expect(opened.status).toBe(303);85 expect(opened.headers.get("location")).toBe("/admin/issues/app/1");86 expect(unread()).toBe(0);87 expect(await page()).not.toContain('class="admin-unread"');88 expect((await operator.get("/admin/notifications/999999")).status).toBe(404);89 });9091 test("a reporter's follow-up raises one", async () => {92 const reporter = new Browser(inst.app, "198.51.100.41");93 const thread = await (await reporter.request(followUpLink)).text();94 tick(4);95 const posted = await reporter.request("/app/issues/1", {96 method: "POST",97 body: new URLSearchParams({98 token: new URL(followUpLink, "https://x").searchParams.get("token") ?? "",99 stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "",100 body: "Still broken.",101 }),102 });103 expect(posted.status).toBe(303);104 expect(await page()).toContain("Follow-up on app #1: Crash <on> start");105 });106107 test("a sync starting to fail raises one; a failure that continues does not", () => {108 clear();109 recordSync(inst.kepr, "app", 1, "bucket unreachable");110 recordSync(inst.kepr, "app", 2, "bucket unreachable");111 expect(unread()).toBe(1);112 recordSync(inst.kepr, "app", 3, null);113 recordSync(inst.kepr, "app", 4, "format changed");114 expect(unread()).toBe(2);115 });116117 test("a backup starting to fail raises one", () => {118 clear();119 120 writeFileSync(`${inst.dataDir}/backups`, "");121 startBackupLoop(inst.kepr, inst.config);122 const row = inst.kepr123 .query<{ text: string; href: string }, []>("SELECT text, href FROM notifications")124 .get();125 expect(row?.text).toStartWith("Backup failed:");126 expect(row?.href).toBe("/admin?section=backups");127 });128129 test("mark all read, and a kind switched off raises nothing", async () => {130 notify(inst.kepr, "issue_filed", "one", "/admin/issues");131 expect((await operator.post("/admin/notifications", { action: "read_all" })).status).toBe(303);132 expect(unread()).toBe(0);133 const off = await operator.post("/admin/notifications", {134 action: "preferences",135 sync_failed: "1",136 backup_failed: "1",137 issue_followup: "1",138 });139 expect(off.status).toBe(303);140 expect(await page()).toMatch(/name="issue_filed" value="1">/);141 notify(inst.kepr, "issue_filed", "two", "/admin/issues");142 expect(unread()).toBe(0);143 notify(inst.kepr, "sync_failed", "three", "/admin/repos");144 expect(unread()).toBe(1);145 });146147 test("keeps the newest 200", () => {148 clear();149 for (let n = 0; n < 205; n++) notify(inst.kepr, "sync_failed", `n${n}`, "/admin/repos");150 const rows = inst.kepr151 .query<{ text: string }, []>("SELECT text FROM notifications ORDER BY id")152 .all();153 expect(rows.length).toBe(200);154 expect(rows[0]?.text).toBe("n5");155 });156 test("the navbar shows only the newest five while counting every unread item", async () => {157 clear();158 for (let i = 0; i < 8; i++) notify(inst.kepr, "sync_failed", `recent ${i}`, "/admin/issues");159 const feed = (await (await operator.get("/admin/notifications?format=json")).json()) as Feed;160 expect(feed.unread).toBe(8);161 expect(feed.notifications.map((n: { text: string }) => n.text)).toEqual([162 "recent 7",163 "recent 6",164 "recent 5",165 "recent 4",166 "recent 3",167 ]);168 expect(unread()).toBe(8);169 });170});