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 const unread = () =>26 inst.kepr27 .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")28 .get()?.n;29 const page = async () => (await operator.get("/admin/notifications")).text();30 const clear = () => inst.kepr.run("DELETE FROM notifications");3132 beforeAll(async () => {33 setSystemTime(new Date(clock));34 const repos = await makeRepos(["app"]);35 inst = await instance(repos.bucketDir);36 operator = new Browser(inst.app);37 await operator.signIn();38 await operator.post("/admin/repos/app", { public: "1" });39 clear();40 const reporter = new Browser(inst.app, "198.51.100.41");41 const form = await (await reporter.request("/app/issues/new")).text();42 tick(4);43 await reporter.request("/app/issues/new", {44 method: "POST",45 body: new URLSearchParams({46 stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "",47 website: "",48 title: "Crash <on> start",49 body: "It crashed.",50 type: "bug",51 }),52 });53 });54 afterAll(() => setSystemTime());5556 test("a filed issue raises one, unread, shown in the sidebar and escaped", async () => {57 expect(unread()).toBe(1);58 const text = await page();59 expect(text).toMatch(60 /href="\/admin\/notifications"[^>]*>notifications <span class="admin-unread">1<\/span>/,61 );62 expect(text).toContain("New issue app #1: Crash <on> start");63 });6465 test("the navbar feed is private, bounded and leaves unread state unchanged", async () => {66 expect((await inst.app.request("/admin/notifications?format=json")).status).toBe(401);67 const response = await operator.get("/admin/notifications?format=json");68 expect(response.headers.get("cache-control")).toContain("no-store");69 const feed = (await response.json()) as Feed;70 expect(feed.unread).toBe(1);71 expect(feed.notifications).toHaveLength(1);72 expect(feed.notifications[0].text).toContain("Crash <on> start");73 expect(feed.notifications[0].read).toBe(0);74 expect(unread()).toBe(1);75 });7677 test("opening one marks it read and goes to what it is about", async () => {78 const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id;79 const opened = await operator.get(`/admin/notifications/${id}`);80 expect(opened.status).toBe(303);81 expect(opened.headers.get("location")).toBe("/admin/issues/app/1");82 expect(unread()).toBe(0);83 expect(await page()).not.toContain('class="admin-unread"');84 expect((await operator.get("/admin/notifications/999999")).status).toBe(404);85 });8687 test("a public reply raises one", async () => {88 const reader = new Browser(inst.app, "198.51.100.42");89 const thread = await (await reader.request("/app/issues/1")).text();90 tick(4);91 const posted = await reader.request("/app/issues/1", {92 method: "POST",93 body: new URLSearchParams({94 stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "",95 website: "",96 body: "Still broken.",97 }),98 });99 expect(posted.status).toBe(303);100 expect(await page()).toContain("Reply on app #1: Crash <on> start");101 });102103 test("a sync starting to fail raises one; a failure that continues does not", () => {104 clear();105 recordSync(inst.kepr, "app", 1, "bucket unreachable");106 recordSync(inst.kepr, "app", 2, "bucket unreachable");107 expect(unread()).toBe(1);108 recordSync(inst.kepr, "app", 3, null);109 recordSync(inst.kepr, "app", 4, "format changed");110 expect(unread()).toBe(2);111 });112113 test("a backup starting to fail raises one", () => {114 clear();115 116 writeFileSync(`${inst.dataDir}/backups`, "");117 startBackupLoop(inst.kepr, inst.config);118 const row = inst.kepr119 .query<{ text: string; href: string }, []>("SELECT text, href FROM notifications")120 .get();121 expect(row?.text).toStartWith("Backup failed:");122 expect(row?.href).toBe("/admin?section=backups");123 });124125 test("mark all read, and a kind switched off raises nothing", async () => {126 notify(inst.kepr, "issue_filed", "one", "/admin/issues");127 expect((await operator.post("/admin/notifications", { action: "read_all" })).status).toBe(303);128 expect(unread()).toBe(0);129 const off = await operator.post("/admin/notifications", {130 action: "preferences",131 sync_failed: "1",132 backup_failed: "1",133 issue_followup: "1",134 });135 expect(off.status).toBe(303);136 expect(await page()).toMatch(/name="issue_filed" value="1">/);137 notify(inst.kepr, "issue_filed", "two", "/admin/issues");138 expect(unread()).toBe(0);139 notify(inst.kepr, "sync_failed", "three", "/admin/repos");140 expect(unread()).toBe(1);141 });142143 test("keeps the newest 200", () => {144 clear();145 for (let n = 0; n < 205; n++) notify(inst.kepr, "sync_failed", `n${n}`, "/admin/repos");146 const rows = inst.kepr147 .query<{ text: string }, []>("SELECT text FROM notifications ORDER BY id")148 .all();149 expect(rows.length).toBe(200);150 expect(rows[0]?.text).toBe("n5");151 });152 test("the navbar shows only the newest five while counting every unread item", async () => {153 clear();154 for (let i = 0; i < 8; i++) notify(inst.kepr, "sync_failed", `recent ${i}`, "/admin/issues");155 const feed = (await (await operator.get("/admin/notifications?format=json")).json()) as Feed;156 expect(feed.unread).toBe(8);157 expect(feed.notifications.map((n: { text: string }) => n.text)).toEqual([158 "recent 7",159 "recent 6",160 "recent 5",161 "recent 4",162 "recent 3",163 ]);164 expect(unread()).toBe(8);165 });166});