import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { toast } from "react-toastify"; import { Route, Routes } from "react-router"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import InviteRegistrationPage from "@/app/invite"; import { getAuthUser } from "@/lib/auth/auth-storage"; import { renderWithProviders } from "@/test/test-utils"; vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); const TOKEN = "Zm9yLXRlc3RzLW9ubHktbm90LWEtcmVhbC10b2tlbg"; const CODE = "246810"; const DETAILS = { name: "Taylor Reed", role: "Dispatcher", email: "taylor@example.com", phone: "555-0100", }; const SESSION = { token: "session-jwt", expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), email: DETAILS.email, userRoles: "Dispatcher", phoneNumber: "555-123-4567", fullname: "Taylor Reed", id: "user-7", }; type Call = { path: string; url: string; body: Record }; type ServerOptions = { sendCodeTooSoon?: boolean; resendTooSoon?: boolean; /** The admin re-sent the invite after the code went out, revoking this token. */ revokedBeforeVerify?: boolean; rejectPasswordAtFinish?: boolean; /** How the first `resolve` call fails before the server answers normally. */ resolveFailsOnce?: "network" | "server-error"; }; const INVALID_INVITE_BODY = { code: "invalid_invite", message: "This invite link is invalid or has expired. Ask your admin to send a new invite.", }; const PASSWORD_REJECTED_MESSAGE = "Choose a different password"; function json(status: number, body: unknown) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } /** A fake of the four anonymous invite endpoints, scoped to one valid token. */ function installInviteServer({ sendCodeTooSoon = false, resendTooSoon = false, revokedBeforeVerify = false, rejectPasswordAtFinish = false, resolveFailsOnce, }: ServerOptions = {}) { const calls: Call[] = []; let sends = 0; let resolves = 0; vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, ""); const body = (await request.json()) as Record; calls.push({ path, url: request.url, body }); if (path === "resolve" && resolveFailsOnce && ++resolves === 1) { if (resolveFailsOnce === "network") { throw new TypeError("Failed to fetch"); } return json(500, { message: "Internal Server Error" }); } if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) { return json(400, INVALID_INVITE_BODY); } switch (path) { case "resolve": return json(200, DETAILS); case "send-code": { sends += 1; const refuse = sends === 1 ? sendCodeTooSoon : resendTooSoon; return refuse ? json(429, { code: "resend_too_soon", message: "Please wait a moment before requesting another code", retryAfterSeconds: 42, }) : json(200, { message: "Code sent" }); } case "verify-code": return body.code === CODE ? json(200, { message: "Email confirmed" }) : json(400, { code: "code_incorrect", message: "Incorrect code — check your email and try again", }); case "complete": return rejectPasswordAtFinish ? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE }) : json(200, SESSION); default: return json(404, {}); } }); return calls; } function renderInvite(hash: string) { return renderWithProviders( } /> Dashboard home} /> Login page} /> , { route: `/invite${hash}` }, ); } function input(label: RegExp) { return screen.getByLabelText(label, { selector: "input" }); } function ruleStates() { const list = screen.getByRole("list", { name: "Password requirements" }); return Object.fromEntries( within(list) .getAllByRole("listitem") .map((item) => [item.getAttribute("data-rule"), item.getAttribute("data-met")]), ); } async function completePasswordStep(user: ReturnType) { await screen.findByRole("heading", { name: "Create your password" }); await user.type(input(/^password/i), "Abc1!x"); await user.type(input(/^confirm password/i), "Abc1!x"); await user.click(screen.getByRole("button", { name: "Continue" })); await screen.findByRole("heading", { name: "Confirm your email" }); } async function completeCodeStep(user: ReturnType) { await user.type(input(/^confirmation code/i), CODE); await user.click(screen.getByRole("button", { name: "Continue" })); await screen.findByRole("heading", { name: "Review your info" }); } describe("Invite registration", { timeout: 15_000 }, () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); }); afterEach(() => { vi.restoreAllMocks(); }); it("shows the generic unavailable message for an unknown token", async () => { const calls = installInviteServer(); renderInvite("#not-a-real-token"); expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); expect( screen.getByText( "This invite link is invalid or has expired. Ask your admin to send a new invite.", ), ).toBeInTheDocument(); expect(calls.map((call) => call.path)).toEqual(["resolve"]); expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login"); }); it.each(["network", "server-error"] as const)( "offers a retry instead of the dead-invite message when resolve fails (%s)", async (failure) => { const calls = installInviteServer({ resolveFailsOnce: failure }); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); expect( await screen.findByRole("heading", { name: "Couldn't load your invite" }), ).toBeInTheDocument(); expect( screen.getByText( "We couldn't reach the server to check your invite. Check your connection and try again.", ), ).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Invite unavailable" })).not.toBeInTheDocument(); expect( screen.queryByText( "This invite link is invalid or has expired. Ask your admin to send a new invite.", ), ).not.toBeInTheDocument(); expect(vi.mocked(toast.error)).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: "Try again" })); expect( await screen.findByRole("heading", { name: "Create your password" }), ).toBeInTheDocument(); expect(calls.map((call) => call.path)).toEqual(["resolve", "resolve"]); }, ); it("does not call the server when the link carries no token", async () => { const calls = installInviteServer(); renderInvite(""); expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); expect(calls).toHaveLength(0); }); it("step 1 rejects a password missing a rule, shows the live checklist, and requires a match", async () => { installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); expect(await screen.findByText("Step 1 of 3")).toBeInTheDocument(); expect(screen.getByText("Setting up the account for Taylor Reed")).toBeInTheDocument(); const continueButton = screen.getByRole("button", { name: "Continue" }); await user.type(input(/^password/i), "abcdefgh"); await user.type(input(/^confirm password/i), "abcdefgh"); expect(ruleStates()).toEqual({ length: "true", uppercase: "false", number: "false", special: "false", }); expect(continueButton).toBeDisabled(); await user.clear(input(/^password/i)); await user.type(input(/^password/i), "Abc1!x"); expect(ruleStates()).toEqual({ length: "true", uppercase: "true", number: "true", special: "true", }); await user.click(input(/^confirm password/i)); await user.tab(); expect(await screen.findByText("Passwords don't match")).toBeInTheDocument(); expect(continueButton).toBeDisabled(); await user.clear(input(/^confirm password/i)); await user.type(input(/^confirm password/i), "Abc1!x"); expect(continueButton).toBeEnabled(); }); it("step 2 shows a clear error for a wrong code, resends on request, and advances on the right code", async () => { const calls = installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); expect(screen.getByText("Step 2 of 3")).toBeInTheDocument(); expect(screen.getByText(`Enter the 6-digit code sent to ${DETAILS.email}`)).toBeInTheDocument(); const continueButton = screen.getByRole("button", { name: "Continue" }); await user.type(input(/^confirmation code/i), "123"); expect(continueButton).toBeDisabled(); await user.type(input(/^confirmation code/i), "456"); await user.click(continueButton); expect( await screen.findByText("Incorrect code — check your email and try again"), ).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Confirm your email" })).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Resend code" })); await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalledWith(`Code resent to ${DETAILS.email}`), ); expect(calls.filter((call) => call.path === "send-code")).toHaveLength(2); await user.clear(input(/^confirmation code/i)); await completeCodeStep(user); expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); }); it("shows the cooldown message when a resend is refused", async () => { installInviteServer({ resendTooSoon: true }); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await user.click(screen.getByRole("button", { name: "Resend code" })); expect( await screen.findByText("Please wait a moment before requesting another code"), ).toBeInTheDocument(); expect(vi.mocked(toast.success)).not.toHaveBeenCalled(); }); it("still advances to step 2 when a code was already sent moments ago", async () => { installInviteServer({ sendCodeTooSoon: true }); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); expect(screen.getByText("Step 2 of 3")).toBeInTheDocument(); }); it("step 3 keeps name, role and email read-only, lets the member edit phone, and finishing signs them in", async () => { const calls = installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await completeCodeStep(user); for (const value of [DETAILS.name, DETAILS.role, DETAILS.email]) { expect(screen.getByText(value)).toBeInTheDocument(); expect(screen.queryByDisplayValue(value)).not.toBeInTheDocument(); } expect(screen.getAllByRole("textbox")).toHaveLength(1); const phone = input(/^phone/i); expect(phone).toHaveValue(DETAILS.phone); expect(phone).toHaveAttribute("type", "tel"); await user.clear(phone); await user.type(phone, "555-123-4567"); await user.click(screen.getByRole("button", { name: /finish registration/i })); expect(await screen.findByText("Dashboard home")).toBeInTheDocument(); expect(vi.mocked(toast.success)).toHaveBeenCalledWith( "Registration complete — welcome to Seahaven", ); expect(getAuthUser()).toMatchObject({ token: "session-jwt", id: "user-7" }); expect(calls.find((call) => call.path === "complete")?.body).toEqual({ token: TOKEN, password: "Abc1!x", phone: "555-123-4567", }); }); it("step 3 blocks finishing with an invalid phone and never calls the server", async () => { const calls = installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await completeCodeStep(user); await user.clear(input(/^phone/i)); await user.type(input(/^phone/i), "call me"); await user.click(screen.getByRole("button", { name: /finish registration/i })); expect(await screen.findByText("Enter a valid phone number")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Review your info" })).toBeInTheDocument(); expect(calls.some((call) => call.path === "complete")).toBe(false); }); it("sends an empty phone when the member clears it", async () => { const calls = installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await completeCodeStep(user); await user.clear(input(/^phone/i)); await user.click(screen.getByRole("button", { name: /finish registration/i })); await screen.findByText("Dashboard home"); expect(calls.find((call) => call.path === "complete")?.body.phone).toBe(""); }); it("ends the wizard with the generic message when the invite is revoked mid-way", async () => { installInviteServer({ revokedBeforeVerify: true }); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await user.type(input(/^confirmation code/i), CODE); await user.click(screen.getByRole("button", { name: "Continue" })); expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument(); expect(screen.getByText(INVALID_INVITE_BODY.message)).toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Confirm your email" })).not.toBeInTheDocument(); }); it("returns to step 1 with the server's message when the password is rejected at finish", async () => { installInviteServer({ rejectPasswordAtFinish: true }); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await completeCodeStep(user); await user.click(screen.getByRole("button", { name: /finish registration/i })); expect( await screen.findByRole("heading", { name: "Create your password" }), ).toBeInTheDocument(); expect(screen.getByText(PASSWORD_REJECTED_MESSAGE)).toBeInTheDocument(); expect(getAuthUser()).toBeNull(); }); it("sends the invite token only in request bodies, never in a URL", async () => { const calls = installInviteServer(); const user = userEvent.setup(); renderInvite(`#${TOKEN}`); await completePasswordStep(user); await completeCodeStep(user); await user.click(screen.getByRole("button", { name: /finish registration/i })); await screen.findByText("Dashboard home"); expect(calls.map((call) => call.path)).toEqual([ "resolve", "send-code", "verify-code", "complete", ]); for (const call of calls) { expect(call.url).not.toContain(TOKEN); expect(call.body.token).toBe(TOKEN); } }); });