triage.test.ts

8.1 KB · raw

1// Covers §23–§24's triage: tab counts, awaiting reply, repo/type/word filters, hiding one2// reply, and pausing new issues per repo.34import { beforeAll, describe, expect, test } from "bun:test";5import { addEntry, fileIssue, setClosed } from "../src/issues/store.ts";6import { Browser, type Instance, instance } from "./browser.ts";7import { makeRepos } from "./fixture.ts";89describe("issue triage (§23, §24)", () => {10	let inst: Instance;11	let operator: Browser;12	let followUp: number;13	const list = async (query = "") => (await operator.get(`/admin/issues${query}`)).text();14	const text = async (path: string) => (await inst.app.request(path)).text();15	const file = (repo: string, title: string, body: string, type: "bug" | "idea" | "question") =>16		fileIssue(inst.kepr, {17			repo,18			title,19			body,20			type,21			version: "",22			reporterName: "",23			contactEmail: "",24			ipHash: "x",25			filedAt: 1,26		});27	const id = (repo: string, n: number) =>28		(29			inst.kepr30				.query<{ id: number }, [string, number]>(31					"SELECT id FROM issues WHERE repo = ? AND number = ?",32				)33				.get(repo, n) as { id: number }34		).id;3536	beforeAll(async () => {37		const repos = await makeRepos(["app", "tools"]);38		inst = await instance(repos.bucketDir);39		operator = new Browser(inst.app);40		await operator.signIn();41		for (const repo of ["app", "tools"]) {42			await operator.post(`/admin/repos/${repo}`, { public: "1" });43		}44		file("app", "Crash on start", "A segfault in main", "bug");45		file("app", "Dark mode", "Please", "idea");46		file("app", "How to install", "Which command?", "question");47		file("tools", "Tools broken", "Nothing works", "bug");48		addEntry(inst.kepr, id("app", 2), { author: "operator", body: "Planned.", at: 2 });49		addEntry(inst.kepr, id("app", 3), { author: "operator", body: "Use the script.", at: 2 });50		followUp = addEntry(inst.kepr, id("app", 3), {51			author: "reporter",52			body: "Buy cheap pills here",53			at: 3,54		});55		setClosed(inst.kepr, id("tools", 1), 4);56	});5758	test("every tab carries its count", async () => {59		const page = await list();60		expect(page).toMatch(/>\s*open <span class="muted">3<\/span>/);61		expect(page).toMatch(/>\s*awaiting reply <span class="muted">2<\/span>/);62		expect(page).toMatch(/>\s*closed <span class="muted">1<\/span>/);63		expect(page).toMatch(/>\s*hidden <span class="muted">0<\/span>/);64	});6566	test("awaiting reply is the open issues where the reporter spoke last", async () => {67		const page = await list("?filter=awaiting");68		expect(page).toContain('href="/admin/issues/app/1"');69		expect(page).toContain('href="/admin/issues/app/3"');70		expect(page).not.toContain('href="/admin/issues/app/2"');71		const rows = (await list()).split('class="issue-row"');72		const row = (n: number) => rows.find((r) => r.includes(`/admin/issues/app/${n}"`)) ?? "";73		expect(row(1)).toContain("awaiting reply");74		expect(row(2)).not.toContain("awaiting reply");75	});7677	test("narrows by repo, type and words in the title or text, keeping the form filled", async () => {78		const closedTools = await list("?filter=closed&repo=tools");79		expect(closedTools).toContain('href="/admin/issues/tools/1"');80		expect(closedTools).toContain('<option value="tools" selected>');81		expect(await list("?repo=tools")).not.toContain('href="/admin/issues/app/1"');82		const ideas = await list("?type=idea");83		expect(ideas).toContain('href="/admin/issues/app/2"');84		expect(ideas).not.toContain('href="/admin/issues/app/1"');85		const words = await list("?q=SEGFAULT");86		expect(words).toContain('href="/admin/issues/app/1"');87		expect(words).not.toContain('href="/admin/issues/app/2"');88		expect(words).toContain('value="SEGFAULT"');89		expect(words).toMatch(/>\s*open <span class="muted">1<\/span>/);90		// An unknown repo or type narrows nothing rather than failing.91		expect(await list("?repo=nowhere&type=nonsense")).toContain('href="/admin/issues/app/2"');92	});9394	test("hiding one follow-up removes it publicly, keeps it marked in the console", async () => {95		const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/3", fields);96		expect((await act({ action: "hide_entry", entry: String(followUp) })).status).toBe(303);97		expect(await text("/app/issues/3")).not.toContain("cheap pills");98		expect(await text("/app/issues/3")).toContain("Use the script.");99		const console = await (await operator.get("/admin/issues/app/3")).text();100		expect(console).toContain("cheap pills");101		expect(console).toMatch(/hidden[\s\S]*?cheap pills/);102		// The hidden follow-up no longer counts as the reporter speaking last.103		expect(await list("?filter=awaiting")).not.toContain('href="/admin/issues/app/3"');104		expect((await act({ action: "unhide_entry", entry: String(followUp) })).status).toBe(303);105		expect(await text("/app/issues/3")).toContain("cheap pills");106		const other = operator.post("/admin/issues/app/1", {107			action: "hide_entry",108			entry: String(followUp),109		});110		expect((await other).status).toBe(404);111		expect((await act({ action: "hide_entry", entry: "x" })).status).toBe(404);112	});113114	test("pausing new issues stops filing but keeps the list and follow-ups", async () => {115		const pause = await operator.post("/admin/repos/app", {116			public: "1",117			issues_paused: "1",118		});119		expect(pause.status).toBe(303);120		expect(await (await operator.get("/admin/repos?repo=app")).text()).toMatch(121			/name="issues_paused" value="1" checked/,122		);123		const form = await inst.app.request("/app/issues/new");124		expect(form.status).toBe(200);125		const formText = await form.text();126		expect(formText).toContain("app is not taking new issues right now.");127		expect(formText).not.toContain('name="title"');128		const filed = await inst.app.request("/app/issues/new", {129			method: "POST",130			body: new URLSearchParams({ title: "t", body: "b", type: "bug" }),131		});132		expect(filed.status).toBe(403);133		expect(await text("/app/issues")).not.toContain("report an issue →");134		expect(await text("/app/issues")).toContain('href="/app/issues/1"');135		expect(await text("/app/issues/3")).toContain('name="body"');136		await operator.post("/admin/repos/app", { public: "1" });137		expect(await text("/app/issues/new")).toContain('name="title"');138	});139140	test("ticked issues are closed, reopened, hidden and unhidden together", async () => {141		const ticked = [id("app", 1), id("app", 2)];142		const bulk = (action: string, issues = ticked) => {143			const form = new FormData();144			form.set("action", action);145			form.set("filter", "closed");146			for (const issue of issues) form.append("issue", String(issue));147			return operator.post("/admin/issues", form);148		};149		const status = () =>150			inst.kepr151				.query("SELECT status, hidden FROM issues WHERE id IN (?, ?) ORDER BY id")152				.all(...ticked);153		expect(await list()).toContain(`name="issue" value="${ticked[0]}"`);154		const closed = await bulk("close");155		expect(closed.status).toBe(303);156		expect(closed.headers.get("location")).toBe("/admin/issues?filter=closed");157		expect(status()).toEqual([158			{ status: "closed", hidden: 0 },159			{ status: "closed", hidden: 0 },160		]);161		await bulk("hide");162		expect(status().map((row) => (row as { hidden: number }).hidden)).toEqual([1, 1]);163		await bulk("unhide");164		await bulk("reopen");165		expect(status()).toEqual([166			{ status: "open", hidden: 0 },167			{ status: "open", hidden: 0 },168		]);169		expect((await bulk("close", [999_999])).status).toBe(404);170		expect((await bulk("explode")).status).toBe(404);171	});172173	test("blocking a reporter lists the block under the tabs until it is lifted", async () => {174		expect(await list()).not.toContain("blocked reporters");175		const blocked = await operator.post("/admin/issues/app/2", { action: "block" });176		expect(blocked.status).toBe(303);177		const issue = await (await operator.get("/admin/issues/app/2")).text();178		expect(issue).toContain("reporter blocked");179		const page = await list();180		expect(page).toContain("blocked reporters");181		expect(page).toContain('href="/admin/issues/app/2"');182		const block = /name="block" value="(\d+)"/.exec(page)?.[1] ?? "";183		expect(block).not.toBe("");184		expect((await operator.post("/admin/issues", { action: "unblock", block: "999" })).status).toBe(185			404,186		);187		expect((await operator.post("/admin/issues", { action: "unblock", block })).status).toBe(303);188		expect(await list()).not.toContain("blocked reporters");189	});190});