Files
community-rule/tests/components/LoginForm.test.tsx
T
adilalloandCursor db34a1f0df Let guests keep a published rule via Save & Exit and a post-finalize sign-in prompt.
Email is optional so they can continue without saving; skip from Save & Exit leaves the flow instead of returning to completed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 16:18:41 -06:00

355 lines
11 KiB
TypeScript

import { Suspense } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
import LoginForm from "../../app/components/modals/Login/LoginForm";
const { navMock } = vi.hoisted(() => ({
navMock: {
/** Default: marketing route — header modal is the primary entry (not `/login`). */
pathname: "/",
searchParams: new URLSearchParams(),
replace: vi.fn(),
},
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: navMock.replace,
push: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
}),
usePathname: () => navMock.pathname,
useSearchParams: () => navMock.searchParams,
}));
vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("../../app/(app)/create/utils/anonymousDraftStorage")
>();
return {
...actual,
setTransferPendingFlag: vi.fn(),
};
});
import { EMAIL_MAX_LEN } from "../../lib/create/isValidCreateFlowSaveEmail";
import { requestMagicLink } from "../../lib/create/api";
import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
function renderLoginForm() {
return renderWithProviders(
<Suspense fallback={null}>
<LoginForm />
</Suspense>,
);
}
describe("LoginForm", () => {
beforeEach(() => {
vi.mocked(requestMagicLink).mockReset();
vi.mocked(setTransferPendingFlag).mockReset();
navMock.replace.mockReset();
navMock.pathname = "/";
navMock.searchParams = new URLSearchParams();
});
it("renders title, email field, and submit control", () => {
renderLoginForm();
expect(
screen.getByRole("heading", { name: /log in to communityrule/i }),
).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: /email address/i }),
).toHaveAttribute("maxLength", String(EMAIL_MAX_LEN));
expect(
screen.getByRole("button", { name: /send me a magic link/i }),
).toBeInTheDocument();
});
it("links terms and privacy to the legal pages", () => {
renderLoginForm();
expect(
screen.getByRole("link", { name: "Terms of Service" }),
).toHaveAttribute("href", "/terms");
expect(
screen.getByRole("link", { name: "Privacy Policy" }),
).toHaveAttribute("href", "/privacy");
});
it("shows validation error when email is invalid", async () => {
const user = userEvent.setup();
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"not-an-email",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/enter a valid email address/i),
).toBeInTheDocument();
expect(requestMagicLink).not.toHaveBeenCalled();
});
it("submits trimmed email and shows success state when API succeeds", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
" Pat@Example.COM ",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"pat@example.com",
"/",
undefined,
);
});
expect(
await screen.findByRole("heading", { name: /check your email/i }),
).toBeInTheDocument();
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
expect(screen.getByText(/check spam or promotions/i)).toBeInTheDocument();
});
it("submits a long email without treating length as invalid", async () => {
const user = userEvent.setup();
const email =
"very.long.local-part.for.testing@subdomain.example.communityrule.org";
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
email,
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(email, "/", undefined);
});
});
it("saveProgress variant uses magicLinkNextPath and sets transfer pending on success", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="saveProgress"
magicLinkNextPath="/create/community-structure?syncDraft=1"
/>
</Suspense>,
);
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"save@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"save@example.com",
"/create/community-structure?syncDraft=1",
undefined,
);
});
expect(setTransferPendingFlag).toHaveBeenCalled();
});
it("keepRule variant claims without attaching a create-flow draft", async () => {
const user = userEvent.setup();
window.localStorage.setItem(
"create-flow-anonymous",
JSON.stringify({ title: "Guest draft" }),
);
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="keepRule"
magicLinkNextPath="/create/completed"
/>
</Suspense>,
);
expect(
screen.getByRole("heading", {
name: /sign in to keep this rule on your profile/i,
}),
).toBeInTheDocument();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"guest@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"guest@example.com",
"/create/completed",
undefined,
);
});
expect(setTransferPendingFlag).not.toHaveBeenCalled();
window.localStorage.removeItem("create-flow-anonymous");
});
it("keepRule continue without saving calls onDismiss", async () => {
const user = userEvent.setup();
const onDismiss = vi.fn();
renderWithProviders(
<Suspense fallback={null}>
<LoginForm
variant="keepRule"
magicLinkNextPath="/create/completed"
onDismiss={onDismiss}
/>
</Suspense>,
);
await user.click(
screen.getByRole("button", { name: /continue without saving/i }),
);
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(requestMagicLink).not.toHaveBeenCalled();
});
it("default variant has no continue without saving action", () => {
renderLoginForm();
expect(
screen.queryByRole("button", { name: /continue without saving/i }),
).not.toBeInTheDocument();
});
it("passes safe next path when next query param is set", async () => {
const user = userEvent.setup();
navMock.searchParams = new URLSearchParams("next=/learn");
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"a@b.co",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
await waitFor(() => {
expect(requestMagicLink).toHaveBeenCalledWith(
"a@b.co",
"/learn",
undefined,
);
});
});
it("shows API error when request fails", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Server says no",
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(await screen.findByText("Server says no")).toBeInTheDocument();
});
it("shows rate limit message when retryAfterMs is present", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockResolvedValue({
ok: false,
error: "Too many",
retryAfterMs: 3500,
});
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/try again in 4 seconds/i),
).toBeInTheDocument();
});
it("shows URL-driven error for expired_link", () => {
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link has expired/i),
).toBeInTheDocument();
});
it("shows URL-driven error for invalid_link", () => {
navMock.searchParams = new URLSearchParams("error=invalid_link");
renderLoginForm();
expect(
screen.getByText(/that sign-in link is not valid/i),
).toBeInTheDocument();
});
it("calls router.replace to clear error query when user types (full-page /login)", async () => {
const user = userEvent.setup();
navMock.pathname = "/login";
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"x",
);
expect(navMock.replace).toHaveBeenCalledWith("/login", { scroll: false });
});
it("clears error query using current pathname when not on /login", async () => {
const user = userEvent.setup();
navMock.pathname = "/learn";
navMock.searchParams = new URLSearchParams("error=expired_link");
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"x",
);
expect(navMock.replace).toHaveBeenCalledWith("/learn", { scroll: false });
});
it("shows network error when request throws", async () => {
const user = userEvent.setup();
vi.mocked(requestMagicLink).mockRejectedValue(new Error("network"));
renderLoginForm();
await user.type(
screen.getByRole("textbox", { name: /email address/i }),
"ok@example.com",
);
await user.click(
screen.getByRole("button", { name: /send me a magic link/i }),
);
expect(
await screen.findByText(/check your connection/i),
).toBeInTheDocument();
});
});