/** * AI Software Factory — Integration: Form responses (Phase 10, TASK-0109) * * The responses list (joined view model: question labels, submitter * resolution incl. the deleted-user "Former member", IP masking per role), * the cascade delete (record + page gone, linked files purged, audit rows, * `confirm=true` gate, orphan submissions), and the 501 export stub. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; async function setupForm() { const owner = await createUser(app.request, { name: "Owner" }); const headers = authHeaders(owner.accessToken!); const { workspaceId } = await createWorkspace(app.request, headers, "Resp WS"); // A database is created THROUGH THE PAGES SURFACE (there is no // `POST /databases` route; the record-a-page invariant), then properties // are added with `POST /databases/:id/properties`. const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "Feedback", type: "database" }, }); expect([200, 201]).toContain(pageRes.status); const database = (await json(pageRes)) as { databaseId: string }; for (const p of [ { name: "Score", type: "NUMBER", config: {} }, { name: "Category", type: "SELECT", config: { options: [{ name: "praise" }] } }, ]) { const propRes = await app.request("POST", `/api/v1/databases/${database.databaseId}/properties`, { headers, body: p, }); expect([200, 201]).toContain(propRes.status); } const props = await app.prisma.databaseProperty.findMany({ where: { databaseId: database.databaseId, deletedAt: null } }); const formRes = await app.request("POST", `/api/v1/databases/${database.databaseId}/forms`, { headers: authHeaders(owner.accessToken!), body: { name: "Feedback Form", questions: [ { type: "SHORT_TEXT", label: "Title", propertyId: props.find((p) => p.type === "TITLE")!.id }, { type: "RATING", label: "Score", propertyId: props.find((p) => p.name === "Score")!.id, validation: { max: 10 } }, { type: "SINGLE_SELECT", label: "Category", propertyId: props.find((p) => p.name === "Category")!.id, validation: { options: ["praise"] } }, ], }, }); expect([200, 201]).toContain(formRes.status); const form = (await json(formRes)) as { id: string; slug: string }; await app.request("PATCH", `/api/v1/forms/${form.id}`, { headers: authHeaders(owner.accessToken!), body: { status: "PUBLISHED" } }); const questions = await app.prisma.formQuestion.findMany({ where: { formId: form.id } }); const byLabel = (label: string) => questions.find((q) => q.label === label)!; return { ownerHeaders: authHeaders(owner.accessToken!), ownerUserId: owner.userId, workspaceId, formId: form.id, slug: form.slug, databaseId: database.databaseId, submit: (answers: Record, bearer?: string) => app.request("POST", `/api/v1/public/forms/${form.slug}/submit`, { body: { answers }, headers: bearer ? { Authorization: `Bearer ${bearer}` } : {}, }), titleQ: byLabel("Title").id, scoreQ: byLabel("Score").id, catQ: byLabel("Category").id }; } describe("Form responses (TASK-0109)", () => { it("list: joined question labels + submitter names + masked IP for a non-admin", async () => { const s = await setupForm(); // A MEMBER (non-admin) is added to the workspace (the seam keys by email). const member = await createUser(app.request, { name: "Member" }); await addMemberViaSeam(app.request, app.internalServiceToken, s.workspaceId, member.email, "MEMBER"); // We deliberately use the ORIGINAL token (minted BEFORE the seam, so its // `workspaces` JWT claim is stale). Attribution must come from the // DB membership (source of truth), not the claim — this is the regression // the DB-based optionalSession() prevents. const memberHeaders = authHeaders(member.accessToken!); // An attributed submit (a member of the workspace submitting publicly). const subRes = await s.submit( { [s.titleQ]: "Great product", [s.scoreQ]: 9, [s.catQ]: "praise" }, member.accessToken ); expect(subRes.status).toBe(201); const { submissionId } = (await json(subRes)) as { submissionId: string }; const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: memberHeaders }); expect(listRes.status).toBe(200); const { items } = (await json(listRes)) as { items: Record[] }; expect(items.length).toBe(1); const row = items[0]!; // Joined question labels. const labels = row.questionLabels as Record; expect(labels[s.titleQ]?.label).toBe("Title"); expect(labels[s.scoreQ]?.label).toBe("Score"); // Submitter resolved to the member (not anonymous). const sub = row.submittedBy as { name: string; anonymous: boolean } | null; expect(sub?.anonymous).toBe(false); expect(sub?.name).toBe("Member"); // IP masked for a non-admin. expect(row.ipMasked).toBe(true); expect(String(row.ip)).toContain("***"); // recordId present (a record was created). expect(row.recordId).toBeTruthy(); void submissionId; }); it("IP unmasked for a workspace Owner/Admin", async () => { const s = await setupForm(); await s.submit({ [s.titleQ]: "Admin sees the IP" }); const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: s.ownerHeaders }); expect(listRes.status).toBe(200); const { items } = (await json(listRes)) as { items: Record[] }; expect(items.length).toBe(1); // The owner (workspace OWNER role) sees the raw IP — no masking. expect(items[0]!.ipMasked).toBe(false); expect(String(items[0]!.ip)).not.toContain("***"); }); it("deleted submitter → 'Former member'", async () => { const s = await setupForm(); const ghost = await createUser(app.request, { name: "Ghost" }); await addMemberViaSeam(app.request, app.internalServiceToken, s.workspaceId, ghost.email, "MEMBER"); // Same stale-token setup as the attribution test above: the original // access token's `workspaces` claim predates the seam. DB membership // (not the claim) must attribute the submit to the ghost user. const subRes = await s.submit({ [s.titleQ]: "Ghost reply" }, ghost.accessToken); expect(subRes.status).toBe(201); // Delete the user's row (the submission keeps the id — the orphan case). await app.prisma.user.delete({ where: { id: ghost.userId } }); const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: s.ownerHeaders }); const { items } = (await json(listRes)) as { items: Record[] }; const sub = items[0]!.submittedBy as { name: string } | null; expect(sub?.name).toBe("Former member"); }); it("anonymousOnly + q= + date range filters work", async () => { const s = await setupForm(); await s.submit({ [s.titleQ]: "Anonymous note about zebra" }); await s.submit({ [s.titleQ]: "Different note" }); // anonymousOnly const an = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?anonymousOnly=true`, { headers: s.ownerHeaders }); const anBody = (await json(an)) as { items: unknown[] }; expect(anBody.items.length).toBe(2); // q= matches answer text const q = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?q=zebra`, { headers: s.ownerHeaders }); const qBody = (await json(q)) as { items: Record[] }; expect(qBody.items.length).toBe(1); expect(JSON.stringify(qBody.items[0]!.answers)).toContain("zebra"); // date range (a window around now includes both) const now = new Date(); const from = new Date(now.getTime() - 60_000).toISOString(); const to = new Date(now.getTime() + 60_000).toISOString(); const r = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?from=${from}&to=${to}`, { headers: s.ownerHeaders }); const rBody = (await json(r)) as { items: unknown[] }; expect(rBody.items.length).toBe(2); }); it("non-member (other workspace) gets 403/404 — never the data", async () => { const s = await setupForm(); const outsider = await createUser(app.request, { name: "Outsider" }); const res = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: authHeaders(outsider.accessToken!), }); expect([403, 404]).toContain(res.status); }); it("delete without confirm=true → 422 CONFIRM_REQUIRED", async () => { const s = await setupForm(); const subRes = await s.submit({ [s.titleQ]: "To delete" }); const { submissionId } = (await json(subRes)) as { submissionId: string }; const res = await app.request("DELETE", `/api/v1/forms/${s.formId}/submissions/${submissionId}`, { headers: s.ownerHeaders }); expect(res.status).toBe(422); expect(JSON.stringify(await json(res))).toContain("CONFIRM_REQUIRED"); }); it("delete with confirm cascades: record + page gone, submission gone, audit rows present", async () => { const s = await setupForm(); const subRes = await s.submit({ [s.titleQ]: "Cascade me", [s.scoreQ]: 5 }); const body = (await json(subRes)) as { submissionId: string }; const submission = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: body.submissionId } }); const recordId = submission.recordId!; const pageId = (await app.prisma.databaseRecord.findUniqueOrThrow({ where: { id: recordId } })).pageId!; const res = await app.request( "DELETE", `/api/v1/forms/${s.formId}/submissions/${body.submissionId}?confirm=true`, { headers: s.ownerHeaders } ); expect(res.status).toBe(200); const out = (await json(res)) as { deleted: boolean; recordDeleted: boolean }; expect(out.deleted).toBe(true); expect(out.recordDeleted).toBe(true); // The record + its page are gone. expect(await app.prisma.databaseRecord.findUnique({ where: { id: recordId } })).toBeNull(); expect(await app.prisma.page.findUnique({ where: { id: pageId } })).toBeNull(); // The submission row is gone. expect(await app.prisma.formSubmission.findUnique({ where: { id: body.submissionId } })).toBeNull(); // Audit rows: form.submission_deleted (+ record.deleted for the record). const audit = await app.prisma.auditLog.findMany({ where: { workspaceId: s.workspaceId, action: { in: ["form.submission_deleted", "record.deleted"] } } }); expect(audit.some((a) => a.action === "form.submission_deleted")).toBe(true); expect(audit.some((a) => a.action === "record.deleted" && a.resourceId === recordId)).toBe(true); }); it("orphan submission (recordId null) delete → only the row removed", async () => { const s = await setupForm(); // A submission with no record: a form whose questions map to NO // properties (create a second form on a db with only a TITLE question // UNMAPPED — the submit pipeline still creates a submission, no record). const unmapped = await app.request("POST", `/api/v1/databases/${s.databaseId}/forms`, { headers: s.ownerHeaders, body: { name: "Unmapped", questions: [{ type: "SHORT_TEXT", label: "Free text" }] }, }); const uf = (await json(unmapped)) as { id: string; slug: string }; await app.request("PATCH", `/api/v1/forms/${uf.id}`, { headers: s.ownerHeaders, body: { status: "PUBLISHED" } }); const uq = (await app.prisma.formQuestion.findMany({ where: { formId: uf.id } }))[0]!; const subRes = await app.request("POST", `/api/v1/public/forms/${uf.slug}/submit`, { body: { answers: { [uq.id]: "orphan answer" } } }); expect(subRes.status).toBe(201); const orphanId = ((await json(subRes)) as { submissionId: string }).submissionId; const orphan = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: orphanId } }); expect(orphan.recordId).toBeNull(); const del = await app.request( "DELETE", `/api/v1/forms/${uf.id}/submissions/${orphanId}?confirm=true`, { headers: s.ownerHeaders } ); expect(del.status).toBe(200); const out = (await json(del)) as { deleted: boolean; recordDeleted: boolean }; expect(out.deleted).toBe(true); expect(out.recordDeleted).toBe(false); expect(await app.prisma.formSubmission.findUnique({ where: { id: orphanId } })).toBeNull(); }); it("export → 501 NOT_IMPLEMENTED with implementedIn: phase-13", async () => { const s = await setupForm(); const res = await app.request("GET", `/api/v1/forms/${s.formId}/export?format=csv`, { headers: s.ownerHeaders }); expect(res.status).toBe(501); const { error } = await json(res); const details = (error as { details?: { reason?: string; implementedIn?: string }[] }).details ?? []; expect(details.some((d) => d.reason === "NOT_IMPLEMENTED_YET")).toBe(true); expect(details.find((d) => d.reason === "NOT_IMPLEMENTED_YET")?.implementedIn).toBe("phase-13"); }); it("linked file purge: a FILE submission's file is purged on cascade (S3 key gone)", async () => { const s = await setupForm(); // A form with a FILE question mapped to a FILES property (pages-surface // create + explicit property, same as setupForm above). const db2 = await app.request("POST", `/api/v1/workspaces/${s.workspaceId}/pages`, { headers: s.ownerHeaders, body: { title: "Files DB", type: "database" }, }); const db2Obj = (await json(db2)) as { databaseId: string }; const docProp = await app.request("POST", `/api/v1/databases/${db2Obj.databaseId}/properties`, { headers: s.ownerHeaders, body: { name: "Docs", type: "FILES", config: {} }, }); expect([200, 201]).toContain(docProp.status); const props2 = await app.prisma.databaseProperty.findMany({ where: { databaseId: db2Obj.databaseId, deletedAt: null } }); const title2 = props2.find((p) => p.type === "TITLE")!; const files2 = props2.find((p) => p.name === "Docs")!; const f2 = await app.request("POST", `/api/v1/databases/${db2Obj.databaseId}/forms`, { headers: s.ownerHeaders, body: { name: "File Form", questions: [ { type: "SHORT_TEXT", label: "Title", propertyId: title2.id }, { type: "FILE", label: "Docs", propertyId: files2.id, validation: { maxFiles: 1 } }, ], }, }); const form2 = (await json(f2)) as { id: string; slug: string }; await app.request("PATCH", `/api/v1/forms/${form2.id}`, { headers: s.ownerHeaders, body: { status: "PUBLISHED" } }); const qs2 = await app.prisma.formQuestion.findMany({ where: { formId: form2.id } }); const fileQ = qs2.find((q) => q.label === "Docs")!; // A READY file in THIS workspace, attributed to THIS form (metadata.formId). const file = await app.prisma.file.create({ data: { workspaceId: s.workspaceId, name: "attach.txt", key: `ws/${s.workspaceId}/form-${Math.random().toString(36).slice(2)}.txt`, size: 12, mimeType: "text/plain", status: "READY", uploadedBy: s.ownerUserId, metadata: { formId: form2.id } } }); const subRes = await app.request("POST", `/api/v1/public/forms/${form2.slug}/submit`, { body: { answers: { [fileQ.id]: [file.id] } } }); expect(subRes.status).toBe(201); const subId = ((await json(subRes)) as { submissionId: string }).submissionId; const fileAfterLink = await app.prisma.file.findUniqueOrThrow({ where: { id: file.id } }); // The link was stamped (the cascade's lookup key). expect((fileAfterLink.metadata as Record).submissionId).toBe(subId); const del = await app.request( "DELETE", `/api/v1/forms/${form2.id}/submissions/${subId}?confirm=true`, { headers: s.ownerHeaders } ); expect(del.status).toBe(200); const out = (await json(del)) as { filesPurged: number }; expect(out.filesPurged).toBe(1); // The DB row is gone. expect(await app.prisma.file.findUnique({ where: { id: file.id } })).toBeNull(); }); });