notifications.test.ts

6.3 KB · raw

1// Covers §24's in-app notifications: what raises one, unread state, opening, mark all read,2// switching kinds off, and keeping the newest 200.34import { 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 &lt;on&gt; 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 &lt;on&gt; 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		// A file where the backups folder belongs makes every backup fail.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});