notifications.test.ts

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