follow.test.ts

7.8 KB · raw

1// Covers §23's email updates: confirming before anything is sent, who is told what, stopping by2// link or one-click, and nothing offered without SMTP.34import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5import { Browser, type Instance, instance } from "./browser.ts";6import { makeRepos } from "./fixture.ts";78let clock = Date.parse("2026-10-08T12:00:00Z");9const tick = (seconds: number) => {10	clock += seconds * 1000;11	setSystemTime(new Date(clock));12};13type Queued = { recipient: string; subject: string; body: string; unsubscribe: string };1415describe("following an issue by email (§23)", () => {16	let inst: Instance;17	let operator: Browser;18	const queued = (to: string) =>19		inst.kepr20			.query<Queued, [string]>(21				"SELECT recipient, subject, body, unsubscribe FROM email_queue WHERE recipient = ? ORDER BY id",22			)23			.all(to);24	const stamped = async (who: Browser, path: string) => {25		const page = await (await who.request(path)).text();26		tick(4);27		return /name="stamp" value="([^"]+)"/.exec(page)?.[1] ?? "";28	};29	const send = async (who: Browser, path: string, fields: Record<string, string>, from = path) =>30		who.request(path, {31			method: "POST",32			body: new URLSearchParams({ website: "", stamp: await stamped(who, from), ...fields }),33		});34	const link = (body: string, action: string) =>35		new RegExp(`https://kepr\\.example(/app/issues/1/${action}\\?f=\\d+&t=[0-9a-f]+)`).exec(36			body,37		)?.[1] ?? "";3839	beforeAll(async () => {40		setSystemTime(new Date(clock));41		inst = await instance((await makeRepos(["app"])).bucketDir);42		operator = new Browser(inst.app);43		await operator.signIn();44		await operator.post("/admin/repos/app", { public: "1" });45		const reporter = new Browser(inst.app, "198.51.100.60");46		await send(reporter, "/app/issues/new", { title: "Crash", body: "It broke.", type: "bug" });47	});48	afterAll(() => setSystemTime());4950	test("without SMTP, nothing offers email and following is not there", async () => {51		const thread = await (await inst.app.request("/app/issues/1")).text();52		expect(thread).not.toContain('id="follow"');53		expect(thread).not.toContain('name="email"');54		const reader = new Browser(inst.app, "198.51.100.61");55		const response = await send(56			reader,57			"/app/issues/1/follow",58			{ email: "[email protected]" },59			"/app/issues/1",60		);61		expect(response.status).toBe(404);62	});6364	test("an address gets one confirmation, and nothing else until it confirms", async () => {65		inst.config.smtp = {66			host: "smtp.example",67			port: 587,68			security: "starttls",69			username: "",70			password: "",71			from: "[email protected]",72			to: "[email protected]",73		};74		const reader = new Browser(inst.app, "198.51.100.61");75		const thread = await (await reader.request("/app/issues/1")).text();76		expect(thread).toContain('id="follow"');77		const follow = () =>78			send(reader, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");79		const response = await follow();80		expect(response.status).toBe(303);81		expect(response.headers.get("location")).toBe("/app/issues/1?notice=check");82		expect((await follow()).headers.get("location")).toBe("/app/issues/1?notice=check");83		const [confirm, ...more] = queued("[email protected]");84		expect(more).toEqual([]);85		expect(confirm?.subject).toBe("Confirm email updates for app #1");86		expect(confirm?.unsubscribe).toBe("");87		const other = new Browser(inst.app, "198.51.100.62");88		await send(other, "/app/issues/1", { body: "Me too." });89		expect(queued("[email protected]")).toHaveLength(1);90		const wrong = await inst.app.request(91			link(confirm?.body ?? "", "follow").replace(/t=\w+/, "t=00"),92		);93		expect(wrong.headers.get("location")).toBe("/app/issues/1?notice=expired");94		const opened = await inst.app.request(link(confirm?.body ?? "", "follow"));95		expect(opened.headers.get("location")).toBe("/app/issues/1?notice=following");96		expect(await (await inst.app.request("/app/issues/1?notice=following")).text()).toContain(97			"You&#39;ll get an email for each reply",98		);99	});100101	test("followers hear of each reply but their own, with a stop link and header", async () => {102		const other = new Browser(inst.app, "198.51.100.62");103		await send(other, "/app/issues/1", { body: "Still <broken>.", name: "Bo" });104		const [, reply] = queued("[email protected]");105		expect(reply?.subject).toBe("Re: Crash (app #1)");106		expect(reply?.body).toContain("Bo replied:\n\nStill <broken>.");107		expect(reply?.body).toContain("https://kepr.example/app/issues/1#entry-");108		expect(reply?.unsubscribe).toMatch(/^\/app\/issues\/1\/unfollow\?f=\d+&t=[0-9a-f]+$/);109		const ada = new Browser(inst.app, "198.51.100.61");110		await send(ada, "/app/issues/1", { body: "Thanks.", email: "[email protected]" });111		expect(queued("[email protected]")).toHaveLength(2);112	});113114	test("the operator's reply and close reach followers too", async () => {115		await operator.get("/admin/issues/app/1");116		await operator.post("/admin/issues/app/1", { action: "reply", body: "Looking into it." });117		await operator.post("/admin/issues/app/1", { action: "close", fixed_in: "" });118		const [, , reply, closed] = queued("[email protected]");119		expect(reply?.body).toContain("The operator replied:\n\nLooking into it.");120		expect(closed?.body).toContain('"Crash" was closed.');121	});122123	test("a stop link asks first, and an inbox's one-click stops at once", async () => {124		const stop = queued("[email protected]")[1]?.unsubscribe ?? "";125		const page = await (await inst.app.request(stop)).text();126		expect(page).toContain("stop emails");127		expect(queued("[email protected]")).toHaveLength(4);128		const oneClick = await inst.app.request(stop, {129			method: "POST",130			body: new URLSearchParams({ "List-Unsubscribe": "One-Click" }),131		});132		expect(oneClick.status).toBe(200);133		await operator.post("/admin/issues/app/1", { action: "reopen" });134		expect(queued("[email protected]")).toHaveLength(4);135		const again = await inst.app.request(stop, { method: "POST" });136		expect(again.headers.get("location")).toBe("/app/issues/1?notice=expired");137	});138139	test("filing can follow the issue at the contact address", async () => {140		const reporter = new Browser(inst.app, "198.51.100.63");141		const filed = await send(reporter, "/app/issues/new", {142			title: "Dark mode",143			body: "Please.",144			type: "idea",145			email: "[email protected]",146			follow: "1",147		});148		expect(filed.headers.get("location")).toBe("/app/issues/2?notice=check");149		expect(queued("[email protected]")[0]?.subject).toBe("Confirm email updates for app #2");150		const bad = await send(reporter, "/app/issues/new", {151			title: "x",152			body: "y",153			type: "bug",154			email: "not an address",155		});156		expect(bad.status).toBe(400);157	});158159	test("a spent link cannot stop a follower that reuses its id", async () => {160		// A follower's id is a rowid, so SQLite hands a stopped follower's id to the next one.161		const gone = new Browser(inst.app, "198.51.100.70");162		await send(gone, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");163		const stale = link(queued("[email protected]")[0]?.body ?? "", "follow").replace(164			"/follow?",165			"/unfollow?",166		);167		const freed = Number(/f=(\d+)/.exec(stale)?.[1]);168		await inst.app.request(stale, { method: "POST" });169		const next = new Browser(inst.app, "198.51.100.71");170		await send(next, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");171		const reused = inst.kepr172			.query<{ id: number }, [string]>("SELECT id FROM issue_followers WHERE email = ?")173			.get("[email protected]")?.id;174		expect(reused).toBe(freed);175		await inst.app.request(link(queued("[email protected]")[0]?.body ?? "", "follow"));176		const replay = await inst.app.request(stale, { method: "POST" });177		expect(replay.headers.get("location")).toBe("/app/issues/1?notice=expired");178		const other = new Browser(inst.app, "198.51.100.72");179		await send(other, "/app/issues/1", { body: "One more." });180		expect(queued("[email protected]")).toHaveLength(2);181	});182});