Magic-link sign in UI and APIs
@@ -0,0 +1,95 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderWithProviders } from "../utils/test-utils";
|
||||
import Login from "../../app/components/modals/Login";
|
||||
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders dialog when open and portal is ready", async () => {
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Login content</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Login content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dialog when closed", () => {
|
||||
renderWithProviders(
|
||||
<Login
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
>
|
||||
<p>Hidden</p>
|
||||
</Login>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Close dialog"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose when Escape is pressed", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={onClose} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("locks body scroll while open", async () => {
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.body.style.overflow).toBe("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders belowCard outside the dialog card", async () => {
|
||||
renderWithProviders(
|
||||
<Login
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
belowCard={<a href="/">Back to home</a>}
|
||||
>
|
||||
<p id="login-modal-heading">Body</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("link", { name: /back to home/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { 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: {
|
||||
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: () => "/login",
|
||||
useSearchParams: () => navMock.searchParams,
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/create/api", () => ({
|
||||
requestMagicLink: vi.fn(),
|
||||
}));
|
||||
|
||||
import { requestMagicLink } from "../../lib/create/api";
|
||||
|
||||
function renderLoginForm() {
|
||||
return renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm />
|
||||
</Suspense>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requestMagicLink).mockReset();
|
||||
navMock.replace.mockReset();
|
||||
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 }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /send me a magic link/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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", "/");
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: /check your email/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/we sent a sign-in link/i)).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");
|
||||
});
|
||||
});
|
||||
|
||||
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("calls router.replace to clear error query when user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
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("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();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 491 KiB |
|
Before Width: | Height: | Size: 882 KiB After Width: | Height: | Size: 903 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 515 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 465 KiB After Width: | Height: | Size: 557 KiB |
|
Before Width: | Height: | Size: 541 KiB After Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 574 KiB After Width: | Height: | Size: 793 KiB |
@@ -32,10 +32,13 @@ describe("Page", () => {
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// Check feature grid section (using getAllByText since there are multiple instances)
|
||||
expect(
|
||||
screen.getAllByText("We've got your back, every step of the way").length,
|
||||
).toBeGreaterThan(0);
|
||||
// FeatureGrid is next/dynamic — wait like other code-split sections
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getAllByText("We've got your back, every step of the way")
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
"Use our toolkit to improve, document, and evolve your organization.",
|
||||
|
||||
@@ -21,7 +21,10 @@ describe("assertPlainJsonValue", () => {
|
||||
});
|
||||
|
||||
it("rejects __proto__ keys", () => {
|
||||
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<string, unknown>;
|
||||
const obj = JSON.parse('{"__proto__": {"x": 1}}') as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(assertPlainJsonValue(obj, 0, DEFAULT_PLAIN_JSON_LIMITS)).toBe(
|
||||
"Unsafe object key",
|
||||
);
|
||||
|
||||