import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CheckEmailCard } from "./check-email-card"; // `vi.hoisted` because `vi.mock` is lifted above every other statement in the // file, so a plain `const` here would not exist yet when the factory runs. The // router is one stable object rather than a fresh one per `useRouter()` call, per // the convention in `DECISIONS.md` - Next's own returns a stable reference. const { router, verifyEmailCode } = vi.hoisted(() => ({ router: { push: vi.fn() }, verifyEmailCode: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: () => router })); vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ verifyEmailCode }), })); beforeEach(() => { vi.clearAllMocks(); }); function renderCard(redirectTo: string | null = null) { const user = userEvent.setup(); render( , ); return user; } // The code is six single-character inputs in a `role="group"`, so there is no one // element to address. The wrapper labels each box "Character N of 6"; the group carries // the visible "Enter the code from the email" text as its own name. const codeBoxes = () => screen.getAllByRole("textbox", { name: /^Character \d of 6$/ }); const typedCode = () => codeBoxes() .map((box) => (box as HTMLInputElement).value) .join(""); // Focus walks itself from box to box as digits land, so the whole code is typed // into whichever box has focus at the time rather than into a named one. async function typeCode( user: ReturnType, digits: string, ) { await user.click(codeBoxes()[0]); await user.keyboard(digits); } describe("CheckEmailCard", () => { // The group is what carries the visible label; `htmlFor` has nothing to point // at once the field is six inputs rather than one. it("names the whole group with the visible label", () => { renderCard(); expect( screen.getByRole("group", { name: /enter the code from the email/i }), ).toBeInTheDocument(); }); // The card lands with the submit button that produced it already gone, so // without this focus falls to `` and six unlabelled-looking boxes have to // be found by hand before a single digit can be typed. it("puts the caret in the first box as soon as it appears", () => { renderCard(); expect(codeBoxes()[0]).toHaveFocus(); }); it("verifies the typed code against the request that produced it", async () => { verifyEmailCode.mockResolvedValue({ status: "authenticated" }); const user = renderCard("/dives/abc"); await typeCode(user, "481052"); expect(verifyEmailCode).toHaveBeenCalledWith("req-1", "481052"); // In-tab, so the destination is the prop - not the `localStorage` value that // only `/auth/verify` consumes. expect(router.push).toHaveBeenCalledWith("/dives/abc"); }); // The funnel treats a code exactly like the link, so a brand-new account starts // onboarding rather than getting a session. it("sends a diver with no account yet to onboarding", async () => { verifyEmailCode.mockResolvedValue({ status: "onboarding_required", }); const user = renderCard("/dives/abc"); await typeCode(user, "481052"); expect(router.push).toHaveBeenCalledWith("/onboarding"); }); // The third outcome, and the one the code path cannot warn about in advance: it // claims its request row before the account is even resolved, so this screen has // already spent the code by the time it learns the account is pending deletion. it("sends an account pending deletion to the restore offer", async () => { verifyEmailCode.mockResolvedValue({ status: "deletion_pending", restore_token: "res", email: "diver@example.com", purge_after: "2026-09-04T12:00:00Z", }); const user = renderCard("/dives/abc"); await typeCode(user, "481052"); expect(router.push).toHaveBeenCalledWith("/restore"); }); // Same guard the Google button applies to the same prop: `?next=` reaches this // as a URL parameter, so a crafted sign-in link must not turn into a redirect // off-origin. it("refuses a destination that isn't ours", async () => { verifyEmailCode.mockResolvedValue({ status: "authenticated" }); const user = renderCard("//evil.example"); await typeCode(user, "481052"); expect(router.push).toHaveBeenCalledWith("/home"); }); // The email prints the code as "481 052", so the obvious thing a diver does - // select it and paste - must not send a space to the API and spend one of the // five attempts on a formatting difference. The paste lands on one box and has // to fill all six. it("spreads a pasted code across the boxes, space and all", async () => { verifyEmailCode.mockResolvedValue({ status: "authenticated" }); const user = renderCard(); await user.click(codeBoxes()[0]); await user.paste("481 052"); expect(typedCode()).toBe("481052"); expect(verifyEmailCode).toHaveBeenCalledWith("req-1", "481052"); }); // Letters never reach the value at all - the field is numeric, so a diver who // starts typing before noticing the code is digits only isn't left with a // half-filled box that looks right. it("ignores anything that isn't a digit", async () => { const user = renderCard(); await typeCode(user, "4a8b1c"); expect(typedCode()).toBe("481"); }); // Five wrong attempts kill the code server-side, and no disabled button stands // between a half-typed code and the API - the sixth digit is what submits, so // nothing may go out before it lands. it("sends nothing until all six digits are in", async () => { const user = renderCard(); await typeCode(user, "4810"); expect(verifyEmailCode).not.toHaveBeenCalled(); }); // Enter submits the form too, straight from the field, so it needs the same // guard rather than relying on the auto-submit path's own completeness check. it("won't submit a short code on Enter either", async () => { const user = renderCard(); await typeCode(user, "4810"); await user.keyboard("{Enter}"); expect(verifyEmailCode).not.toHaveBeenCalled(); }); // The API's own wording rather than this component's fallback, which matters // because the two carry different advice. Every *code* rejection is deliberately // one sentence - wrong digits, an unknown request, an expired row and a spent // attempt budget alike, so nothing leaks about a row the caller cannot name - but // the endpoint's per-IP rate limit answers 429 with its own message, and that one // means "waiting is the fix", which a fallback about the code being invalid would // send the diver away from. Hence the rate-limit message here: it is the case that // actually distinguishes the two. It also has to stay on screen, since a toast // would fade while they are still reading the email. it("shows the API's own message when the code is rejected, and stays put", async () => { verifyEmailCode.mockRejectedValue({ response: { status: 429, data: { detail: "Too many requests. Please try again later." }, }, }); const user = renderCard(); await typeCode(user, "000000"); expect( await screen.findByText("Too many requests. Please try again later."), ).toBeInTheDocument(); expect(router.push).not.toHaveBeenCalled(); // And the field is live again rather than stuck mid-verify - whatever the // diver does next, retype or resend, they can do it from here. expect(screen.queryByRole("status")).not.toBeInTheDocument(); // Cleared like any other failure. A 429 spent no attempt, so these digits // were arguably still good - but the field empties on every rejection // rather than branching on a status code, and the email is still open. expect(typedCode()).toBe(""); }); // The second of the four doors an uninvited address can reach on an instance // that is not taking registrations. The code is right and the refusal comes // from the gate behind it, so the message has to say that rather than send the // diver off to request another email; it is the API's sentence, verbatim. it("shows the gate's refusal for an address nobody invited", async () => { verifyEmailCode.mockRejectedValue({ response: { status: 403, data: { detail: "This address hasn't been invited to this instance yet. You can request an invitation from the home page.", }, }, }); const user = renderCard(); await typeCode(user, "123456"); expect( await screen.findByText(/hasn't been invited to this instance yet/i), ).toBeInTheDocument(); expect(router.push).not.toHaveBeenCalled(); }); // With no button there is no spinner slot, and a diver who typed six digits and // saw nothing change would type them again. The status line is the whole // affordance, and a keystroke meanwhile changes nothing. The boxes are never made // read-only for it: on iOS that closes the keyboard, and the refocus after a // rejection, outside any tap, cannot bring it back. it("says it is checking while the request is out", async () => { let settle: (outcome: { status: string }) => void = () => {}; verifyEmailCode.mockReturnValue( new Promise((resolve) => { settle = resolve; }), ); const user = renderCard(); await typeCode(user, "481052"); expect(await screen.findByRole("status")).toHaveTextContent( /checking your code/i, ); await user.keyboard("{Backspace}9"); expect(typedCode()).toBe("481052"); codeBoxes().forEach((box) => expect(box).not.toHaveAttribute("readonly")); expect(document.activeElement).toBe(codeBoxes()[5]); expect(verifyEmailCode).toHaveBeenCalledTimes(1); await act(async () => settle({ status: "authenticated" })); expect(router.push).toHaveBeenCalledWith("/home"); }); // The expensive half of having no submit button. Auto-submit fires on every // change to a full field, so a rejected code left in the boxes would turn each // keystroke of the correction into another of the five attempts the API allows. // Emptying it makes a retype cost one attempt instead of six. it("empties the boxes after a rejection so a retype costs one attempt", async () => { verifyEmailCode.mockRejectedValue({ response: { status: 400, data: { detail: "That code is invalid." } }, }); const user = renderCard(); await typeCode(user, "000000"); await screen.findByText("That code is invalid."); expect(verifyEmailCode).toHaveBeenCalledTimes(1); expect(typedCode()).toBe(""); // And the caret is back where the next digit belongs. Left on box six, the // next thing typed lands in position six of an empty code. expect(codeBoxes()[0]).toHaveFocus(); // Retyping is one further attempt, not one per digit. await user.keyboard("000001"); expect(verifyEmailCode).toHaveBeenCalledTimes(2); expect(verifyEmailCode).toHaveBeenLastCalledWith("req-1", "000001"); }); });