1
9import { afterAll, beforeAll, describe, expect, it } from "vitest";
10import { createTestApp, type TestAppHandle } from "./helpers/api";
11import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
12
13let app: TestAppHandle;
14
15beforeAll(async () => {
16 app = await createTestApp();
17}, 180_000);
18afterAll(async () => {
19 await app?.close();
20});
21
22const json = async (res: Response): Promise<Record<string, unknown>> =>
23 (await res.json().catch(() => ({}))) as Record<string, unknown>;
24
25async function setupForm() {
26 const owner = await createUser(app.request, { name: "Owner" });
27 const headers = authHeaders(owner.accessToken!);
28 const { workspaceId } = await createWorkspace(app.request, headers, "Resp WS");
29
30
31
32 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
33 headers,
34 body: { title: "Feedback", type: "database" },
35 });
36 expect([200, 201]).toContain(pageRes.status);
37 const database = (await json(pageRes)) as { databaseId: string };
38 for (const p of [
39 { name: "Score", type: "NUMBER", config: {} },
40 { name: "Category", type: "SELECT", config: { options: [{ name: "praise" }] } },
41 ]) {
42 const propRes = await app.request("POST", `/api/v1/databases/${database.databaseId}/properties`, {
43 headers,
44 body: p,
45 });
46 expect([200, 201]).toContain(propRes.status);
47 }
48 const props = await app.prisma.databaseProperty.findMany({ where: { databaseId: database.databaseId, deletedAt: null } });
49 const formRes = await app.request("POST", `/api/v1/databases/${database.databaseId}/forms`, {
50 headers: authHeaders(owner.accessToken!),
51 body: {
52 name: "Feedback Form",
53 questions: [
54 { type: "SHORT_TEXT", label: "Title", propertyId: props.find((p) => p.type === "TITLE")!.id },
55 { type: "RATING", label: "Score", propertyId: props.find((p) => p.name === "Score")!.id, validation: { max: 10 } },
56 { type: "SINGLE_SELECT", label: "Category", propertyId: props.find((p) => p.name === "Category")!.id, validation: { options: ["praise"] } },
57 ],
58 },
59 });
60 expect([200, 201]).toContain(formRes.status);
61 const form = (await json(formRes)) as { id: string; slug: string };
62 await app.request("PATCH", `/api/v1/forms/${form.id}`, { headers: authHeaders(owner.accessToken!), body: { status: "PUBLISHED" } });
63
64 const questions = await app.prisma.formQuestion.findMany({ where: { formId: form.id } });
65 const byLabel = (label: string) => questions.find((q) => q.label === label)!;
66 return {
67 ownerHeaders: authHeaders(owner.accessToken!),
68 ownerUserId: owner.userId,
69 workspaceId,
70 formId: form.id,
71 slug: form.slug,
72 databaseId: database.databaseId,
73 submit: (answers: Record<string, unknown>, bearer?: string) =>
74 app.request("POST", `/api/v1/public/forms/${form.slug}/submit`, {
75 body: { answers },
76 headers: bearer ? { Authorization: `Bearer ${bearer}` } : {},
77 }),
78 titleQ: byLabel("Title").id,
79 scoreQ: byLabel("Score").id,
80 catQ: byLabel("Category").id
81 };
82}
83
84describe("Form responses (TASK-0109)", () => {
85 it("list: joined question labels + submitter names + masked IP for a non-admin", async () => {
86 const s = await setupForm();
87
88
89 const member = await createUser(app.request, { name: "Member" });
90 await addMemberViaSeam(app.request, app.internalServiceToken, s.workspaceId, member.email, "MEMBER");
91
92
93
94
95 const memberHeaders = authHeaders(member.accessToken!);
96
97
98 const subRes = await s.submit(
99 { [s.titleQ]: "Great product", [s.scoreQ]: 9, [s.catQ]: "praise" },
100 member.accessToken
101 );
102 expect(subRes.status).toBe(201);
103 const { submissionId } = (await json(subRes)) as { submissionId: string };
104
105 const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: memberHeaders });
106 expect(listRes.status).toBe(200);
107 const { items } = (await json(listRes)) as { items: Record<string, unknown>[] };
108 expect(items.length).toBe(1);
109 const row = items[0]!;
110
111 const labels = row.questionLabels as Record<string, { label: string }>;
112 expect(labels[s.titleQ]?.label).toBe("Title");
113 expect(labels[s.scoreQ]?.label).toBe("Score");
114
115 const sub = row.submittedBy as { name: string; anonymous: boolean } | null;
116 expect(sub?.anonymous).toBe(false);
117 expect(sub?.name).toBe("Member");
118
119 expect(row.ipMasked).toBe(true);
120 expect(String(row.ip)).toContain("***");
121
122 expect(row.recordId).toBeTruthy();
123 void submissionId;
124 });
125
126 it("IP unmasked for a workspace Owner/Admin", async () => {
127 const s = await setupForm();
128 await s.submit({ [s.titleQ]: "Admin sees the IP" });
129 const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: s.ownerHeaders });
130 expect(listRes.status).toBe(200);
131 const { items } = (await json(listRes)) as { items: Record<string, unknown>[] };
132 expect(items.length).toBe(1);
133
134 expect(items[0]!.ipMasked).toBe(false);
135 expect(String(items[0]!.ip)).not.toContain("***");
136 });
137
138 it("deleted submitter → 'Former member'", async () => {
139 const s = await setupForm();
140 const ghost = await createUser(app.request, { name: "Ghost" });
141 await addMemberViaSeam(app.request, app.internalServiceToken, s.workspaceId, ghost.email, "MEMBER");
142
143
144
145 const subRes = await s.submit({ [s.titleQ]: "Ghost reply" }, ghost.accessToken);
146 expect(subRes.status).toBe(201);
147
148 await app.prisma.user.delete({ where: { id: ghost.userId } });
149 const listRes = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, { headers: s.ownerHeaders });
150 const { items } = (await json(listRes)) as { items: Record<string, unknown>[] };
151 const sub = items[0]!.submittedBy as { name: string } | null;
152 expect(sub?.name).toBe("Former member");
153 });
154
155 it("anonymousOnly + q= + date range filters work", async () => {
156 const s = await setupForm();
157 await s.submit({ [s.titleQ]: "Anonymous note about zebra" });
158 await s.submit({ [s.titleQ]: "Different note" });
159
160 const an = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?anonymousOnly=true`, { headers: s.ownerHeaders });
161 const anBody = (await json(an)) as { items: unknown[] };
162 expect(anBody.items.length).toBe(2);
163
164 const q = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?q=zebra`, { headers: s.ownerHeaders });
165 const qBody = (await json(q)) as { items: Record<string, unknown>[] };
166 expect(qBody.items.length).toBe(1);
167 expect(JSON.stringify(qBody.items[0]!.answers)).toContain("zebra");
168
169 const now = new Date();
170 const from = new Date(now.getTime() - 60_000).toISOString();
171 const to = new Date(now.getTime() + 60_000).toISOString();
172 const r = await app.request("GET", `/api/v1/forms/${s.formId}/submissions?from=${from}&to=${to}`, { headers: s.ownerHeaders });
173 const rBody = (await json(r)) as { items: unknown[] };
174 expect(rBody.items.length).toBe(2);
175 });
176
177 it("non-member (other workspace) gets 403/404 — never the data", async () => {
178 const s = await setupForm();
179 const outsider = await createUser(app.request, { name: "Outsider" });
180 const res = await app.request("GET", `/api/v1/forms/${s.formId}/submissions`, {
181 headers: authHeaders(outsider.accessToken!),
182 });
183 expect([403, 404]).toContain(res.status);
184 });
185
186 it("delete without confirm=true → 422 CONFIRM_REQUIRED", async () => {
187 const s = await setupForm();
188 const subRes = await s.submit({ [s.titleQ]: "To delete" });
189 const { submissionId } = (await json(subRes)) as { submissionId: string };
190 const res = await app.request("DELETE", `/api/v1/forms/${s.formId}/submissions/${submissionId}`, { headers: s.ownerHeaders });
191 expect(res.status).toBe(422);
192 expect(JSON.stringify(await json(res))).toContain("CONFIRM_REQUIRED");
193 });
194
195 it("delete with confirm cascades: record + page gone, submission gone, audit rows present", async () => {
196 const s = await setupForm();
197 const subRes = await s.submit({ [s.titleQ]: "Cascade me", [s.scoreQ]: 5 });
198 const body = (await json(subRes)) as { submissionId: string };
199 const submission = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: body.submissionId } });
200 const recordId = submission.recordId!;
201 const pageId = (await app.prisma.databaseRecord.findUniqueOrThrow({ where: { id: recordId } })).pageId!;
202
203 const res = await app.request(
204 "DELETE",
205 `/api/v1/forms/${s.formId}/submissions/${body.submissionId}?confirm=true`,
206 { headers: s.ownerHeaders }
207 );
208 expect(res.status).toBe(200);
209 const out = (await json(res)) as { deleted: boolean; recordDeleted: boolean };
210 expect(out.deleted).toBe(true);
211 expect(out.recordDeleted).toBe(true);
212
213
214 expect(await app.prisma.databaseRecord.findUnique({ where: { id: recordId } })).toBeNull();
215 expect(await app.prisma.page.findUnique({ where: { id: pageId } })).toBeNull();
216
217 expect(await app.prisma.formSubmission.findUnique({ where: { id: body.submissionId } })).toBeNull();
218
219 const audit = await app.prisma.auditLog.findMany({
220 where: { workspaceId: s.workspaceId, action: { in: ["form.submission_deleted", "record.deleted"] } }
221 });
222 expect(audit.some((a) => a.action === "form.submission_deleted")).toBe(true);
223 expect(audit.some((a) => a.action === "record.deleted" && a.resourceId === recordId)).toBe(true);
224 });
225
226 it("orphan submission (recordId null) delete → only the row removed", async () => {
227 const s = await setupForm();
228
229
230
231 const unmapped = await app.request("POST", `/api/v1/databases/${s.databaseId}/forms`, {
232 headers: s.ownerHeaders,
233 body: { name: "Unmapped", questions: [{ type: "SHORT_TEXT", label: "Free text" }] },
234 });
235 const uf = (await json(unmapped)) as { id: string; slug: string };
236 await app.request("PATCH", `/api/v1/forms/${uf.id}`, { headers: s.ownerHeaders, body: { status: "PUBLISHED" } });
237 const uq = (await app.prisma.formQuestion.findMany({ where: { formId: uf.id } }))[0]!;
238 const subRes = await app.request("POST", `/api/v1/public/forms/${uf.slug}/submit`, {
239 body: { answers: { [uq.id]: "orphan answer" } }
240 });
241 expect(subRes.status).toBe(201);
242 const orphanId = ((await json(subRes)) as { submissionId: string }).submissionId;
243 const orphan = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: orphanId } });
244 expect(orphan.recordId).toBeNull();
245
246 const del = await app.request(
247 "DELETE",
248 `/api/v1/forms/${uf.id}/submissions/${orphanId}?confirm=true`,
249 { headers: s.ownerHeaders }
250 );
251 expect(del.status).toBe(200);
252 const out = (await json(del)) as { deleted: boolean; recordDeleted: boolean };
253 expect(out.deleted).toBe(true);
254 expect(out.recordDeleted).toBe(false);
255 expect(await app.prisma.formSubmission.findUnique({ where: { id: orphanId } })).toBeNull();
256 });
257
258 it("export → 501 NOT_IMPLEMENTED with implementedIn: phase-13", async () => {
259 const s = await setupForm();
260 const res = await app.request("GET", `/api/v1/forms/${s.formId}/export?format=csv`, { headers: s.ownerHeaders });
261 expect(res.status).toBe(501);
262 const { error } = await json(res);
263 const details = (error as { details?: { reason?: string; implementedIn?: string }[] }).details ?? [];
264 expect(details.some((d) => d.reason === "NOT_IMPLEMENTED_YET")).toBe(true);
265 expect(details.find((d) => d.reason === "NOT_IMPLEMENTED_YET")?.implementedIn).toBe("phase-13");
266 });
267
268 it("linked file purge: a FILE submission's file is purged on cascade (S3 key gone)", async () => {
269 const s = await setupForm();
270
271
272 const db2 = await app.request("POST", `/api/v1/workspaces/${s.workspaceId}/pages`, {
273 headers: s.ownerHeaders,
274 body: { title: "Files DB", type: "database" },
275 });
276 const db2Obj = (await json(db2)) as { databaseId: string };
277 const docProp = await app.request("POST", `/api/v1/databases/${db2Obj.databaseId}/properties`, {
278 headers: s.ownerHeaders,
279 body: { name: "Docs", type: "FILES", config: {} },
280 });
281 expect([200, 201]).toContain(docProp.status);
282 const props2 = await app.prisma.databaseProperty.findMany({ where: { databaseId: db2Obj.databaseId, deletedAt: null } });
283 const title2 = props2.find((p) => p.type === "TITLE")!;
284 const files2 = props2.find((p) => p.name === "Docs")!;
285 const f2 = await app.request("POST", `/api/v1/databases/${db2Obj.databaseId}/forms`, {
286 headers: s.ownerHeaders,
287 body: {
288 name: "File Form",
289 questions: [
290 { type: "SHORT_TEXT", label: "Title", propertyId: title2.id },
291 { type: "FILE", label: "Docs", propertyId: files2.id, validation: { maxFiles: 1 } },
292 ],
293 },
294 });
295 const form2 = (await json(f2)) as { id: string; slug: string };
296 await app.request("PATCH", `/api/v1/forms/${form2.id}`, { headers: s.ownerHeaders, body: { status: "PUBLISHED" } });
297 const qs2 = await app.prisma.formQuestion.findMany({ where: { formId: form2.id } });
298 const fileQ = qs2.find((q) => q.label === "Docs")!;
299
300
301 const file = await app.prisma.file.create({
302 data: {
303 workspaceId: s.workspaceId,
304 name: "attach.txt",
305 key: `ws/${s.workspaceId}/form-${Math.random().toString(36).slice(2)}.txt`,
306 size: 12,
307 mimeType: "text/plain",
308 status: "READY",
309 uploadedBy: s.ownerUserId,
310 metadata: { formId: form2.id }
311 }
312 });
313
314 const subRes = await app.request("POST", `/api/v1/public/forms/${form2.slug}/submit`, {
315 body: { answers: { [fileQ.id]: [file.id] } }
316 });
317 expect(subRes.status).toBe(201);
318 const subId = ((await json(subRes)) as { submissionId: string }).submissionId;
319 const fileAfterLink = await app.prisma.file.findUniqueOrThrow({ where: { id: file.id } });
320
321 expect((fileAfterLink.metadata as Record<string, unknown>).submissionId).toBe(subId);
322
323 const del = await app.request(
324 "DELETE",
325 `/api/v1/forms/${form2.id}/submissions/${subId}?confirm=true`,
326 { headers: s.ownerHeaders }
327 );
328 expect(del.status).toBe(200);
329 const out = (await json(del)) as { filesPurged: number };
330 expect(out.filesPurged).toBe(1);
331
332 expect(await app.prisma.file.findUnique({ where: { id: file.id } })).toBeNull();
333 });
334});
335