Create flow: session UI + sign out
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
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 { useAuthModal } from "../../app/contexts/AuthModalContext";
|
||||
|
||||
const { navMock } = vi.hoisted(() => ({
|
||||
navMock: {
|
||||
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/create/anonymousDraftStorage", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../app/create/anonymousDraftStorage")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
setTransferPendingFlag: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { requestMagicLink } from "../../lib/create/api";
|
||||
import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage";
|
||||
|
||||
function LoginTrigger() {
|
||||
const { openLogin, closeLogin } = useAuthModal();
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => openLogin()}>
|
||||
Open login modal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
openLogin({
|
||||
variant: "saveProgress",
|
||||
nextPath: "/create/select?syncDraft=1",
|
||||
})
|
||||
}
|
||||
>
|
||||
Open save progress
|
||||
</button>
|
||||
<button type="button" onClick={() => closeLogin()}>
|
||||
Close from outside
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AuthModalProvider (header overlay)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requestMagicLink).mockReset();
|
||||
vi.mocked(setTransferPendingFlag).mockReset();
|
||||
navMock.replace.mockReset();
|
||||
navMock.pathname = "/";
|
||||
navMock.searchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
it("opens blurred overlay with LoginForm when openLogin is called", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /open login modal/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
const backdrop = screen.getByRole("dialog").parentElement;
|
||||
expect(backdrop).toHaveClass("backdrop-blur-md");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /log in to communityrule/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /back to home/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes overlay when closeLogin is called", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /open login modal/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /close from outside/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("saveProgress openLogin wires magicLinkNextPath and transfer flag on success", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(requestMagicLink).mockResolvedValue({ ok: true });
|
||||
renderWithProviders(
|
||||
<Suspense fallback={null}>
|
||||
<LoginTrigger />
|
||||
</Suspense>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open save progress/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).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/select?syncDraft=1",
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithProviders as render, screen } from "../utils/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import CreateFlowTopNav from "../../app/components/utility/CreateFlowTopNav";
|
||||
@@ -34,7 +34,7 @@ const config: ComponentTestSuiteConfig<CreateFlowTopNavProps> = {
|
||||
hasShare: true,
|
||||
hasExport: true,
|
||||
hasEdit: true,
|
||||
loggedIn: true,
|
||||
saveDraftOnExit: true,
|
||||
onShare: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
@@ -60,14 +60,14 @@ describe("CreateFlowTopNav (behavioral tests)", () => {
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Save & Exit when loggedIn is true", () => {
|
||||
render(<CreateFlowTopNav loggedIn={true} />);
|
||||
it("shows Save & Exit when saveDraftOnExit is true", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={true} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Save & Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Exit when loggedIn is false", () => {
|
||||
render(<CreateFlowTopNav loggedIn={false} />);
|
||||
it("shows Exit when saveDraftOnExit is false", () => {
|
||||
render(<CreateFlowTopNav saveDraftOnExit={false} />);
|
||||
const exitButton = screen.getByRole("button", { name: "Exit" });
|
||||
expect(exitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,37 @@ describe("Login", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses blurredYellow backdrop by default (header overlay)", 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();
|
||||
});
|
||||
const backdrop = screen.getByRole("dialog").parentElement;
|
||||
expect(backdrop).toHaveClass("backdrop-blur-md");
|
||||
});
|
||||
|
||||
it("uses solid backdrop when backdropVariant is solid (/login page)", async () => {
|
||||
renderWithProviders(
|
||||
<Login
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
ariaLabelledBy="login-modal-heading"
|
||||
backdropVariant="solid"
|
||||
>
|
||||
<p id="login-modal-heading">Login content</p>
|
||||
</Login>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
const backdrop = screen.getByRole("dialog").parentElement;
|
||||
expect(backdrop).not.toHaveClass("backdrop-blur-md");
|
||||
});
|
||||
|
||||
it("renders dialog when open and portal is ready", async () => {
|
||||
renderWithProviders(
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
@@ -35,6 +66,23 @@ describe("Login", () => {
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("portals overlay outside the rendered subtree by default", async () => {
|
||||
const { container } = renderWithProviders(
|
||||
<div data-testid="inline-root">
|
||||
<Login isOpen onClose={vi.fn()} ariaLabelledBy="login-modal-heading">
|
||||
<p id="login-modal-heading">Portaled</p>
|
||||
</Login>
|
||||
</div>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(
|
||||
container.querySelector('[data-testid="inline-root"]')?.contains(dialog),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(
|
||||
|
||||
@@ -8,6 +8,8 @@ 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(),
|
||||
},
|
||||
@@ -22,7 +24,7 @@ vi.mock("next/navigation", () => ({
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
}),
|
||||
usePathname: () => "/login",
|
||||
usePathname: () => navMock.pathname,
|
||||
useSearchParams: () => navMock.searchParams,
|
||||
}));
|
||||
|
||||
@@ -30,7 +32,19 @@ vi.mock("../../lib/create/api", () => ({
|
||||
requestMagicLink: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../app/create/anonymousDraftStorage", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../app/create/anonymousDraftStorage")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
setTransferPendingFlag: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { requestMagicLink } from "../../lib/create/api";
|
||||
import { setTransferPendingFlag } from "../../app/create/anonymousDraftStorage";
|
||||
|
||||
function renderLoginForm() {
|
||||
return renderWithProviders(
|
||||
@@ -43,7 +57,9 @@ function renderLoginForm() {
|
||||
describe("LoginForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(requestMagicLink).mockReset();
|
||||
vi.mocked(setTransferPendingFlag).mockReset();
|
||||
navMock.replace.mockReset();
|
||||
navMock.pathname = "/";
|
||||
navMock.searchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
@@ -96,6 +112,33 @@ describe("LoginForm", () => {
|
||||
expect(screen.getByText(/we sent a sign-in link/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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/select?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/select?syncDraft=1",
|
||||
);
|
||||
});
|
||||
expect(setTransferPendingFlag).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes safe next path when next query param is set", async () => {
|
||||
const user = userEvent.setup();
|
||||
navMock.searchParams = new URLSearchParams("next=/learn");
|
||||
@@ -158,8 +201,9 @@ describe("LoginForm", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls router.replace to clear error query when user types", async () => {
|
||||
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(
|
||||
@@ -169,6 +213,18 @@ describe("LoginForm", () => {
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user