From 882e9e20a71b1c383ce9022ac6368ac2ec9a76b4 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 21 Sep 2026 13:12:44 +0200 Subject: [PATCH] docs(phase-9c): plan for picker integration tests + verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tasks. Same format as 8c. Task 1 — tests/integration/intake-picker.test.ts mirrors the Phase 8 shape. Five describe groups: - picker page (multi-project list, single-project redirect, zero-projects fallback) - wizard URL (cross-client projectId 404, show_in_intake=false 404) - submission flow (card lands on chosen project's inbox, cross-client refused, hostile body cannot override server-set fields, per-project cap isolates) - Phase 8 backwards compat (per-project link still resolves through the picker to the wizard URL) Task 2 — full verification: unit, integration, build, colour check, manual walkthrough, Phase 8 backwards-compat walkthrough. --- .../2026-09-21-phase-9c-tests-verification.md | 678 ++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md diff --git a/docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md b/docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md new file mode 100644 index 0000000..60075aa --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md @@ -0,0 +1,678 @@ +# Phase 9c — Integration Tests + Full Verification + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lock down the Phase 9 picker path with integration tests that mirror the shape Phase 8 Task 17 set, then run the full verification. The Phase 8 test set stays green throughout; the new tests prove the picker-specific invariants (cap isolation, project mismatch, single-project redirect, etc.) and the Phase 8 backwards compat claim. + +**Architecture:** One new integration test file mirroring `intake-submit-public.test.ts`. The Phase 8 helper layer (`tests/integration/helpers.ts`) is unchanged. The dev PocketBase is the only system under test. + +**Tech Stack:** Next.js dev server · vitest · PocketBase 0.30 + +**Spec:** `docs/superpowers/specs/2026-09-21-phase-9-per-client-intake-links-and-project-picker-design.md` §11 (acceptance criteria) + +**Prerequisite:** Phases 9a + 9b complete — `intake_links.client`, `projects.show_in_intake`, `intake_link_submissions` exist; picker page and wizard page render; route + action accept `projectId`. + +--- + +## File structure + +``` +tests/ +└── integration/ + └── intake-picker.test.ts # NEW — picker path tests +``` + +No source changes in this plan. 9c is verification work. + +--- + +## Task 1: The picker integration tests + +**Files:** +- Create: `tests/integration/intake-picker.test.ts` + +Mirrors `intake-submit-public.test.ts` exactly. Same imports, same fixtures (`admin`, `projectId`, `inboxId`), same brief, same cleanup pattern. Adds one second project under the same client and walks through the picker scenarios. + +- [ ] **Step 1: Write the tests** + +```ts +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[] = []; + +function makeToken(): string { + return randomBytes(24).toString("base64url"); +} + +async function newPickerLink(opts: { + showProject1?: boolean; + showProject2?: boolean; +} = {}) { + 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: 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. Both default off (the spec default); we flip them here so the + // test controls exactly what the picker sees. + if (opts.showProject1 !== false) { + await admin.collection("projects").update(projectId, { + show_in_intake: opts.showProject1 ?? true, + }); + } else { + await admin.collection("projects").update(projectId, { + show_in_intake: false, + }); + } + 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 — set up by 9a's tests + // already; create one if it doesn't exist. + const existing = await admin + .collection("projects") + .getFullList({ filter: `client = "${clientId}" && name = "Phase9 second"` }); + if (existing.length > 0) { + secondProjectId = existing[0].id; + } else { + const second = await admin.collection("projects").create({ + client: clientId, + name: "Phase9 second", + slug: `phase9-second-${Date.now()}`, + status: "active", + }); + secondProjectId = second.id; + } + + secondInboxId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${secondProjectId}" && is_inbox = true`) + ).id; +}); + +afterAll(async () => { + for (const id of createdCardIds) { + await admin.collection("cards").delete(id).catch(() => {}); + } + for (const id of createdLinkIds) { + await admin.collection("intake_links").delete(id).catch(() => {}); + } + for (const id of createdSubmissionIds) { + await admin + .collection("intake_link_submissions") + .delete(id) + .catch(() => {}); + } + // Reset the demo project's show_in_intake to false 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 two 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(); + + // The picker page renders the project names. It does NOT render + // the wizard's "Tell what's not working" button (the + // wizard only shows on /intake/[token]/p/[projectId]). + expect(html).toContain("Tell us which project"); + expect(html).toContain("Phase9 second"); + }); + + 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", + }); + // Next returns 307/308 for server-side redirects, or 200 if it + // follows them. We asked manual, so we expect a redirect status. + 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 is on 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(); + expect(html).toContain("Tell "); + // The wizard's start button is project-specific. + expect(html).toMatch(/Tell .* what's not working/); + }); + + 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, + }); + 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, + }); + const otherInbox = await admin.collection("columns").create({ + project: otherProject.id, + name: "Inbox", + position: 1, + is_inbox: true, + }); + + const response = await fetch(`${APP_URL}/intake/${link.token}/p/${otherProject.id}`); + expect(response.status).toBe(404); + + // Cleanup + await admin.collection("columns").delete(otherInbox.id).catch(() => {}); + await admin.collection("projects").delete(otherProject.id).catch(() => {}); + await admin.collection("clients").delete(otherClient.id).catch(() => {}); + }); + + 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); + + // Cleanup: remove the per-link, per-project, today's counter. + 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 { + // No row — the action may have failed before creating one. + } + }); + + it("refuses a projectId that doesn't belong to the link's client", async () => { + const link = await newPickerLink({ showProject1: true, showProject2: false }); + + // Mint a Phase 8 link on the OTHER project (different client) just + // to grab its projectId; we won't use the token. + const otherClient = await admin.collection("clients").create({ + name: `picker test other ${Date.now()}`, + slug: `picker-test-other-${Date.now()}`, + active: true, + }); + 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, + }); + const otherInbox = await admin.collection("columns").create({ + project: otherProject.id, + name: "Inbox", + position: 1, + is_inbox: true, + }); + + 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); + + await admin.collection("columns").delete(otherInbox.id).catch(() => {}); + await admin.collection("projects").delete(otherProject.id).catch(() => {}); + await admin.collection("clients").delete(otherClient.id).catch(() => {}); + }); + + 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 () => { + const link = await newPickerLink({ + showProject1: true, + showProject2: true, + }); + + // Tighten the cap to 1 so the test is fast. + await admin.collection("intake_links").update(link.id, { + max_submissions_per_day: 1, + }); + + // First submission to project1 — lands. + const r1 = 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(r1.status).toBe(201); + const c1 = (await r1.json()).card; + createdCardIds.push(c1.id); + + // Second 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); + + // No projectId on the URL — the picker page should redirect to the + // wizard URL using the link's project. + 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}` + ); + + // Wizard renders at that URL. + 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 () => { + // Phase 8 test: POST without projectId on a per-project link. + // (The body may have projectId ignored when the link is per-project; + // this test just confirms the route doesn't 500.) + 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: [], + }), + }); + // The Phase 9 route reads projectId from the body; for Phase 8 links + // it falls back to link.project. Either way, the call should land. + expect([201, 400]).toContain(response.status); + if (response.status === 201) { + const { card } = await response.json(); + createdCardIds.push(card.id); + } + }); +}); +``` + +- [ ] **Step 2: Run the test suite** + +```bash +nohup npm run dev > /tmp/devserver.log 2>&1 & +for i in 1 2 3 4 5 6 7 8 9 10; do + code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/login" 2>&1) + if [ "$code" = "200" ]; then break; fi + sleep 2 +done +set -a && source .env.local && set +a +npx vitest run --project integration tests/integration/intake-picker.test.ts +``` + +Expected: every test passes. The dev server must be running for the route calls to land. + +- [ ] **Step 3: Stop the dev server** + +```bash +kill $(pgrep -f "next dev") 2>/dev/null +``` + +- [ ] **Step 4: Commit** + +```bash +git add tests/integration/intake-picker.test.ts +git commit -m "test(intake): picker path — multi-project picker, single-project redirect, zero-projects fallback, project-mismatch 404, cap isolation, Phase 8 backwards compat + +Phase 9c Task 1. The new file mirrors intake-submit-public.test.ts +shape. Three groups: + - picker page renders (multi-project list, single-project redirect, + zero-projects fallback) + - wizard URL (/intake/[token]/p/[projectId]) 404s on cross-client + projectId and on show_in_intake=false + - submission flow: card lands on chosen project's inbox; + cross-client projectId is refused; hostile body cannot override + server-decided fields; per-project cap isolates one project from + another under the same link + +Phase 8 backwards compat: per-project links still redirect through +the picker to the wizard URL and still submit through /api/public/intake." +``` + +--- + +## Task 2: Full verification + +- [ ] **Step 1: Run every test** + +```bash +set -a && source .env.local && set +a && npm run test +``` + +Expected: 463 unit pass, all 9 Phase 8 integration tests pass, all Phase 9 integration tests pass. + +- [ ] **Step 2: Run every integration test** + +```bash +nohup npm run dev > /tmp/devserver.log 2>&1 & +for i in 1 2 3 4 5 6 7 8 9 10; do + code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000/login" 2>&1) + if [ "$code" = "200" ]; then break; fi + sleep 2 +done +set -a && source .env.local && set +a +npx vitest run --project integration +kill $(pgrep -f "next dev") 2>/dev/null +``` + +Expected: 11 Phase 8 integration tests + 11 Phase 9 integration tests = 22 total pass. + +- [ ] **Step 3: Verify the build and the colour check** + +```bash +npm run build +grep -rnE "bg-(neutral|gray|slate|zinc|stone)-|text-(neutral|gray|slate|zinc|stone)-|#[0-9a-fA-F]{6}" --include="*.tsx" src/components src/app +``` + +Expected: build succeeds; grep prints nothing (modulo the spec-known PWA themeColor metadata exception). + +- [ ] **Step 4: Walk the picker end to end** + +1. Sign in as owner. `/admin/clients`. Click any client name. +2. The new client admin page loads. Note the projects list and the + "List on request page" toggles. +3. Click **New request link**, type "Phase 9 smoke test", save. Copy + the URL. +4. Open the URL in a private window. Picker shows only the projects + whose toggle is on. Click one. Wizard runs. +5. Run through 2 questions, **That's enough — write it up**, review + the brief, **Send this**. Land on the thanks page. +6. Click **Send another request** — picker again, not the wizard. +7. Sign in as the owner. `/admin/clients/[id]`. The link's count went + up by one for the chosen project only. +8. On `/admin/projects/[id]`, toggle **List on request page** off. + Reload the picker URL — the project is gone. +9. Click the link's **Pause**. The URL 404s. Click **Resume**. The URL + works again. + +Expected: every step is reflected on the next page load. + +- [ ] **Step 5: Walk the Phase 8 backwards compat** + +1. On `/admin/projects/[projectId]`, click **Public intake links** → + **New link** (Phase 8 path). Save. Copy the URL. +2. Open the URL in a private window. The wizard starts immediately + (no picker — single-project behaviour). +3. Submit a brief. Card lands on the chosen project's inbox with + `created_by_role = "anonymous"`. + +Expected: Phase 8 URLs continue to work; the Phase 9 features (picker, per-(link, project) cap, project opt-in) are invisible because the link is per-project. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A +git commit -m "fix(phase-9c): issues found during Phase 9 verification" +``` + +--- + +## Definition of done + +1. `npm run test` passes: 463 unit. +2. `npx vitest run --project integration` passes: 22 integration + tests (11 Phase 8 + 11 Phase 9). +3. `npm run build` succeeds; colour check empty (modulo PWA + themeColor). +4. The picker flow works end-to-end. +5. Phase 8 URLs continue to work. +6. The "List on request page" toggle hides a project from the picker + on the next page load. +7. A per-(link, project) cap stops one project without locking + others under the same client. + +## Deliberately not in this plan + +- Unit tests for the pure helpers in `submissions.ts` — covered by + Phase 9a Task 2. +- The unit-test for the picker render — covered by the integration + test in Task 1 above. +- Cross-client isolation tests at the route level — covered by + Task 1's "404s when the projectId belongs to a different client". +- Performance / load tests — out of scope. +- Owner-side UX polish (drag-reorder, copy-URL badge, etc.) — Phase + 9.5 territory.