diff --git a/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx b/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx new file mode 100644 index 00000000..a0279708 --- /dev/null +++ b/canvas/src/app/waitlist/__tests__/waitlist-page.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +/** + * Tests for /waitlist — the contact form page shown to users rejected + * by the beta-gate (PR #150 backend + #? frontend). + * + * This page is a user's ONLY path to request access after the CP + * rejects their login, so regressions here strand every new user. + * Covers: + * - Form renders with required email field + * - Client-side validation rejects empty / malformed emails + * - Successful POST → success banner, form hidden + * - dedup=true response → softer "already on file" banner + * - Non-2xx response → error banner with server message + * - Network error → error banner with fallback message + * - email NEVER appears in URL (regression guard — pre-fix the CP + * redirect passed ?email= which leaked to referrer headers) + * - Body is normalized (trim email) before submit + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + waitFor, + cleanup, + fireEvent, +} from "@testing-library/react"; + +vi.mock("@/lib/api", () => ({ + PLATFORM_URL: "https://cp.test", +})); + +const mockFetch = vi.fn(); +globalThis.fetch = mockFetch as unknown as typeof fetch; + +import WaitlistPage from "../page"; + +function okJson(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as unknown as Response; +} + +beforeEach(() => { + vi.clearAllMocks(); + // Reset URL so the URL-leak guard sees a clean query string. + window.history.pushState({}, "", "/waitlist"); +}); + +afterEach(() => { + cleanup(); +}); + +describe("/waitlist — page render", () => { + it("renders the form with an email field by default", () => { + render(); + expect(screen.getByRole("heading", { name: /waitlist/i })).toBeTruthy(); + expect(screen.getByLabelText(/email/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /request access/i })).toBeTruthy(); + // No success/dedup banners at rest. + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("does NOT pre-fill email from URL query (privacy regression guard)", () => { + // Pre-fix, the CP redirected to /waitlist?email=. + // Even though the backend no longer does that, a bookmark or a + // cached redirect could still hand us one. The page must not + // auto-read it. + window.history.pushState({}, "", "/waitlist?email=leaked@example.com"); + render(); + const input = screen.getByLabelText(/email/i) as HTMLInputElement; + expect(input.value).toBe(""); + }); +}); + +describe("/waitlist — client-side validation", () => { + it("rejects an empty email without calling the API", async () => { + render(); + fireEvent.submit(screen.getByRole("button", { name: /request access/i }).closest("form")!); + // HTML5 required attribute handles this before our JS runs — so + // no fetch happens. This test documents the contract that invalid + // submissions never hit the network. + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects a malformed email with an inline error and no fetch", async () => { + render(); + // Type something that passes HTML5 required but fails our @-check. + // We bypass input validation by setting the value directly through + // the onChange handler (jsdom's native email type accepts "noat"). + const input = screen.getByLabelText(/email/i); + fireEvent.change(input, { target: { value: "noat" } }); + const form = screen.getByRole("button", { name: /request access/i }).closest("form")!; + fireEvent.submit(form); + await waitFor(() => { + expect(screen.getByRole("alert")).toBeTruthy(); + }); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("/waitlist — submit happy path", () => { + it("POSTs a trimmed body and shows a success banner on {ok:true}", async () => { + mockFetch.mockResolvedValueOnce(okJson({ ok: true, id: "req-abc" })); + render(); + + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: " hi@example.com " }, + }); + fireEvent.change(screen.getByLabelText(/^name$/i), { + target: { value: "Hongming" }, + }); + fireEvent.change(screen.getByLabelText(/what would you build/i), { + target: { value: "research automation" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + // URL and body shape. + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://cp.test/cp/waitlist/request"); + expect(init.method).toBe("POST"); + const body = JSON.parse(init.body as string); + expect(body).toEqual({ + email: "hi@example.com", // trimmed + name: "Hongming", + use_case: "research automation", + }); + + await waitFor(() => { + expect(screen.getByRole("status").textContent).toMatch(/your request is in/i); + }); + }); +}); + +describe("/waitlist — submit dedup", () => { + it("shows a softer banner when backend returns dedup=true", async () => { + mockFetch.mockResolvedValueOnce(okJson({ ok: true, dedup: true })); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "existing@example.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("status").textContent).toMatch(/already have your request/i); + }); + }); +}); + +describe("/waitlist — submit error paths", () => { + it("shows the server's error message on a non-2xx response", async () => { + mockFetch.mockResolvedValueOnce(okJson({ error: "email required" }, 400)); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/email required/i); + }); + }); + + it("falls back to a generic message when the response has no error field", async () => { + mockFetch.mockResolvedValueOnce( + okJson({}, 500) // no `error` key + ); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/500/); + }); + }); + + it("shows an error banner on network failure", async () => { + mockFetch.mockRejectedValueOnce(new Error("TCP reset")); + render(); + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: "x@y.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: /request access/i })); + await waitFor(() => { + expect(screen.getByRole("alert").textContent).toMatch(/TCP reset/); + }); + }); +}); diff --git a/canvas/src/app/waitlist/page.tsx b/canvas/src/app/waitlist/page.tsx new file mode 100644 index 00000000..94f95828 --- /dev/null +++ b/canvas/src/app/waitlist/page.tsx @@ -0,0 +1,189 @@ +"use client"; + +// /waitlist — the page shown to users whose email isn't on the +// private-beta allowlist. The CP auth callback redirects here (no +// session cookie set) after rejecting the sign-in. +// +// The page offers a contact form that POSTs to +// /cp/waitlist/request with the user's email + optional name and +// use-case. The CP stores the row in beta_requests; ops triages +// via GET /cp/admin/beta-requests and moves approved emails over +// to beta_allowlist manually. +// +// No session required — the whole point is that the user isn't +// authenticated yet. Per CLAUDE.md privacy rule, we don't read the +// email from a URL query param; user re-enters it into the form. + +import { useState } from "react"; +import { PLATFORM_URL } from "@/lib/api"; + +type SubmitState = "idle" | "submitting" | "success" | "dedup" | "error"; + +interface SubmitResponse { + ok: boolean; + id?: string; + dedup?: boolean; + error?: string; +} + +export default function WaitlistPage() { + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [useCase, setUseCase] = useState(""); + const [state, setState] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(""); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + // Client-side sanity. CP enforces the same checks — these exist + // so the user gets instant feedback without a round trip. + const trimmed = email.trim(); + if (!trimmed || !trimmed.includes("@")) { + setState("error"); + setErrorMsg("Please enter a valid email address."); + return; + } + setState("submitting"); + setErrorMsg(""); + try { + const res = await fetch(`${PLATFORM_URL}/cp/waitlist/request`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: trimmed, + name: name.trim(), + use_case: useCase.trim(), + }), + }); + const body = (await res.json().catch(() => ({}))) as SubmitResponse; + if (!res.ok || !body.ok) { + setState("error"); + setErrorMsg(body.error || `Request failed (${res.status}). Please try again.`); + return; + } + // Backend returns dedup=true when this email was already + // submitted within the last hour. Same 200, softer message. + setState(body.dedup ? "dedup" : "success"); + } catch (err) { + setState("error"); + setErrorMsg(err instanceof Error ? err.message : "Network error. Please try again."); + } + } + + return ( +
+
+

+ You’re on the waitlist +

+

+ Molecule AI is in private beta while we harden the platform. Tell us + a bit about yourself and we’ll reach out when a spot opens. +

+ + {state === "success" && ( +
+ Thanks — your request is in. We’ll email{" "} + {email} when access opens up. +
+ )} + + {state === "dedup" && ( +
+ We already have your request on file for{" "} + {email}. No need to resubmit — + we’ll be in touch. +
+ )} + + {state !== "success" && state !== "dedup" && ( +
+
+ + setEmail(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="you@company.com" + autoComplete="email" + /> +
+ +
+ + setName(e.target.value)} + className="mt-1 block w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-zinc-100 placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + placeholder="How should we address you?" + autoComplete="name" + maxLength={200} + /> +
+ +
+ +