import { describe, it, expect, beforeAll, afterAll } from "vitest"; import PocketBase from "pocketbase"; import { randomBytes } from "node:crypto"; import { APP_URL, DEMO, adminPb, requireServices } from "./helpers"; let admin: PocketBase; let projectId: string; let inboxId: string; let secondProjectId: string; let secondInboxId: string; let clientId: string; const createdLinkIds: string[] = []; const createdCardIds: string[] = []; const createdSubmissionIds: string[] = []; const createdClientIds: string[] = []; const createdProjectIds: string[] = []; const createdColumnIds: string[] = []; function makeToken(): string { return randomBytes(24).toString("base64url"); } async function newPickerLink(opts: { showProject1?: boolean; showProject2?: boolean; cap?: number; } = {}) { const token = makeToken(); const link = await admin.collection("intake_links").create({ project: "", client: clientId, label: "picker test", token, active: true, expires_at: "", max_submissions_per_day: opts.cap ?? 100, submission_count: 0, submission_count_today: 0, submission_count_today_date: "", last_submission_at: "", notes: "", }); createdLinkIds.push(link.id); // Set the per-project opt-in flag on whichever projects the test // wants visible. await admin.collection("projects").update(projectId, { show_in_intake: opts.showProject1 ?? true, }); await admin.collection("projects").update(secondProjectId, { show_in_intake: opts.showProject2 ?? false, }); return link; } const brief = { title: "Add a public-facing intake", summary: "Anonymous clients should be able to file requests.", subtasks: ["Generate a token", "Render the wizard"], openQuestions: ["Which URL prefix?"], area: "", degraded: false, }; beforeAll(async () => { await requireServices(); admin = await adminPb(); const project = await admin .collection("projects") .getFirstListItem(`slug = "${DEMO.projectSlug}"`); projectId = project.id; clientId = project.client; inboxId = ( await admin .collection("columns") .getFirstListItem(`project = "${projectId}" && is_inbox = true`) ).id; // Second project under the same client for picker isolation tests. const second = await admin.collection("projects").create({ client: clientId, name: "Phase9 picker second", slug: `phase9-picker-second-${Date.now()}`, status: "active", }); secondProjectId = second.id; createdProjectIds.push(secondProjectId); // The schema doesn't auto-create an inbox column for new projects; the // test owns the fixture it relies on. const secondColumn = await admin.collection("columns").create({ project: secondProjectId, name: "Inbox", position: 1000, is_inbox: true, }); createdColumnIds.push(secondColumn.id); secondInboxId = secondColumn.id; }); afterAll(async () => { for (const id of createdCardIds) { await admin.collection("cards").delete(id).catch(() => {}); } for (const id of createdSubmissionIds) { await admin .collection("intake_link_submissions") .delete(id) .catch(() => {}); } for (const id of createdLinkIds) { await admin.collection("intake_links").delete(id).catch(() => {}); } for (const id of createdColumnIds) { await admin.collection("columns").delete(id).catch(() => {}); } for (const id of createdProjectIds) { await admin.collection("projects").delete(id).catch(() => {}); } for (const id of createdClientIds) { await admin.collection("clients").delete(id).catch(() => {}); } // Reset the demo project's show_in_intake so other tests are unaffected. await admin .collection("projects") .update(projectId, { show_in_intake: false }) .catch(() => {}); await admin .collection("projects") .update(secondProjectId, { show_in_intake: false }) .catch(() => {}); }); describe("the picker page renders for a Phase 9 link", () => { it("renders the project list when both projects are opted in", async () => { const link = await newPickerLink({ showProject1: true, showProject2: true, }); const response = await fetch(`${APP_URL}/intake/${link.token}`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain("Tell us which project"); expect(html).toContain("Phase9 picker second"); // WFDash project name should also appear. expect(html).toMatch(/WFDash/); }); it("redirects to the wizard URL when only one project is opted in", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); const response = await fetch(`${APP_URL}/intake/${link.token}`, { redirect: "manual", }); expect([301, 302, 303, 307, 308]).toContain(response.status); const location = response.headers.get("location"); expect(location).toBe(`/intake/${link.token}/p/${projectId}`); }); it("renders the 'nothing here yet' page when zero projects are opted in", async () => { const link = await newPickerLink({ showProject1: false, showProject2: false, }); const response = await fetch(`${APP_URL}/intake/${link.token}`); expect(response.status).toBe(200); const html = await response.text(); expect(html).toContain("Nothing here yet"); }); }); describe("the wizard URL on the picker path", () => { it("renders the wizard when the projectId belongs to the link's client", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); const response = await fetch( `${APP_URL}/intake/${link.token}/p/${projectId}` ); expect(response.status).toBe(200); const html = await response.text(); // The wizard renders a "Tell what's not working" start // button. The apostrophe is HTML-encoded in the rendered HTML. expect(html).toContain("what's not working"); expect(html).toContain("Request to"); }); it("404s when the projectId belongs to a different client", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); // Make a project under a different client. const otherClient = await admin.collection("clients").create({ name: `picker test other ${Date.now()}`, slug: `picker-test-other-${Date.now()}`, active: true, }); createdClientIds.push(otherClient.id); const otherProject = await admin.collection("projects").create({ client: otherClient.id, name: "Other client project", slug: `picker-test-other-p-${Date.now()}`, status: "active", show_in_intake: true, }); createdProjectIds.push(otherProject.id); const otherInbox = await admin.collection("columns").create({ project: otherProject.id, name: "Inbox", position: 1, is_inbox: true, }); createdColumnIds.push(otherInbox.id); const response = await fetch( `${APP_URL}/intake/${link.token}/p/${otherProject.id}` ); expect(response.status).toBe(404); }); it("404s when the projectId is opted out (show_in_intake = false)", async () => { const link = await newPickerLink({ showProject1: false, showProject2: false, }); const response = await fetch( `${APP_URL}/intake/${link.token}/p/${projectId}` ); expect(response.status).toBe(404); }); }); describe("the picker submission flow", () => { it("creates a card on the chosen project's inbox", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: projectId, brief, transcript: [{ question: "What?", answer: "Picked this project." }], sizeBand: "medium", }), }); expect(response.status).toBe(201); const { card } = await response.json(); createdCardIds.push(card.id); const created = await admin.collection("cards").getOne(card.id); expect(created.column).toBe(inboxId); expect(created.created_by_role).toBe("anonymous"); expect(created.created_by).toBe(""); expect(created.title).toBe(brief.title); // Track the per-(link, project) counter for cleanup. const today = new Date().toISOString().slice(0, 10); try { const row = await admin .collection("intake_link_submissions") .getFirstListItem( `link = "${link.id}" && project = "${projectId}" && date = "${today}"` ); createdSubmissionIds.push(row.id); } catch { // ignore } }); it("refuses a projectId that doesn't belong to the link's client", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); const otherClient = await admin.collection("clients").create({ name: `picker test other ${Date.now()}`, slug: `picker-test-other-${Date.now()}`, active: true, }); createdClientIds.push(otherClient.id); const otherProject = await admin.collection("projects").create({ client: otherClient.id, name: "Other client project", slug: `picker-test-other-p-${Date.now()}`, status: "active", show_in_intake: true, }); createdProjectIds.push(otherProject.id); const otherInbox = await admin.collection("columns").create({ project: otherProject.id, name: "Inbox", position: 1, is_inbox: true, }); createdColumnIds.push(otherInbox.id); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: otherProject.id, brief, transcript: [], }), }); expect(response.status).toBe(404); }); it("ignores a hostile body that sets priority, accepted, or column", async () => { const link = await newPickerLink({ showProject1: true, showProject2: false, }); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: projectId, brief, transcript: [], sizeBand: "large", priority: "critical", accepted: true, column: secondInboxId, created_by_role: "owner", created_by: "fake-user-id", }), }); expect(response.status).toBe(201); const { card } = await response.json(); createdCardIds.push(card.id); const created = await admin.collection("cards").getOne(card.id); expect(created.column).toBe(inboxId); // not secondInboxId expect(created.priority).toBe("medium"); expect(created.accepted).toBe(false); expect(created.created_by_role).toBe("anonymous"); expect(created.created_by).toBe(""); const today = new Date().toISOString().slice(0, 10); try { const row = await admin .collection("intake_link_submissions") .getFirstListItem( `link = "${link.id}" && project = "${projectId}" && date = "${today}"` ); createdSubmissionIds.push(row.id); } catch { // ignore } }); it("isolates the daily cap per project under the same link", async () => { // The cap is bumped up to avoid PocketBase's read-after-write latency // making this test flaky in CI. The isolation property is what matters: // project1's submissions do not consume project2's budget. const link = await newPickerLink({ showProject1: true, showProject2: true, cap: 5, }); // 5 submissions to project1 — all land. for (let i = 0; i < 5; i++) { const r = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: projectId, brief, transcript: [], }), }); expect(r.status).toBe(201); const c = (await r.json()).card; createdCardIds.push(c.id); // Brief pause so PocketBase's read-after-write window is comfortably past // by the time the next request arrives. await new Promise((r) => setTimeout(r, 50)); } // 6th submission to project1 — hits the cap. const r2 = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: projectId, brief, transcript: [], }), }); expect(r2.status).toBe(429); // First submission to project2 — still works (per-project cap). const r3 = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, projectId: secondProjectId, brief, transcript: [], }), }); expect(r3.status).toBe(201); const c3 = (await r3.json()).card; createdCardIds.push(c3.id); // Cleanup counters. const today = new Date().toISOString().slice(0, 10); for (const pid of [projectId, secondProjectId]) { try { const row = await admin .collection("intake_link_submissions") .getFirstListItem( `link = "${link.id}" && project = "${pid}" && date = "${today}"` ); createdSubmissionIds.push(row.id); } catch { // ignore } } }); }); describe("Phase 8 backwards compat", () => { it("a Phase 8 per-project link still resolves to the wizard", async () => { const token = makeToken(); const link = await admin.collection("intake_links").create({ project: projectId, client: "", label: "phase8 backwards", token, active: true, expires_at: "", max_submissions_per_day: 100, submission_count: 0, submission_count_today: 0, submission_count_today_date: "", last_submission_at: "", notes: "", }); createdLinkIds.push(link.id); const response = await fetch(`${APP_URL}/intake/${link.token}`, { redirect: "manual", }); expect([301, 302, 303, 307, 308]).toContain(response.status); expect(response.headers.get("location")).toBe( `/intake/${link.token}/p/${projectId}` ); const wizard = await fetch( `${APP_URL}/intake/${link.token}/p/${projectId}` ); expect(wizard.status).toBe(200); }); it("the existing Phase 8 submission route still accepts Phase 8 links", async () => { const token = makeToken(); const link = await admin.collection("intake_links").create({ project: projectId, client: "", label: "phase8 submit", token, active: true, expires_at: "", max_submissions_per_day: 100, submission_count: 0, submission_count_today: 0, submission_count_today_date: "", last_submission_at: "", notes: "", }); createdLinkIds.push(link.id); const response = await fetch(`${APP_URL}/api/public/intake`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ token: link.token, brief, transcript: [], }), }); expect([201, 400]).toContain(response.status); if (response.status === 201) { const { card } = await response.json(); createdCardIds.push(card.id); } }); });