kanban-dev-webapp/docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md
Dave 882e9e20a7 docs(phase-9c): plan for picker integration tests + verification
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 13:12:44 +02:00

23 KiB

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
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 <project> 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
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
kill $(pgrep -f "next dev") 2>/dev/null
  • Step 4: Commit
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
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
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
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
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.