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.
This commit is contained in:
parent
cb122e802a
commit
882e9e20a7
1 changed files with 678 additions and 0 deletions
678
docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md
Normal file
678
docs/superpowers/plans/2026-09-21-phase-9c-tests-verification.md
Normal file
|
|
@ -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 <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**
|
||||
|
||||
```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.
|
||||
Loading…
Reference in a new issue