// Covers §24's in-app notifications: what raises one, unread state, opening, mark all read, // switching kinds off, and keeping the newest 200. import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { writeFileSync } from "node:fs"; import { startBackupLoop } from "../src/server/backup.ts"; import { notify, recordSync } from "../src/server/db.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; type Feed = { unread: number; notifications: { id: number; text: string; read: number; when: string }[]; }; let clock = Date.parse("2026-09-29T12:00:00Z"); const tick = (seconds: number) => { clock += seconds * 1000; setSystemTime(new Date(clock)); }; describe("operator notifications (§24)", () => { let inst: Instance; let operator: Browser; const unread = () => inst.kepr .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0") .get()?.n; const page = async () => (await operator.get("/admin/notifications")).text(); const clear = () => inst.kepr.run("DELETE FROM notifications"); beforeAll(async () => { setSystemTime(new Date(clock)); const repos = await makeRepos(["app"]); inst = await instance(repos.bucketDir); operator = new Browser(inst.app); await operator.signIn(); await operator.post("/admin/repos/app", { public: "1" }); clear(); const reporter = new Browser(inst.app, "198.51.100.41"); const form = await (await reporter.request("/app/issues/new")).text(); tick(4); await reporter.request("/app/issues/new", { method: "POST", body: new URLSearchParams({ stamp: /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "", website: "", title: "Crash start", body: "It crashed.", type: "bug", }), }); }); afterAll(() => setSystemTime()); test("a filed issue raises one, unread, shown in the sidebar and escaped", async () => { expect(unread()).toBe(1); const text = await page(); expect(text).toMatch( /href="\/admin\/notifications"[^>]*>notifications 1<\/span>/, ); expect(text).toContain("New issue app #1: Crash <on> start"); }); test("the navbar feed is private, bounded and leaves unread state unchanged", async () => { expect((await inst.app.request("/admin/notifications?format=json")).status).toBe(401); const response = await operator.get("/admin/notifications?format=json"); expect(response.headers.get("cache-control")).toContain("no-store"); const feed = (await response.json()) as Feed; expect(feed.unread).toBe(1); expect(feed.notifications).toHaveLength(1); expect(feed.notifications[0].text).toContain("Crash start"); expect(feed.notifications[0].read).toBe(0); expect(unread()).toBe(1); }); test("opening one marks it read and goes to what it is about", async () => { const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id; const opened = await operator.get(`/admin/notifications/${id}`); expect(opened.status).toBe(303); expect(opened.headers.get("location")).toBe("/admin/issues/app/1"); expect(unread()).toBe(0); expect(await page()).not.toContain('class="admin-unread"'); expect((await operator.get("/admin/notifications/999999")).status).toBe(404); }); test("a public reply raises one", async () => { const reader = new Browser(inst.app, "198.51.100.42"); const thread = await (await reader.request("/app/issues/1")).text(); tick(4); const posted = await reader.request("/app/issues/1", { method: "POST", body: new URLSearchParams({ stamp: /name="stamp" value="([^"]+)"/.exec(thread)?.[1] ?? "", website: "", body: "Still broken.", }), }); expect(posted.status).toBe(303); expect(await page()).toContain("Reply on app #1: Crash <on> start"); }); test("a sync starting to fail raises one; a failure that continues does not", () => { clear(); recordSync(inst.kepr, "app", 1, "bucket unreachable"); recordSync(inst.kepr, "app", 2, "bucket unreachable"); expect(unread()).toBe(1); recordSync(inst.kepr, "app", 3, null); recordSync(inst.kepr, "app", 4, "format changed"); expect(unread()).toBe(2); }); test("a backup starting to fail raises one", () => { clear(); // A file where the backups folder belongs makes every backup fail. writeFileSync(`${inst.dataDir}/backups`, ""); startBackupLoop(inst.kepr, inst.config); const row = inst.kepr .query<{ text: string; href: string }, []>("SELECT text, href FROM notifications") .get(); expect(row?.text).toStartWith("Backup failed:"); expect(row?.href).toBe("/admin?section=backups"); }); test("mark all read, and a kind switched off raises nothing", async () => { notify(inst.kepr, "issue_filed", "one", "/admin/issues"); expect((await operator.post("/admin/notifications", { action: "read_all" })).status).toBe(303); expect(unread()).toBe(0); const off = await operator.post("/admin/notifications", { action: "preferences", sync_failed: "1", backup_failed: "1", issue_followup: "1", }); expect(off.status).toBe(303); expect(await page()).toMatch(/name="issue_filed" value="1">/); notify(inst.kepr, "issue_filed", "two", "/admin/issues"); expect(unread()).toBe(0); notify(inst.kepr, "sync_failed", "three", "/admin/repos"); expect(unread()).toBe(1); }); test("keeps the newest 200", () => { clear(); for (let n = 0; n < 205; n++) notify(inst.kepr, "sync_failed", `n${n}`, "/admin/repos"); const rows = inst.kepr .query<{ text: string }, []>("SELECT text FROM notifications ORDER BY id") .all(); expect(rows.length).toBe(200); expect(rows[0]?.text).toBe("n5"); }); test("the navbar shows only the newest five while counting every unread item", async () => { clear(); for (let i = 0; i < 8; i++) notify(inst.kepr, "sync_failed", `recent ${i}`, "/admin/issues"); const feed = (await (await operator.get("/admin/notifications?format=json")).json()) as Feed; expect(feed.unread).toBe(8); expect(feed.notifications.map((n: { text: string }) => n.text)).toEqual([ "recent 7", "recent 6", "recent 5", "recent 4", "recent 3", ]); expect(unread()).toBe(8); }); });