1
31import { expect, test, API_BASE, type APIRequestContext } from "../fixtures";
32
33const EVENTS_STREAM = "factory:events";
34
35
36
37
38
39
40async function createDatabaseViaPage(api: APIRequestContext, workspaceId: string, title: string): Promise<string> {
41 const res = await api.post(`/api/v1/workspaces/${workspaceId}/pages`, { data: { title, type: "database" } });
42 if (res.status() !== 201) throw new Error(`createDatabaseViaPage: ${res.status()} ${await res.text()}`);
43 return ((await res.json()) as { databaseId: string }).databaseId;
44}
45
46async function addProp(
47 api: APIRequestContext,
48 databaseId: string,
49 prop: { name: string; type: string; config?: unknown },
50): Promise<string> {
51 const res = await api.post(`/api/v1/databases/${databaseId}/properties`, { data: prop });
52 if (res.status() !== 201) throw new Error(`addProp ${prop.name}: ${res.status()} ${await res.text()}`);
53 return ((await res.json()) as { id: string }).id;
54}
55
56async function addQuestion(api: APIRequestContext, formId: string, body: Record<string, unknown>): Promise<string> {
57 const res = await api.post(`/api/v1/forms/${formId}/questions`, { data: body });
58 if (res.status() !== 201) throw new Error(`addQuestion ${body.label}: ${res.status()} ${await res.text()}`);
59 return ((await res.json()) as { id: string }).id;
60}
61
62
63async function records(api: APIRequestContext, databaseId: string, limit = 100) {
64 const res = await api.get(`/api/v1/databases/${databaseId}/records?limit=${limit}`);
65 if (res.status() !== 200) throw new Error(`records: ${res.status()} ${await res.text()}`);
66 return ((await res.json()) as { data: { id: string; properties: Record<string, unknown> }[] }).data;
67}
68
69
77async function captureStreamTailId(): Promise<string> {
78 const { default: Redis } = await import("ioredis");
79 const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
80 maxRetriesPerRequest: 2,
81 lazyConnect: true,
82 });
83 try {
84 await redis.connect().catch(() => {
85 throw new Error("captureStreamTailId: cannot connect to Redis");
86 });
87 const last = (await redis.xrevrange(EVENTS_STREAM, "+", "-", "COUNT", 1)) as [string, string[]][];
88 return last.length ? last[0][0] : "-";
89 } finally {
90 await redis.quit().catch(() => {});
91 }
92}
93
94
95async function readFormSubmittedEvents(
96 formId: string,
97 sinceId: string,
98 { timeoutMs = 10_000, pollMs = 250 }: { timeoutMs?: number; pollMs?: number } = {},
99): Promise<Record<string, unknown>[]> {
100 const { default: Redis } = await import("ioredis");
101 const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", {
102 maxRetriesPerRequest: 2,
103 lazyConnect: true,
104 });
105 try {
106 await redis.connect().catch(() => {
107 throw new Error("readFormSubmittedEvents: cannot connect to Redis");
108 });
109 const lo = sinceId && sinceId !== "-" ? `(${sinceId}` : "-";
110 const out: Record<string, unknown>[] = [];
111 const deadline = Date.now() + timeoutMs;
112 while (true) {
113 const raw = (await redis.xrange(EVENTS_STREAM, lo, "+")) as [string, string[]][];
114 for (const [, fields] of raw) {
115 const map: Record<string, string> = {};
116 for (let i = 0; i < fields.length; i += 2) map[fields[i]] = fields[i + 1];
117 if (map.event !== "form.submitted") continue;
118 let p: Record<string, unknown>;
119 try {
120 p = JSON.parse(map.payload ?? "{}");
121 } catch {
122 continue;
123 }
124 if (p.formId === formId && !out.includes(p)) out.push(p);
125 }
126 if (out.length || Date.now() >= deadline) return out;
127 await new Promise((r) => setTimeout(r, pollMs));
128 }
129 } finally {
130 await redis.quit().catch(() => {});
131 }
132}
133
134
135
136
137
138test("forms lifecycle (a–g): create → publish → public submit → record+response+event → invalid → close → delete", async ({
139 browser,
140 api,
141 authPage,
142 freshWorkspace,
143}) => {
144
145
146 const ws = await freshWorkspace("Forms Lifecycle");
147 const dbId = await createDatabaseViaPage(api, ws.id, "Feedback");
148
149 const db = (await (await api.get(`/api/v1/databases/${dbId}`)).json()) as {
150 properties: { id: string; isTitle: boolean }[];
151 };
152 const titlePropId = db.properties.find((p) => p.isTitle)?.id;
153 if (!titlePropId) throw new Error("seeded database has no Title property");
154 const ratingPropId = await addProp(api, dbId, { name: "Rating", type: "NUMBER" });
155 const categoryPropId = await addProp(api, dbId, {
156 name: "Category",
157 type: "SELECT",
158 config: { options: [{ name: "Praise" }, { name: "Complaint" }, { name: "Other" }] },
159 });
160 const emailPropId = await addProp(api, dbId, { name: "Contact email", type: "EMAIL" });
161
162 const formRes = await api.post(`/api/v1/databases/${dbId}/forms`, {
163 data: { name: "Feedback", description: "Tell us how we did", config: { intro: "Thanks for helping us improve." } },
164 });
165 expect(formRes.status(), await formRes.text()).toBe(201);
166 const { id: formId, slug } = (await formRes.json()) as { id: string; slug: string };
167
168 const titleQid = await addQuestion(api, formId, {
169 type: "SHORT_TEXT",
170 label: "What did you use?",
171 required: true,
172 propertyId: titlePropId,
173 });
174 const ratingQid = await addQuestion(api, formId, {
175 type: "RATING",
176 label: "Rate us",
177 required: true,
178 propertyId: ratingPropId,
179 validation: { max: 5 },
180 });
181 const categoryQid = await addQuestion(api, formId, {
182 type: "SINGLE_SELECT",
183 label: "Category",
184 required: true,
185 propertyId: categoryPropId,
186 validation: { options: ["Praise", "Complaint", "Other"] },
187 });
188 const emailQid = await addQuestion(api, formId, {
189 type: "EMAIL",
190 label: "Contact email",
191 required: true,
192 propertyId: emailPropId,
193 });
194
195 const publish = await api.patch(`/api/v1/forms/${formId}`, { data: { status: "PUBLISHED" } });
196 expect(publish.status(), await publish.text()).toBe(200);
197
198
199
200
201 const viewRes = await api.post(`/api/v1/databases/${dbId}/views`, {
202 data: { name: "Form", type: "FORM", config: { formId } },
203 });
204 expect(viewRes.status(), await viewRes.text()).toBe(201);
205 const view = (await viewRes.json()) as { id: string; type: string };
206 expect(view.type).toBe("FORM");
207 const formViewUrl = `/w/${ws.id}/databases/${dbId}?view=${view.id}`;
208
209
210
211 await authPage.goto(formViewUrl);
212 await expect(authPage.getByTestId("form-view-tab-builder")).toBeVisible({ timeout: 20_000 });
213 await expect(authPage.getByTestId("form-view-tab-responses")).toBeVisible();
214 await expect(authPage.getByText("What did you use?").first()).toBeVisible();
215
216
217
218
219 const submitStreamTail = await captureStreamTailId();
220 const anon = await (await browser.newContext()).newPage();
221 await anon.goto(`/f/${slug}`);
222 await expect(anon.getByText("What did you use?")).toBeVisible({ timeout: 15_000 });
223 await anon.getByLabel("What did you use?").fill("The new search feature");
224 await anon.getByRole("button", { name: "4 of 5" }).click();
225 await anon.getByLabel("Category").selectOption("Praise");
226 await anon.getByLabel("Contact email").fill("anon@example.com");
227 await anon.getByRole("button", { name: "Submit", exact: true }).click();
228 await expect(anon.getByText(/Thanks!|response was recorded/i).first()).toBeVisible({ timeout: 15_000 });
229
230
231
232 await expect
233 .poll(async () => (await records(api, dbId)).length, { timeout: 15_000 })
234 .toBe(1);
235 const [rec] = await records(api, dbId);
236 expect(rec.properties[titlePropId]).toMatchObject({ text: "The new search feature" });
237 expect(rec.properties[ratingPropId]).toMatchObject({ value: 4 });
238 expect(rec.properties[categoryPropId]).toMatchObject({ optionId: "Praise" });
239 expect(rec.properties[emailPropId]).toMatchObject({ value: "anon@example.com" });
240
241 await authPage.goto(formViewUrl);
242 await authPage.getByTestId("form-view-tab-responses").click();
243 const respRow = authPage.getByTestId("form-response-row").first();
244 await expect(respRow).toBeVisible({ timeout: 15_000 });
245 await expect(respRow).toContainText("Anonymous");
246
247
248 await expect(respRow).toContainText(/\*{3}|\d{1,3}\.\d{1,3}/);
249
250 await expect(respRow).toContainText("The new search feature");
251
252
253 const events = await readFormSubmittedEvents(formId, submitStreamTail);
254 expect(events.length, "at least one form.submitted event").toBeGreaterThanOrEqual(1);
255 const ev = events[events.length - 1];
256 expect(ev.anonymous).toBe(true);
257 expect(ev.recordId, "recordId present in payload").toBe(rec.id);
258
259
260 const before = (await records(api, dbId)).length;
261
262
263
264 const bad = await anon.request.post(`${API_BASE}/api/v1/public/forms/${slug}/submit`, {
265 data: {
266 answers: {
267 [titleQid]: "bad submit",
268 [ratingQid]: 9,
269 [categoryQid]: "Praise",
270 [emailQid]: "",
271 },
272 },
273 });
274 expect(bad.status(), await bad.text()).toBe(422);
275 const badBody = (await bad.json()) as { error?: { details?: { code: string }[] } };
276 const codes = (badBody.error?.details ?? []).map((d) => d.code);
277 expect(codes, "rating 'range' violation").toContain("range");
278
279
280 expect(codes, "missing email violation").toContain("required");
281 expect((await records(api, dbId)).length, "no record created on invalid submit").toBe(before);
282
283
284 await anon.goto(`/f/${slug}`);
285 await anon.getByLabel("What did you use?").fill("ui invalid");
286 await anon.getByRole("button", { name: "5 of 5" }).click();
287 await anon.getByLabel("Category").selectOption("Other");
288 await anon.getByLabel("Contact email").fill("not-an-email");
289 await anon.getByRole("button", { name: "Submit", exact: true }).click();
290 await expect(anon.getByTestId("form-submit-field-errors")).toBeVisible({ timeout: 15_000 });
291 await expect(anon.getByTestId("form-submit-field-errors")).toContainText(/email/i);
292 expect((await records(api, dbId)).length, "still nothing created").toBe(before);
293
294
295 const close = await api.patch(`/api/v1/forms/${formId}`, { data: { status: "CLOSED" } });
296 expect(close.status(), await close.text()).toBe(200);
297 const closed = await (await browser.newContext()).newPage();
298 await closed.goto(`/f/${slug}`);
299 await expect(closed.getByText(/Form closed/i)).toBeVisible({ timeout: 15_000 });
300 const closedSubmit = await closed.request.post(`${API_BASE}/api/v1/public/forms/${slug}/submit`, { data: { answers: {} } });
301 expect(closedSubmit.status(), await closedSubmit.text()).toBe(403);
302 await closed.context().close().catch(() => {});
303
304
305 await authPage.goto(formViewUrl);
306 await authPage.getByTestId("form-view-tab-responses").click();
307 await expect(authPage.getByTestId("form-response-row").first()).toBeVisible({ timeout: 15_000 });
308 await authPage.getByTestId("form-response-delete").first().click();
309 await authPage.getByRole("button", { name: "Delete", exact: true }).click();
310 await expect
311 .poll(async () => ((await (await api.get(`/api/v1/forms/${formId}/submissions`)).json()) as { items: unknown[] }).items.length, {
312 timeout: 15_000,
313 })
314 .toBe(0);
315 expect((await records(api, dbId)).length, "record cascaded away").toBe(0);
316
317 await anon.context().close().catch(() => {});
318});
319
320
321
322
323
324test("responses: mobile cards render and the windowed list keeps DOM rows < 30 while scrolling", async ({
325 api,
326 authPage,
327 freshWorkspace,
328}) => {
329 const ws = await freshWorkspace("Forms Mobile");
330 const dbId = await createDatabaseViaPage(api, ws.id, "Feedback");
331
332 const formRes = await api.post(`/api/v1/databases/${dbId}/forms`, { data: { name: "Mobile" } });
333 const form = (await formRes.json()) as { id: string };
334 const qid = await addQuestion(api, form.id, { type: "SHORT_TEXT", label: "Feedback", required: true, propertyId: null });
335 await api.patch(`/api/v1/forms/${form.id}`, { data: { status: "PUBLISHED" } });
336 const viewRes = await api.post(`/api/v1/databases/${dbId}/views`, {
337 data: { name: "Form", type: "FORM", config: { formId: form.id } },
338 });
339 const view = (await viewRes.json()) as { id: string };
340
341
342
343
344
345 const { PrismaClient } = await import("@prisma/client");
346 const prisma = new PrismaClient();
347 try {
348 await prisma.formSubmission.createMany({
349 data: Array.from({ length: 40 }, (_, i) => ({
350 workspaceId: ws.id,
351 formId: form.id,
352 recordId: null,
353 submittedBy: null,
354 answers: { [qid]: `response number ${i}` },
355 ipAddress: `10.0.0.${i}`,
356 createdAt: new Date(Date.now() - (40 - i) * 60_000),
357 })),
358 });
359 } finally {
360 await prisma.$disconnect();
361 }
362 const subs = (await (await api.get(`/api/v1/forms/${form.id}/submissions?limit=100`)).json()) as {
363 items: unknown[];
364 };
365 expect(subs.items.length).toBe(40);
366
367
368
369 await authPage.setViewportSize({ width: 375, height: 812 });
370 await authPage.goto(`/w/${ws.id}/databases/${dbId}?view=${view.id}`);
371 await authPage.getByTestId("form-view-tab-responses").click();
372 const row = authPage.getByTestId("form-response-row");
373 await expect(row.first()).toBeVisible({ timeout: 20_000 });
374
375 await expect(row.first()).toContainText("response number 39");
376 const before = await row.count();
377 expect(before, "windowed: rendered rows < 30").toBeGreaterThan(0);
378 expect(before, "windowed: rendered rows < 30").toBeLessThan(30);
379
380
381
382
383 await authPage.getByTestId("form-responses-scroll").evaluate((el) => {
384 el.scrollTop = el.scrollHeight;
385 });
386 await expect(row.last()).toContainText("response number 0", { timeout: 10_000 });
387 const after = await row.count();
388 expect(after, "windowed after scroll: still < 30").toBeLessThan(30);
389});
390