shoc-frontend-new/src/test/app/invite/invite-registration-page.test.tsx
Alexandre Brandizzi 3b7cf83332 fix(team-members): offer a retry when the invite cannot be checked
The invite page showed "invalid or has expired, ask your admin for a new
invite" for any failed resolve call, including offline, timeouts and 5xx.
A member on a flaky connection was sent to their admin for a resend, which
revokes a token that was still valid.

Only the server's invalid_invite answer now ends on "Invite unavailable".
Any other failure shows "Couldn't load your invite" with a Try again
button that re-runs resolve, without a global error toast. Regression
tests cover a network rejection and a 500 followed by a successful retry.
2026-09-25 17:45:08 -03:00

425 lines
15 KiB
TypeScript

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<string, unknown> };
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<string, unknown>;
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(
<Routes>
<Route path="/invite" element={<InviteRegistrationPage />} />
<Route path="/" element={<div>Dashboard home</div>} />
<Route path="/login" element={<div>Login page</div>} />
</Routes>,
{ 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<typeof userEvent.setup>) {
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<typeof userEvent.setup>) {
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);
}
});
});