1
10import { afterAll, beforeAll, describe, expect, it } from "vitest";
11import { createTestApp, type TestAppHandle } from "./helpers/api";
12import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
13
14let app: TestAppHandle;
15
16beforeAll(async () => {
17 app = await createTestApp();
18}, 180_000);
19afterAll(async () => {
20 await app?.close();
21});
22
23const json = async (res: Response): Promise<Record<string, unknown>> =>
24 (await res.json().catch(() => ({}))) as Record<string, unknown>;
25
26
34async function setupDb(
35 extraProperties: { name: string; type: string; config?: unknown }[] = [],
36): Promise<{ headers: Record<string, string>; workspaceId: string; databaseId: string; props: Record<string, string> }> {
37 const user = await createUser(app.request, { name: "Form Owner" });
38 const headers = authHeaders(user.accessToken!);
39 const { workspaceId } = await createWorkspace(app.request, headers, "Form WS");
40 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
41 headers,
42 body: { title: "Feedback", type: "database" },
43 });
44 expect([200, 201]).toContain(res.status);
45 const { databaseId } = (await json(res)) as { databaseId: string };
46 for (const p of extraProperties) {
47 const propRes = await app.request("POST", `/api/v1/databases/${databaseId}/properties`, {
48 headers,
49 body: { name: p.name, type: p.type, config: p.config ?? {} },
50 });
51 expect([200, 201]).toContain(propRes.status);
52 }
53 const rows = await app.prisma.databaseProperty.findMany({ where: { databaseId, deletedAt: null } });
54 const props: Record<string, string> = {};
55 for (const r of rows) props[r.name] = r.id;
56 return { headers, workspaceId, databaseId, props };
57}
58
59
60async function makeForm(
61 headers: Record<string, string>,
62 databaseId: string,
63 name: string,
64 questions: unknown[] = [],
65 config: Record<string, unknown> = {},
66) {
67 const res = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, {
68 headers,
69 body: { name, config, questions },
70 });
71 expect([200, 201]).toContain(res.status);
72 return (await json(res)) as { id: string; slug: string; status: string };
73}
74
75describe("Forms builder (TASK-0106)", () => {
76 it("create: auto-generated kebab slug + a second same-name form gets the -2 suffix", async () => {
77 const { headers, databaseId } = await setupDb();
78 const a = await makeForm(headers, databaseId, "Customer Feedback");
79 expect(a.slug).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
80 expect(a.status).toBe("DRAFT");
81 const b = await makeForm(headers, databaseId, "Customer Feedback");
82 expect(b.slug).toBe(a.slug + "-2");
83 });
84
85 it("create with an explicit slug keeps it (and a colliding explicit slug 409s)", async () => {
86 const { headers, databaseId } = await setupDb();
87 await makeForm(headers, databaseId, "A", [], {});
88 const res1 = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, {
89 headers,
90 body: { name: "Named", slug: "my-form" },
91 });
92 expect([200, 201]).toContain(res1.status);
93 expect(((await json(res1)) as { slug: string }).slug).toBe("my-form");
94 const res2 = await app.request("POST", `/api/v1/databases/${databaseId}/forms`, {
95 headers,
96 body: { name: "Named Again", slug: "my-form" },
97 });
98
99 expect(res2.status).toBe(409);
100 });
101
102 it("question mapping: SHORT_TEXT → NUMBER property is 422 PROPERTY_TYPE_MISMATCH with the allowed types", async () => {
103 const { headers, databaseId, props } = await setupDb([
104 { name: "Count", type: "NUMBER" },
105 ]);
106 const { id: formId } = await makeForm(headers, databaseId, "Mismatch");
107 const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
108 headers,
109 body: { type: "SHORT_TEXT", label: "Notes", propertyId: props.Count },
110 });
111 expect(res.status).toBe(422);
112 const { error } = await json(res);
113 const details = (error as { details?: { issue?: string; allowedTypes?: string[] }[] }).details ?? [];
114 expect(details.some((d) => d.issue === "PROPERTY_TYPE_MISMATCH")).toBe(true);
115 expect(details.find((d) => d.issue === "PROPERTY_TYPE_MISMATCH")?.allowedTypes).toContain("TEXT");
116 });
117
118 it("a compatible mapping (SHORT_TEXT → TEXT property) succeeds", async () => {
119 const { headers, databaseId, props } = await setupDb([{ name: "Notes", type: "TEXT" }]);
120 const { id: formId } = await makeForm(headers, databaseId, "Compat");
121 const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
122 headers,
123 body: { type: "SHORT_TEXT", label: "Tell us", propertyId: props.Notes, required: true },
124 });
125 expect([200, 201]).toContain(res.status);
126 });
127
128 it("publishing a DRAFT with 0 questions → 422 issues[] containing the at-least-one-question issue", async () => {
129 const { headers, databaseId } = await setupDb();
130 const { id: formId } = await makeForm(headers, databaseId, "Empty", []);
131 const res = await app.request("PATCH", `/api/v1/forms/${formId}`, {
132 headers,
133 body: { status: "PUBLISHED" },
134 });
135 expect(res.status).toBe(422);
136 const body = await json(res);
137 const text = JSON.stringify(body);
138 expect(text).toContain("NO_QUESTIONS");
139 });
140
141 it("add a question then publish → 200 and the public endpoint serves it", async () => {
142 const { headers, databaseId, props } = await setupDb([{ name: "Rating", type: "NUMBER" }]);
143 const { id: formId, slug } = await makeForm(headers, databaseId, "Publishable");
144 const add = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
145 headers,
146 body: { type: "RATING", label: "Rate us", propertyId: props.Rating, validation: { max: 5 } },
147 });
148 expect([200, 201]).toContain(add.status);
149 const pub = await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
150 expect(pub.status).toBe(200);
151 const pubRes = await app.request("GET", `/api/v1/public/forms/${slug}`);
152 expect(pubRes.status).toBe(200);
153 });
154
155 it("reorder: 5 questions reordered → positions strictly ordered, API returns them in the new order", async () => {
156 const { headers, databaseId } = await setupDb();
157 const { id: formId } = await makeForm(headers, databaseId, "Order");
158 const ids: string[] = [];
159 for (let i = 0; i < 5; i++) {
160 const r = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
161 headers,
162 body: { type: "SHORT_TEXT", label: `Q${i + 1}` },
163 });
164 ids.push(((await json(r)) as { id: string }).id);
165 }
166
167 const reorder = await app.request("POST", `/api/v1/forms/${formId}/questions/reorder`, {
168 headers,
169 body: { fromId: ids[0], toId: ids[3] },
170 });
171
172
173 expect([200, 201]).toContain(reorder.status);
174 const list = ((await json(reorder)) as unknown as { id: string }[]);
175 expect(list.map((q) => q.id)).toEqual([ids[1], ids[2], ids[0], ids[3], ids[4]]);
176
177 const pos = await app.prisma.formQuestion.findMany({ where: { formId }, orderBy: { position: "asc" } });
178 const order = pos.map((p) => p.position);
179 for (let i = 1; i < order.length; i++) expect(order[i - 1] < order[i]).toBe(true);
180 });
181
182 it("question validation: SINGLE_SELECT with a duplicate option is 422", async () => {
183 const { headers, databaseId } = await setupDb();
184 const { id: formId } = await makeForm(headers, databaseId, "DupOpts");
185 const res = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
186 headers,
187 body: { type: "SINGLE_SELECT", label: "Pick", validation: { options: ["a", "a"] } },
188 });
189 expect(res.status).toBe(422);
190 });
191
192 it("delete question with existing submissions → answers JSON retained; form still lists remaining", async () => {
193 const { headers, databaseId, props } = await setupDb([{ name: "Cat", type: "SELECT", config: { options: [{ name: "x" }] } }]);
194 const { id: formId, slug } = await makeForm(headers, databaseId, "DelQ");
195 const q = await app.request("POST", `/api/v1/forms/${formId}/questions`, {
196 headers,
197 body: { type: "SINGLE_SELECT", label: "Category", propertyId: props.Cat, validation: { options: ["x"] } },
198 });
199 const qid = ((await json(q)) as { id: string }).id;
200 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
201
202 await app.request("POST", `/api/v1/public/forms/${slug}/submit`, {
203 body: { answers: { [qid]: "x" } },
204 });
205 const subBefore = await app.prisma.formSubmission.findFirst({ where: { formId } });
206 expect(subBefore).toBeTruthy();
207 const del = await app.request("DELETE", `/api/v1/forms/${formId}/questions/${qid}`, { headers });
208 expect(del.status).toBe(200);
209
210 const subAfter = await app.prisma.formSubmission.findFirst({ where: { formId } });
211 expect((subAfter?.answers as Record<string, unknown>)[qid]).toBe("x");
212 });
213});
214
215describe("Public form payload (TASK-0107)", () => {
216 it("PUBLISHED → the exact whitelist; DRAFT → 404; unknown slug → 404", async () => {
217
218
219 const SENTINEL = "SSN-9f3a1c-SECRET-COLUMN";
220 const { headers, databaseId, props } = await setupDb([
221 { name: "Email", type: "EMAIL" },
222 { name: SENTINEL, type: "TEXT" },
223 ]);
224 const { id: formId, slug } = await makeForm(
225 headers,
226 databaseId,
227 "Leak Check",
228 [{ type: "SHORT_TEXT", label: "Your email", propertyId: props.Email, required: true }],
229 { intro: "Hello **world**", accent: "#059669" },
230 );
231
232 const draft = await app.request("GET", `/api/v1/public/forms/${slug}`);
233 expect(draft.status).toBe(404);
234 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
235 const pub = await app.request("GET", `/api/v1/public/forms/${slug}`);
236 expect(pub.status).toBe(200);
237 const { form, questions } = (await json(pub)) as {
238 form: { slug: string; name: string; description: string | null; status: string; config: Record<string, unknown> };
239 questions: Record<string, unknown>[];
240 };
241
242 expect(form.status).toBe("published");
243 expect(form).toHaveProperty("slug");
244 expect(form).toHaveProperty("name");
245 expect(form.config).toHaveProperty("intro");
246
247 expect(String(form.config.intro)).not.toContain("**world**");
248 const raw = JSON.stringify({ form, questions });
249 expect(raw).not.toContain("propertyId");
250 expect(raw).not.toContain(props.Email);
251 expect(raw).not.toContain(props[SENTINEL]);
252 expect(raw).not.toContain(databaseId);
253
254
255 expect(raw).not.toContain(SENTINEL);
256
257
258 expect(raw).not.toContain("Form WS");
259
260
261 expect(Object.keys(questions[0]).sort()).toEqual(
262 ["description", "id", "label", "placeholder", "required", "type", "validation"].sort(),
263 );
264 expect(questions[0]).toHaveProperty("label");
265 expect(questions[0]).toHaveProperty("type");
266 });
267
268 it("CLOSED form → 200 with status closed + closedMessage; submit → 403 FORM_CLOSED", async () => {
269 const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]);
270 const { id: formId, slug } = await makeForm(
271 headers,
272 databaseId,
273 "Will Close",
274 [{ type: "SHORT_TEXT", label: "Name", propertyId: props.T }],
275 { closedMessage: "Thanks, we are closed for now." },
276 );
277 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
278 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "CLOSED" } });
279 const pub = await app.request("GET", `/api/v1/public/forms/${slug}`);
280 expect(pub.status).toBe(200);
281 const body = (await json(pub)) as { form: { status: string; config: { closedMessage: string | null } } };
282 expect(body.form.status).toBe("closed");
283 expect(body.form.config.closedMessage).toContain("closed for now");
284 const sub = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body: { answers: {} } });
285 expect(sub.status).toBe(403);
286 expect(JSON.stringify(await json(sub))).toContain("FORM_CLOSED");
287 });
288});
289
290describe("Submission pipeline (TASK-0108)", () => {
291 it("anonymous valid submit → 201; record + submission exist, event on the stream", async () => {
292 const { headers, databaseId, props } = await setupDb([
293 { name: "Score", type: "NUMBER" },
294 { name: "Category", type: "SELECT", config: { options: [{ name: "good" }] } },
295 ]);
296 const { id: formId, slug } = await makeForm(
297 headers,
298 databaseId,
299 "Live Submit",
300 [
301 { type: "SHORT_TEXT", label: "Title", propertyId: props.Title, required: true },
302 { type: "RATING", label: "Score", propertyId: props.Score, validation: { max: 10 } },
303 { type: "SINGLE_SELECT", label: "Category", propertyId: props.Category, validation: { options: ["good"] } },
304 ],
305 );
306 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
307
308
309
310
311
312 const debugEvents = async (): Promise<{ event: string; payload: Record<string, unknown> }[]> => {
313 const r = await app.request("GET", "/api/v1/debug/events?name=form.submitted&limit=200", {
314 headers: { "X-Debug-Token": app.debugToken ?? "" },
315 });
316 if (r.status !== 200) throw new Error(`debug events endpoint failed (${r.status}): ${await r.text()}`);
317 return ((await json(r)) as { items: { event: string; payload: Record<string, unknown> }[] }).items;
318 };
319
320
321 const questions = await app.prisma.formQuestion.findMany({ where: { formId } });
322 const byLabel = (label: string) => questions.find((q) => q.label === label)!;
323 const real = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, {
324 body: {
325 answers: {
326 [byLabel("Title").id]: "A feedback entry",
327 [byLabel("Score").id]: 7,
328 [byLabel("Category").id]: "good"
329 }
330 }
331 });
332 expect(real.status).toBe(201);
333 const { submissionId } = (await json(real)) as { submissionId: string };
334 expect(submissionId).toBeTruthy();
335 expect(real.headers.get("X-Submission-Id")).toBe(submissionId);
336
337 const sub = await app.prisma.formSubmission.findUniqueOrThrow({ where: { id: submissionId } });
338 expect(sub.submittedBy).toBeNull();
339 expect(sub.recordId).toBeTruthy();
340 expect(sub.ipAddress).toBeTruthy();
341
342
343 const rec = await app.prisma.databaseRecord.findUniqueOrThrow({
344 where: { id: sub.recordId! },
345 include: { properties: true },
346 });
347 const scoreProp = rec.properties.find((p) => p.propertyId === props.Score);
348 expect((scoreProp?.value as { value: number }).value).toBe(7);
349
350
351 let ev: Record<string, unknown> | null = null;
352 const deadline = Date.now() + 5000;
353 while (!ev && Date.now() < deadline) {
354 for (const item of await debugEvents()) {
355 if (item.payload.submissionId === submissionId) {
356 ev = item.payload;
357 break;
358 }
359 }
360 if (!ev) await new Promise((r) => setTimeout(r, 250));
361 }
362 expect(ev, "form.submitted event on the stream").toBeTruthy();
363 expect(ev!.anonymous).toBe(true);
364 expect(ev!.recordId).toBe(sub.recordId);
365
366 expect(String(ev!.ip)).toContain("***");
367 expect(String(ev!.ip)).not.toMatch(/\d+\.\d+\.\d+\.\d+$/);
368 });
369
370 it("invalid input → 422 with ALL violations (questionId each), nothing persisted", async () => {
371 const { headers, databaseId, props } = await setupDb([
372 { name: "N", type: "NUMBER" },
373 { name: "Sel", type: "SELECT", config: { options: [{ name: "only" }] } },
374 ]);
375 const { id: formId, slug } = await makeForm(
376 headers,
377 databaseId,
378 "Invalid",
379 [
380 { type: "SHORT_TEXT", label: "Req", propertyId: props.Title, required: true },
381 { type: "NUMBER", label: "Num", propertyId: props.N, validation: { min: 1, max: 5 } },
382 { type: "SINGLE_SELECT", label: "Sel", propertyId: props.Sel, validation: { options: ["only"] } },
383 ],
384 );
385 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
386 const questions = await app.prisma.formQuestion.findMany({ where: { formId } });
387 const byLabel = (label: string) => questions.find((q) => q.label === label)!;
388
389 const before = await app.prisma.formSubmission.count({ where: { formId } });
390 const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, {
391 body: {
392 answers: {
393
394 [byLabel("Num").id]: 99,
395 [byLabel("Sel").id]: "nope"
396 }
397 },
398 });
399 expect(res.status).toBe(422);
400 const { error } = await json(res);
401 const details = (error as { details?: { questionId?: string; code?: string }[] }).details ?? [];
402 const codes = details.map((d) => d.code).filter(Boolean) as string[];
403
404 expect(details.some((d) => d.questionId === byLabel("Req").id)).toBe(true);
405 expect(codes.some((c) => c.includes("max") || c.includes("range"))).toBe(true);
406 expect(details.some((d) => d.questionId === byLabel("Sel").id)).toBe(true);
407 const after = await app.prisma.formSubmission.count({ where: { formId } });
408 expect(after).toBe(before);
409 });
410
411 it("honeypot value → 400 generic, no submission", async () => {
412 const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]);
413 const { id: formId, slug } = await makeForm(headers, databaseId, "Honey", [
414 { type: "SHORT_TEXT", label: "T", propertyId: props.T },
415 ]);
416 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
417 const before = await app.prisma.formSubmission.count({ where: { formId } });
418 const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, {
419 body: { answers: {}, website: "http://spam.example" },
420 });
421 expect(res.status).toBe(400);
422 const msg = ((await json(res)) as { error: { message: string } }).error.message;
423 expect(msg).toBe("Submission failed. Please try again.");
424 const after = await app.prisma.formSubmission.count({ where: { formId } });
425 expect(after).toBe(before);
426 });
427
428 it("6th submit within the window → 429 with Retry-After", async () => {
429 const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }]);
430 const { id: formId, slug } = await makeForm(headers, databaseId, "Ratelimited", [
431 { type: "SHORT_TEXT", label: "T", propertyId: props.T },
432 ]);
433 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
434 const questions = await app.prisma.formQuestion.findMany({ where: { formId } });
435 const body = { answers: { [questions[0]!.id]: "x" } };
436 let last = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body });
437 for (let i = 0; i < 5; i++) {
438 last = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, { body });
439 }
440
441 expect(last.status).toBe(429);
442 expect(last.headers.get("Retry-After")).toBeTruthy();
443 expect(JSON.stringify(await json(last))).toContain("retryAfterSeconds");
444 });
445
446 it("FILE answer from another workspace → 422 FILE_NOT_OWNED; nothing persisted", async () => {
447 const { headers, databaseId, props } = await setupDb([{ name: "T", type: "TEXT" }, { name: "Files", type: "FILES" }]);
448 const { id: formId, slug } = await makeForm(headers, databaseId, "FileOwned", [
449 { type: "FILE", label: "Attach", propertyId: props.Files, validation: { maxFiles: 1 } },
450 ]);
451 await app.request("PATCH", `/api/v1/forms/${formId}`, { headers, body: { status: "PUBLISHED" } });
452
453 const attachQ = (await app.prisma.formQuestion.findFirst({ where: { formId } }))!;
454
455
456 const other = await createUser(app.request, { name: "Other" });
457 const ow = await createWorkspace(app.request, authHeaders(other.accessToken!));
458 const foreignFile = await app.prisma.file.create({
459 data: {
460 workspaceId: ow.workspaceId,
461 name: "evil.pdf",
462 key: `ws/${ow.workspaceId}/file_foreign.pdf`,
463 size: 10,
464 mimeType: "application/pdf",
465 status: "READY",
466 uploadedBy: other.userId,
467 metadata: { formId: "not-this-form" }
468 }
469 });
470 const before = await app.prisma.formSubmission.count({ where: { formId } });
471 const res = await app.request("POST", `/api/v1/public/forms/${slug}/submit`, {
472 body: { answers: { [attachQ.id]: [foreignFile.id] } },
473 });
474 expect(res.status).toBe(422);
475 expect(JSON.stringify(await json(res))).toContain("FILE_NOT_OWNED");
476 const after = await app.prisma.formSubmission.count({ where: { formId } });
477 expect(after).toBe(before);
478 });
479});
480