mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 18:22:05 +00:00
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.
425 lines
15 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|