import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PlanVisitForm } from "@/components/PlanVisitForm"; import { VisitProvider } from "@/components/VisitProvider"; function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); } const ui = () => render( , ); describe("PlanVisitForm (keyboard-operable, consent-enforcing, server-confirmed)", () => { const fetchMock = vi.fn(); beforeEach(() => { window.sessionStorage.clear(); window.dataLayer = []; vi.stubGlobal("fetch", fetchMock); fetchMock.mockReset(); // IntersectionObserver is used by the floating button; not needed here but keep jsdom quiet vi.stubGlobal("IntersectionObserver", class { observe() {} disconnect() {} }); }); afterEach(() => vi.unstubAllGlobals()); it("walks through all three steps with the keyboard and submits a valid lead", async () => { const user = userEvent.setup(); fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true, submissionId: "x", email: { status: "mock" }, sms: { status: "mock" } })); ui(); // Step 1: campus (radio group) const norwood = screen.getByRole("radio", { name: /norwood/i }); await user.click(norwood); await user.click(screen.getByRole("button", { name: /continue/i })); // Step 2: about you const first = await screen.findByLabelText(/first name/i); await user.type(first, "Jordan"); await user.type(screen.getByRole("textbox", { name: /^email/i }), "jordan@example.com"); await user.type(screen.getByRole("textbox", { name: /mobile phone/i }), "508-555-0142"); // consent boxes start unchecked and are separate const emailBox = screen.getByRole("checkbox", { name: /email me/i }); const smsBox = screen.getByRole("checkbox", { name: /text me/i }); expect(emailBox).not.toBeChecked(); expect(smsBox).not.toBeChecked(); await user.click(emailBox); await user.click(smsBox); await user.click(screen.getByRole("button", { name: /continue/i })); // Step 3: visit details (all optional) → submit const submit = await screen.findByRole("button", { name: /send my visit details/i }); submit.focus(); await user.keyboard("{Enter}"); await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); const [url, init] = fetchMock.mock.calls[0]; expect(String(url)).toMatch(/\/api\/plan-visit\.php$/); const body = JSON.parse(String(init?.body)); expect(body.campusId).toBe("norwood"); expect(body.firstName).toBe("Jordan"); expect(body.smsConsent).toBe(true); expect(body.emailConsent).toBe(true); expect(body.consentVersion).toMatch(/^\d{4}-\d{2}-\d{2}/); expect(body.submissionId).toMatch(/^[0-9a-f-]{36}$/); expect(body.website).toBe(""); // Confirmation preserves the page design and shows campus details + Text Us await screen.findByText(/you're all set/i); expect(screen.getByText(/668 Walpole Street/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: /text us a question/i })).toHaveAttribute("href", expect.stringMatching(/^sms:\+17817623357/)); // Conversion fired only after server success const events = (window.dataLayer as { event: string }[]).map((e) => e.event); expect(events).toContain("plan_visit_submitted"); expect(events.indexOf("plan_visit_success")).toBeGreaterThan(events.indexOf("plan_visit_submitted")); }); it("blocks SMS consent without a phone number and announces the error", async () => { const user = userEvent.setup(); ui(); await user.click(screen.getByRole("radio", { name: /north attleboro/i })); await user.click(screen.getByRole("button", { name: /continue/i })); await user.type(await screen.findByLabelText(/first name/i), "Sam"); await user.type(screen.getByRole("textbox", { name: /^email/i }), "sam@example.com"); await user.click(screen.getByRole("checkbox", { name: /text me/i })); await user.click(screen.getByRole("button", { name: /continue/i })); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent(/mobile number/i); expect(screen.getByRole("textbox", { name: /mobile phone/i })).toHaveAttribute("aria-invalid", "true"); expect(fetchMock).not.toHaveBeenCalled(); }); it("keeps the visitor's answers and shows a friendly message when the server fails", async () => { const user = userEvent.setup(); fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Something went wrong on our end. Please try again in a moment." }, 500)); ui(); await user.click(screen.getByRole("radio", { name: /woonsocket/i })); await user.click(screen.getByRole("button", { name: /continue/i })); await user.type(await screen.findByLabelText(/first name/i), "Alex"); await user.type(screen.getByRole("textbox", { name: /^email/i }), "alex@example.com"); await user.click(screen.getByRole("button", { name: /continue/i })); await user.click(await screen.findByRole("button", { name: /send my visit details/i })); const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent(/try again/i); // answers preserved await user.click(screen.getByRole("button", { name: /back/i })); expect(await screen.findByLabelText(/first name/i)).toHaveValue("Alex"); }); it("maps server field errors back to the right step", async () => { const user = userEvent.setup(); fetchMock.mockResolvedValueOnce(jsonResponse({ ok: false, error: "Please check the highlighted fields.", fieldErrors: { email: "That email doesn't look right." } }, 422)); ui(); await user.click(screen.getByRole("radio", { name: /southcoast/i })); await user.click(screen.getByRole("button", { name: /continue/i })); await user.type(await screen.findByLabelText(/first name/i), "Riley"); await user.type(screen.getByRole("textbox", { name: /^email/i }), "riley@example.com"); await user.click(screen.getByRole("button", { name: /continue/i })); await user.click(await screen.findByRole("button", { name: /send my visit details/i })); const email = await screen.findByRole("textbox", { name: /^email/i }); expect(email).toHaveAttribute("aria-invalid", "true"); expect(within(email.closest("div")!).getByRole("alert")).toHaveTextContent(/email/i); }); });