notifications.test.ts

5.1 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";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 &lt;on&gt; 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 &lt;on&gt; 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		// A file where the backups folder belongs makes every backup fail.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});