From e4779c76e04e7998e657f8c5162d831c66783083 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 14:09:36 +0200 Subject: [PATCH] test(intake): picker integration tests + final cap wiring Phase 9c. 12 tests covering: - picker page (multi-project list, single-project redirect, zero-projects fallback) - wizard URL (project-on-client 200, cross-client 404, show_in_intake=false 404) - submission flow (card lands on chosen project's inbox, cross-client refused, hostile body fields ignored, per-(link, project) cap isolates one project from another) - Phase 8 backwards compat (per-project link redirects to the wizard; the existing Phase 8 submission route still accepts Phase 8 links) Three bridges from the plan: 1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the filter must use that form. Passing 'YYYY-MM-DD' silently matches zero rows, even though the create succeeds. 2. PocketBase's getFirstListItem returns 404 for a row just written by the same admin client; getList works. The helper uses getList with perPage=1. 3. PocketBase's read-after-write latency is significant. isLinkProjectUsable retries with exponential backoff for up to ~2.5s before giving up; bumpDailyCapForProject's upsert pattern uses a fallback read+update path if the create hits the composite unique index from a peer's concurrent write. --- src/lib/intake-links/index.ts | 144 ++++--- tests/integration/intake-picker.test.ts | 524 ++++++++++++++++++++++++ 2 files changed, 611 insertions(+), 57 deletions(-) create mode 100644 tests/integration/intake-picker.test.ts diff --git a/src/lib/intake-links/index.ts b/src/lib/intake-links/index.ts index 3b2341e..60d2b41 100644 --- a/src/lib/intake-links/index.ts +++ b/src/lib/intake-links/index.ts @@ -118,6 +118,10 @@ export async function bumpSubmissionCount(link: IntakeLinkRecord): Promise * Reads today's counter from the new `intake_link_submissions` collection. * On any DB read error, returns false — Phase 8 used the same conservative * default (a flaky read fails closed, never open). + * + * Reads a few times before giving up because PocketBase's read-after-write + * latency can briefly hide a row a peer just created — see the 9a + * integration test for the behaviour this guards against. */ export async function isLinkProjectUsable( link: IntakeLinkRecord, @@ -130,19 +134,90 @@ export async function isLinkProjectUsable( if (computeDateForCounter(now) > expiry) return false; } const cap = defaultCapFor(link.max_submissions_per_day); - const today = computeDateForCounter(now); const pb = await getAdminPb(); - try { - const row = await pb + // PocketBase's `date` field stores the value as a full ISO timestamp + // (e.g. "2026-09-21 00:00:00.000Z"); equality filters need the same + // form, or they never match. The writer (`bumpDailyCapForProject`) + // passes just `YYYY-MM-DD` and PocketBase coerces it on insert; the + // reader must query with the same expanded form. + const today = `${computeDateForCounter(now)} 00:00:00.000Z`; + const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`; + let count = 0; + // PocketBase's read-after-write latency is significant. Use `getList` + // because `getFirstListItem` doesn't reliably see a row just written by + // the same admin client. Retry with exponential backoff for up to ~2.5s + // before giving up. + for (let attempt = 0; attempt < 6; attempt++) { + const rows = await pb .collection("intake_link_submissions") - .getFirstListItem( - `link = "${link.id}" && project = "${projectId}" && date = "${today}"` - ); - return !isDailyCapReached(row.count, cap); + .getList(1, 1, { filter }); + if (rows.items.length > 0) { + count = rows.items[0].count; + break; + } + if (attempt < 5) { + await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt))); + } + } + return !isDailyCapReached(count, cap); +} + +/** + * Phase 9: Increment today's counter for the (link, project) pair. + * + * Uses the unique `(link, project, date)` index: a fresh row is created on + * first submission of the day, subsequent submissions update the count. + * + * Uses `getList` (not `getFirstListItem`) because PocketBase's + * `getFirstListItem` doesn't reliably see a row just written by the + * same admin client. + * + * The date field is stored as a full ISO timestamp by PocketBase; the + * filter uses the same expanded form to match. + */ +export async function bumpDailyCapForProject( + link: IntakeLinkRecord, + projectId: string +): Promise { + const pb = await getAdminPb(); + const today = `${computeDateForCounter()} 00:00:00.000Z`; + const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`; + + async function tryIncrement(): Promise { + const rows = await pb + .collection("intake_link_submissions") + .getList(1, 1, { filter }); + if (rows.items.length === 0) return false; + await pb.collection("intake_link_submissions").update(rows.items[0].id, { + count: nextCount(rows.items[0].count), + }); + return true; + } + + if (await tryIncrement()) return; + + // No row yet — try to create. If a peer (or our own prior attempt) + // raced, the composite unique index rejects the duplicate — fall through + // to the read-then-update path. + try { + await pb.collection("intake_link_submissions").create({ + link: link.id, + project: projectId, + date: today, + count: 1, + }); + return; } catch { - // No row for today → 0 submissions → below the cap (unless cap=0). - return !isDailyCapReached(0, cap); + // Race: the unique index rejected our duplicate create. The peer's + // row is now visible. Retry the read+update. + for (let attempt = 0; attempt < 6; attempt++) { + if (await tryIncrement()) return; + if (attempt < 5) { + await new Promise((r) => setTimeout(r, 50 * Math.pow(2, attempt))); + } + } + throw new Error("bumpDailyCapForProject: row visibility timeout after 6 retries"); } } @@ -151,52 +226,7 @@ export async function isLinkProjectUsable( * * Uses the unique `(link, project, date)` index: a fresh row is created on * first submission of the day, subsequent submissions update the count. + * + * (The duplicate stub that previously lived here was removed — the + * single, retry-aware version above is the canonical implementation.) */ -export async function bumpDailyCapForProject( - link: IntakeLinkRecord, - projectId: string -): Promise { - const pb = await getAdminPb(); - const today = computeDateForCounter(); - const filter = `link = "${link.id}" && project = "${projectId}" && date = "${today}"`; - - // Read-after-write consistency: PocketBase's `getFirstListItem` may - // not see a row we just created in the same logical burst, even - // though the unique index will reject a duplicate create. So the - // upsert pattern is: read first; if missing, try create; if create - // races a peer's write and hits the unique constraint, fall back to - // the read again and increment. - const tryIncrement = async (): Promise => { - const existing = await pb - .collection("intake_link_submissions") - .getFirstListItem(filter); - await pb.collection("intake_link_submissions").update(existing.id, { - count: nextCount(existing.count), - }); - }; - - try { - await tryIncrement(); - return; - } catch { - // No row yet — try to create it. - } - - try { - await pb.collection("intake_link_submissions").create({ - link: link.id, - project: projectId, - date: today, - count: 1, - }); - } catch (createErr) { - // Race: a peer (or our own prior attempt) created the row between - // our read and our create. The unique index will reject the - // duplicate; fall through to the read-then-update path. - try { - await tryIncrement(); - } catch { - throw createErr; - } - } -} diff --git a/tests/integration/intake-picker.test.ts b/tests/integration/intake-picker.test.ts new file mode 100644 index 0000000..9bebe03 --- /dev/null +++ b/tests/integration/intake-picker.test.ts @@ -0,0 +1,524 @@ +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); + } + }); +});