/** * TASK-0109 — Forms E2E: the full lifecycle (a–g) + mobile responses cards. * * The ROADMAP Phase-10 exit criterion, proven end to end: a public, auth-less * submission creates a database record (with the answer→property mapping), * fires `form.submitted`, and validation rejects bad input server-side. * * Setup is API-driven (deterministic) for the record/event assertions and * UI-driven for the Phase-10 surfaces: * - the public /f/ page + submit (TASK-0107/0108) — UI; * - the FORM view → Builder / Responses tabs (TASK-0106/0109) — UI; * - the responses row, delete + confirm, mobile cards (TASK-0109) — UI; * - `form.submitted` — read straight off the Redis `factory:events` stream * (the Phase-11 automation-stub log does not exist yet — the task's * documented fallback, since the test-only debug endpoint is NODE_ENV=test * only and the e2e stack runs in production mode). * * Recorded (not silently fixed — see docs/tasks/phase-10-forms.md §Conflicts): * - The pre-existing `createDatabase` helper in ../utils/api.ts calls the * nonexistent `POST /workspaces/:id/databases` route, so this spec creates * the database via the API's only real path — `POST /workspaces/:id/pages * { type: "database" }` (+ `POST /databases/:id/properties` for extra cols). * - The web's database record surface calls `POST /databases/:id/query`, * which the API has NOT implemented (404) — so the table view cannot load * records in-browser. The record + its answer→property mapping are asserted * via `GET /databases/:id/records` instead (same engine, the GET variant). * - `GET /workspaces/:id` resolves by ID only (the web's `useWorkspace(slug)` * 403s on a slug and bounces to /w/default), so the spec navigates with the * workspace ID. */ import { expect, test, API_BASE, type APIRequestContext } from "../fixtures"; const EVENTS_STREAM = "factory:events"; // apps/api/src/constants.ts // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Create a database via the real endpoint (the utils helper is broken — see header). */ async function createDatabaseViaPage(api: APIRequestContext, workspaceId: string, title: string): Promise { const res = await api.post(`/api/v1/workspaces/${workspaceId}/pages`, { data: { title, type: "database" } }); if (res.status() !== 201) throw new Error(`createDatabaseViaPage: ${res.status()} ${await res.text()}`); return ((await res.json()) as { databaseId: string }).databaseId; } async function addProp( api: APIRequestContext, databaseId: string, prop: { name: string; type: string; config?: unknown }, ): Promise { const res = await api.post(`/api/v1/databases/${databaseId}/properties`, { data: prop }); if (res.status() !== 201) throw new Error(`addProp ${prop.name}: ${res.status()} ${await res.text()}`); return ((await res.json()) as { id: string }).id; } async function addQuestion(api: APIRequestContext, formId: string, body: Record): Promise { const res = await api.post(`/api/v1/forms/${formId}/questions`, { data: body }); if (res.status() !== 201) throw new Error(`addQuestion ${body.label}: ${res.status()} ${await res.text()}`); return ((await res.json()) as { id: string }).id; } /** Record count via the query endpoint (response is `{ data, nextCursor }`). */ async function records(api: APIRequestContext, databaseId: string, limit = 100) { const res = await api.get(`/api/v1/databases/${databaseId}/records?limit=${limit}`); if (res.status() !== 200) throw new Error(`records: ${res.status()} ${await res.text()}`); return ((await res.json()) as { data: { id: string; properties: Record }[] }).data; } /** * The stream is capped (~10000) and flooded with `page.updated`, so a fixed * tail window + single read is unreliable: a `form.submitted` appended * post-commit can be pushed out of the window before we look. Instead, capture * the tail stream-id BEFORE the submit and poll only entries added after it * (`xrange (beforeId +`). The event lands within ~100ms of commit, so this is * both deterministic and cheap regardless of the flood. */ async function captureStreamTailId(): Promise { const { default: Redis } = await import("ioredis"); const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", { maxRetriesPerRequest: 2, lazyConnect: true, }); try { await redis.connect().catch(() => { throw new Error("captureStreamTailId: cannot connect to Redis"); }); const last = (await redis.xrevrange(EVENTS_STREAM, "+", "-", "COUNT", 1)) as [string, string[]][]; return last.length ? last[0][0] : "-"; } finally { await redis.quit().catch(() => {}); } } /** Poll for `form.submitted` events for a form added after `sinceId`. */ async function readFormSubmittedEvents( formId: string, sinceId: string, { timeoutMs = 10_000, pollMs = 250 }: { timeoutMs?: number; pollMs?: number } = {}, ): Promise[]> { const { default: Redis } = await import("ioredis"); const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379", { maxRetriesPerRequest: 2, lazyConnect: true, }); try { await redis.connect().catch(() => { throw new Error("readFormSubmittedEvents: cannot connect to Redis"); }); const lo = sinceId && sinceId !== "-" ? `(${sinceId}` : "-"; const out: Record[] = []; const deadline = Date.now() + timeoutMs; while (true) { const raw = (await redis.xrange(EVENTS_STREAM, lo, "+")) as [string, string[]][]; for (const [, fields] of raw) { const map: Record = {}; for (let i = 0; i < fields.length; i += 2) map[fields[i]] = fields[i + 1]; if (map.event !== "form.submitted") continue; let p: Record; try { p = JSON.parse(map.payload ?? "{}"); } catch { continue; } if (p.formId === formId && !out.includes(p)) out.push(p); } if (out.length || Date.now() >= deadline) return out; await new Promise((r) => setTimeout(r, pollMs)); } } finally { await redis.quit().catch(() => {}); } } // --------------------------------------------------------------------------- // (a)–(g) — full form lifecycle // --------------------------------------------------------------------------- test("forms lifecycle (a–g): create → publish → public submit → record+response+event → invalid → close → delete", async ({ browser, api, authPage, freshWorkspace, }) => { // ---- (a) owner creates a Feedback DB (Title + Rating + Category + EMAIL), a // form mapping all four, publishes it, and adds a FORM view to host it. ---- const ws = await freshWorkspace("Forms Lifecycle"); const dbId = await createDatabaseViaPage(api, ws.id, "Feedback"); const db = (await (await api.get(`/api/v1/databases/${dbId}`)).json()) as { properties: { id: string; isTitle: boolean }[]; }; const titlePropId = db.properties.find((p) => p.isTitle)?.id; if (!titlePropId) throw new Error("seeded database has no Title property"); const ratingPropId = await addProp(api, dbId, { name: "Rating", type: "NUMBER" }); const categoryPropId = await addProp(api, dbId, { name: "Category", type: "SELECT", config: { options: [{ name: "Praise" }, { name: "Complaint" }, { name: "Other" }] }, }); const emailPropId = await addProp(api, dbId, { name: "Contact email", type: "EMAIL" }); const formRes = await api.post(`/api/v1/databases/${dbId}/forms`, { data: { name: "Feedback", description: "Tell us how we did", config: { intro: "Thanks for helping us improve." } }, }); expect(formRes.status(), await formRes.text()).toBe(201); const { id: formId, slug } = (await formRes.json()) as { id: string; slug: string }; const titleQid = await addQuestion(api, formId, { type: "SHORT_TEXT", label: "What did you use?", required: true, propertyId: titlePropId, }); const ratingQid = await addQuestion(api, formId, { type: "RATING", label: "Rate us", required: true, propertyId: ratingPropId, validation: { max: 5 }, }); const categoryQid = await addQuestion(api, formId, { type: "SINGLE_SELECT", label: "Category", required: true, propertyId: categoryPropId, validation: { options: ["Praise", "Complaint", "Other"] }, }); const emailQid = await addQuestion(api, formId, { type: "EMAIL", label: "Contact email", required: true, propertyId: emailPropId, }); const publish = await api.patch(`/api/v1/forms/${formId}`, { data: { status: "PUBLISHED" } }); expect(publish.status(), await publish.text()).toBe(200); // A FORM view (the API ViewType is UPPERCASE; the web normalizes to lowercase // on read — use-database.ts). The URL uses the workspace ID (GET // /workspaces/:id resolves by id only — slug 403s; see header). const viewRes = await api.post(`/api/v1/databases/${dbId}/views`, { data: { name: "Form", type: "FORM", config: { formId } }, }); expect(viewRes.status(), await viewRes.text()).toBe(201); const view = (await viewRes.json()) as { id: string; type: string }; expect(view.type).toBe("FORM"); const formViewUrl = `/w/${ws.id}/databases/${dbId}?view=${view.id}`; // UI sanity: the FORM view renders the Builder + Responses tabs and the // builder lists the four questions. await authPage.goto(formViewUrl); await expect(authPage.getByTestId("form-view-tab-builder")).toBeVisible({ timeout: 20_000 }); await expect(authPage.getByTestId("form-view-tab-responses")).toBeVisible(); await expect(authPage.getByText("What did you use?").first()).toBeVisible(); // ---- (b) incognito (auth-less) opens /f/ and submits a valid form. ---- // Capture the stream tail NOW so (d) only sees entries added by THIS submit // (the stream is capped + flooded; a fixed tail window is unreliable). const submitStreamTail = await captureStreamTailId(); const anon = await (await browser.newContext()).newPage(); await anon.goto(`/f/${slug}`); await expect(anon.getByText("What did you use?")).toBeVisible({ timeout: 15_000 }); await anon.getByLabel("What did you use?").fill("The new search feature"); await anon.getByRole("button", { name: "4 of 5" }).click(); // rating = 4 await anon.getByLabel("Category").selectOption("Praise"); await anon.getByLabel("Contact email").fill("anon@example.com"); await anon.getByRole("button", { name: "Submit", exact: true }).click(); await expect(anon.getByText(/Thanks!|response was recorded/i).first()).toBeVisible({ timeout: 15_000 }); // ---- (c) the record appears with the answer→property mapping + the // responses view lists the submission (Anonymous submitter + ip). ---- await expect .poll(async () => (await records(api, dbId)).length, { timeout: 15_000 }) .toBe(1); const [rec] = await records(api, dbId); expect(rec.properties[titlePropId]).toMatchObject({ text: "The new search feature" }); expect(rec.properties[ratingPropId]).toMatchObject({ value: 4 }); expect(rec.properties[categoryPropId]).toMatchObject({ optionId: "Praise" }); expect(rec.properties[emailPropId]).toMatchObject({ value: "anon@example.com" }); await authPage.goto(formViewUrl); await authPage.getByTestId("form-view-tab-responses").click(); const respRow = authPage.getByTestId("form-response-row").first(); await expect(respRow).toBeVisible({ timeout: 15_000 }); await expect(respRow).toContainText("Anonymous"); // An ip is present — full for the OWNER viewer (masked only for Members; that // split is asserted by the integration suite). Accept either form. await expect(respRow).toContainText(/\*{3}|\d{1,3}\.\d{1,3}/); // The answers are shown on the row. await expect(respRow).toContainText("The new search feature"); // ---- (d) form.submitted observed on the Redis stream (recordId + anonymous). ---- const events = await readFormSubmittedEvents(formId, submitStreamTail); expect(events.length, "at least one form.submitted event").toBeGreaterThanOrEqual(1); const ev = events[events.length - 1]; expect(ev.anonymous).toBe(true); expect(ev.recordId, "recordId present in payload").toBe(rec.id); // ---- (e) invalid submit: rating above max + missing email → 422, nothing created. ---- const before = (await records(api, dbId)).length; // Absolute URL: `anon.request` inherits the page context's baseURL (the WEB // origin), where `/api/v1/...` is not a Next route and bounces to login. // Point the public submit straight at the API origin. const bad = await anon.request.post(`${API_BASE}/api/v1/public/forms/${slug}/submit`, { data: { answers: { [titleQid]: "bad submit", [ratingQid]: 9, // > max 5 [categoryQid]: "Praise", [emailQid]: "", // missing email }, }, }); expect(bad.status(), await bad.text()).toBe(422); const badBody = (await bad.json()) as { error?: { details?: { code: string }[] } }; const codes = (badBody.error?.details ?? []).map((d) => d.code); expect(codes, "rating 'range' violation").toContain("range"); // An EMPTY email trips the basic `required` check before the `email` FORMAT // check (that one is asserted in the UI test below with a malformed value). expect(codes, "missing email violation").toContain("required"); expect((await records(api, dbId)).length, "no record created on invalid submit").toBe(before); // UI surface: an invalid email format renders a server field error. await anon.goto(`/f/${slug}`); await anon.getByLabel("What did you use?").fill("ui invalid"); await anon.getByRole("button", { name: "5 of 5" }).click(); await anon.getByLabel("Category").selectOption("Other"); await anon.getByLabel("Contact email").fill("not-an-email"); await anon.getByRole("button", { name: "Submit", exact: true }).click(); await expect(anon.getByTestId("form-submit-field-errors")).toBeVisible({ timeout: 15_000 }); await expect(anon.getByTestId("form-submit-field-errors")).toContainText(/email/i); expect((await records(api, dbId)).length, "still nothing created").toBe(before); // ---- (f) owner closes the form → incognito sees closed; submit attempt → 403. ---- const close = await api.patch(`/api/v1/forms/${formId}`, { data: { status: "CLOSED" } }); expect(close.status(), await close.text()).toBe(200); const closed = await (await browser.newContext()).newPage(); await closed.goto(`/f/${slug}`); await expect(closed.getByText(/Form closed/i)).toBeVisible({ timeout: 15_000 }); const closedSubmit = await closed.request.post(`${API_BASE}/api/v1/public/forms/${slug}/submit`, { data: { answers: {} } }); expect(closedSubmit.status(), await closedSubmit.text()).toBe(403); await closed.context().close().catch(() => {}); // ---- (g) owner deletes the submission (confirm) → record + submission gone. ---- await authPage.goto(formViewUrl); await authPage.getByTestId("form-view-tab-responses").click(); await expect(authPage.getByTestId("form-response-row").first()).toBeVisible({ timeout: 15_000 }); await authPage.getByTestId("form-response-delete").first().click(); await authPage.getByRole("button", { name: "Delete", exact: true }).click(); await expect .poll(async () => ((await (await api.get(`/api/v1/forms/${formId}/submissions`)).json()) as { items: unknown[] }).items.length, { timeout: 15_000, }) .toBe(0); expect((await records(api, dbId)).length, "record cascaded away").toBe(0); await anon.context().close().catch(() => {}); }); // --------------------------------------------------------------------------- // Mobile responses cards + windowing (TASK-0109 DoD) // --------------------------------------------------------------------------- test("responses: mobile cards render and the windowed list keeps DOM rows < 30 while scrolling", async ({ api, authPage, freshWorkspace, }) => { const ws = await freshWorkspace("Forms Mobile"); const dbId = await createDatabaseViaPage(api, ws.id, "Feedback"); const formRes = await api.post(`/api/v1/databases/${dbId}/forms`, { data: { name: "Mobile" } }); const form = (await formRes.json()) as { id: string }; const qid = await addQuestion(api, form.id, { type: "SHORT_TEXT", label: "Feedback", required: true, propertyId: null }); await api.patch(`/api/v1/forms/${form.id}`, { data: { status: "PUBLISHED" } }); const viewRes = await api.post(`/api/v1/databases/${dbId}/views`, { data: { name: "Form", type: "FORM", config: { formId: form.id } }, }); const view = (await viewRes.json()) as { id: string }; // Seed 40 submissions DIRECTLY via Prisma — the public submit endpoint is // rate-limited to 5/min per slug+ip, which a local run would trip long before // 40 rows. recordId/submittedBy are nullable, so no records are needed and // the rows still exercise the full list/view path (labels, ip, sort, window). const { PrismaClient } = await import("@prisma/client"); const prisma = new PrismaClient(); try { await prisma.formSubmission.createMany({ data: Array.from({ length: 40 }, (_, i) => ({ workspaceId: ws.id, formId: form.id, recordId: null, submittedBy: null, answers: { [qid]: `response number ${i}` }, ipAddress: `10.0.0.${i}`, createdAt: new Date(Date.now() - (40 - i) * 60_000), // i=39 is newest })), }); } finally { await prisma.$disconnect(); } const subs = (await (await api.get(`/api/v1/forms/${form.id}/submissions?limit=100`)).json()) as { items: unknown[]; }; expect(subs.items.length).toBe(40); // Mobile viewport (375px) — the responses surface switches to fixed-height // answer cards, windowed to <30 DOM rows. await authPage.setViewportSize({ width: 375, height: 812 }); await authPage.goto(`/w/${ws.id}/databases/${dbId}?view=${view.id}`); await authPage.getByTestId("form-view-tab-responses").click(); const row = authPage.getByTestId("form-response-row"); await expect(row.first()).toBeVisible({ timeout: 20_000 }); // Newest-first: the top card is the newest ("response number 39"). await expect(row.first()).toContainText("response number 39"); const before = await row.count(); expect(before, "windowed: rendered rows < 30").toBeGreaterThan(0); expect(before, "windowed: rendered rows < 30").toBeLessThan(30); // Scroll the bounded container to the bottom: the window slides so the // OLDEST card ("response number 0") becomes the last rendered row, and the // DOM still holds <30 cards — not all 40. await authPage.getByTestId("form-responses-scroll").evaluate((el) => { el.scrollTop = el.scrollHeight; }); await expect(row.last()).toContainText("response number 0", { timeout: 10_000 }); const after = await row.count(); expect(after, "windowed after scroll: still < 30").toBeLessThan(30); });