diff --git a/tests/integration/intake-submit.test.ts b/tests/integration/intake-submit.test.ts new file mode 100644 index 0000000..5ff6f9e --- /dev/null +++ b/tests/integration/intake-submit.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import PocketBase from "pocketbase"; + +import { APP_URL, DEMO, adminPb, requireServices, signIn } from "./helpers"; + +let admin: PocketBase; +let cookie: string; +let projectId: string; +let inboxId: string; +let doneId: string; +const createdCards: string[] = []; + +beforeAll(async () => { + await requireServices(); + admin = await adminPb(); + cookie = await signIn(DEMO.managerEmail, DEMO.managerPassword); + + const project = await admin + .collection("projects") + .getFirstListItem(`slug = "${DEMO.projectSlug}"`); + projectId = project.id; + + inboxId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_inbox = true`) + ).id; + doneId = ( + await admin + .collection("columns") + .getFirstListItem(`project = "${projectId}" && is_done = true`) + ).id; +}); + +afterAll(async () => { + for (const id of createdCards) { + await admin.collection("cards").delete(id).catch(() => {}); + } +}); + +const brief = { + title: "Add a POS at the counter", + summary: "Till staff need to take card payments.", + subtasks: ["Choose a card reader", "Wire it to the till"], + openQuestions: ["Which reader is in use today?"], + area: "", + degraded: false, +}; + +describe("a brief becomes an Inbox card with sub-tasks", () => { + it("creates the card, the subtasks and the activity row", async () => { + const response = await fetch(`${APP_URL}/api/client/intake`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + project: projectId, + brief, + transcript: [{ question: "What would you like?", answer: "A POS." }], + sizeBand: "medium", + }), + }); + + expect(response.status).toBe(201); + const { card } = await response.json(); + createdCards.push(card.id); + + const created = await admin.collection("cards").getOne(card.id); + expect(created.column).toBe(inboxId); + expect(created.accepted).toBe(false); + expect(created.created_by_role).toBe("client"); + expect(created.title).toBe(brief.title); + expect(created.size_band).toBe("medium"); + expect(created.number).toBeGreaterThan(0); + + const subtasks = await admin + .collection("subtasks") + .getFullList({ filter: `card = "${card.id}"`, sort: "position" }); + expect(subtasks.map((s) => s.title)).toEqual(brief.subtasks); + + const activity = await admin + .collection("activity") + .getFullList({ filter: `card = "${card.id}"` }); + expect(activity.length).toBeGreaterThan(0); + }); +}); + +describe("the server decides the fields that matter", () => { + it("ignores an escalating brief entirely", async () => { + // The whole security property in one test: whatever the body claims, the + // card lands in the Inbox at medium priority, unaccepted. + const response = await fetch(`${APP_URL}/api/client/intake`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + project: projectId, + brief: { ...brief, priority: "critical", accepted: true, column: doneId }, + transcript: [], + sizeBand: "large", + // Top-level attempts too, not only inside the brief. + priority: "critical", + accepted: true, + column: doneId, + created_by_role: "owner", + }), + }); + + expect(response.status).toBe(201); + const { card } = await response.json(); + createdCards.push(card.id); + + const created = await admin.collection("cards").getOne(card.id); + expect(created.column).toBe(inboxId); + expect(created.priority).toBe("medium"); + expect(created.accepted).toBe(false); + expect(created.created_by_role).toBe("client"); + }); + + it("rejects an unknown size band rather than storing it", async () => { + const response = await fetch(`${APP_URL}/api/client/intake`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ + project: projectId, + brief, + transcript: [], + sizeBand: "enormous", + }), + }); + + const { card } = await response.json(); + createdCards.push(card.id); + + const created = await admin.collection("cards").getOne(card.id); + expect(created.size_band).toBe(""); + }); +}); + +describe("the intake route is scoped like every other client route", () => { + it("refuses another client's project", async () => { + const stamp = Date.now(); + const other = await admin + .collection("clients") + .create({ name: "Intake scope probe", slug: `isp-${stamp}`, active: true }); + const otherProject = await admin.collection("projects").create({ + client: other.id, + name: "Probe", + slug: `isp-${stamp}`, + status: "active", + }); + + const response = await fetch(`${APP_URL}/api/client/intake`, { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ project: otherProject.id, brief, transcript: [] }), + }); + + expect(response.status).toBe(404); + + await admin.collection("projects").delete(otherProject.id); + await admin.collection("clients").delete(other.id); + }); + + it("refuses an unauthenticated request", async () => { + const response = await fetch(`${APP_URL}/api/client/intake`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: projectId, brief, transcript: [] }), + }); + expect(response.status).toBe(401); + }); +});