129 lines
6.5 KiB
TypeScript
129 lines
6.5 KiB
TypeScript
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(
|
||
|
|
<VisitProvider>
|
||
|
|
<PlanVisitForm />
|
||
|
|
</VisitProvider>,
|
||
|
|
);
|
||
|
|
|
||
|
|
describe("PlanVisitForm (keyboard-operable, consent-enforcing, server-confirmed)", () => {
|
||
|
|
const fetchMock = vi.fn<typeof fetch>();
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|