Merge pull request #1082 from Molecule-AI/chore/canvas-remove-waitlist-dead-page

chore(canvas): remove dead /waitlist page (lives in molecule-app)
This commit is contained in:
Hongming Wang 2026-04-20 09:56:01 -07:00 committed by GitHub
commit 35d9363fbd
2 changed files with 0 additions and 381 deletions

View File

@ -1,192 +0,0 @@
// @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(<WaitlistPage />);
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=<urlencoded>.
// 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(<WaitlistPage />);
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(<WaitlistPage />);
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(<WaitlistPage />);
// 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(<WaitlistPage />);
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(<WaitlistPage />);
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(<WaitlistPage />);
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(<WaitlistPage />);
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(<WaitlistPage />);
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/);
});
});
});

View File

@ -1,189 +0,0 @@
"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<SubmitState>("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 (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-xl px-6 py-20">
<h1 className="text-4xl font-bold tracking-tight text-white md:text-5xl">
You&rsquo;re on the waitlist
</h1>
<p className="mt-4 text-lg text-zinc-300">
Molecule AI is in private beta while we harden the platform. Tell us
a bit about yourself and we&rsquo;ll reach out when a spot opens.
</p>
{state === "success" && (
<div
role="status"
className="mt-8 rounded-lg border border-emerald-800 bg-emerald-950/50 p-4 text-emerald-200"
>
Thanks your request is in. We&rsquo;ll email{" "}
<span className="font-mono">{email}</span> when access opens up.
</div>
)}
{state === "dedup" && (
<div
role="status"
className="mt-8 rounded-lg border border-sky-800 bg-sky-950/50 p-4 text-sky-200"
>
We already have your request on file for{" "}
<span className="font-mono">{email}</span>. No need to resubmit
we&rsquo;ll be in touch.
</div>
)}
{state !== "success" && state !== "dedup" && (
<form className="mt-8 space-y-5" onSubmit={onSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-zinc-200">
Email <span className="text-rose-400">*</span>
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-zinc-200">
Name
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => 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}
/>
</div>
<div>
<label htmlFor="use_case" className="block text-sm font-medium text-zinc-200">
What would you build with this?
</label>
<textarea
id="use_case"
rows={4}
value={useCase}
onChange={(e) => setUseCase(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="Research assistant, customer support automation, internal ops agent…"
maxLength={500}
/>
<p className="mt-1 text-xs text-zinc-500">
Helps us prioritize who to let in first.
</p>
</div>
{state === "error" && errorMsg && (
<div
role="alert"
className="rounded-md border border-rose-800 bg-rose-950/50 px-3 py-2 text-sm text-rose-200"
>
{errorMsg}
</div>
)}
<button
type="submit"
disabled={state === "submitting"}
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-5 py-2.5 font-medium text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-blue-900 disabled:text-blue-300"
>
{state === "submitting" ? "Submitting…" : "Request access"}
</button>
</form>
)}
<p className="mt-12 text-sm text-zinc-500">
Questions? Email{" "}
<a
href="mailto:support@moleculesai.app"
className="text-blue-400 underline hover:text-blue-300"
>
support@moleculesai.app
</a>
.
</p>
</div>
</main>
);
}