import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { AuthConfigResponse } from "@mosaic/shared"; import LoginPage from "./page"; /* ------------------------------------------------------------------ */ /* Hoisted mocks */ /* ------------------------------------------------------------------ */ const { mockOAuth2, mockSignInEmail, mockPush } = vi.hoisted(() => ({ mockOAuth2: vi.fn(), mockSignInEmail: vi.fn(), mockPush: vi.fn(), })); vi.mock("next/navigation", () => ({ useRouter: (): { push: Mock } => ({ push: mockPush, }), })); vi.mock("@/lib/auth-client", () => ({ signIn: { oauth2: mockOAuth2, email: mockSignInEmail, }, })); vi.mock("@/lib/config", () => ({ API_BASE_URL: "http://localhost:3001", })); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function mockFetchConfig(config: AuthConfigResponse): void { (global.fetch as Mock).mockResolvedValueOnce({ ok: true, json: (): Promise => Promise.resolve(config), }); } function mockFetchFailure(): void { (global.fetch as Mock).mockRejectedValueOnce(new Error("Network error")); } const OAUTH_ONLY_CONFIG: AuthConfigResponse = { providers: [{ id: "authentik", name: "Authentik", type: "oauth" }], }; const EMAIL_ONLY_CONFIG: AuthConfigResponse = { providers: [{ id: "email", name: "Email", type: "credentials" }], }; const BOTH_PROVIDERS_CONFIG: AuthConfigResponse = { providers: [ { id: "authentik", name: "Authentik", type: "oauth" }, { id: "email", name: "Email", type: "credentials" }, ], }; /* ------------------------------------------------------------------ */ /* Tests */ /* ------------------------------------------------------------------ */ describe("LoginPage", (): void => { beforeEach((): void => { vi.clearAllMocks(); global.fetch = vi.fn(); }); it("renders loading state initially", (): void => { // Never resolve fetch so it stays in loading state // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise to test loading state (global.fetch as Mock).mockReturnValueOnce(new Promise(() => {})); render(); expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect(screen.getByText("Loading authentication options")).toBeInTheDocument(); }); it("renders the page heading and description", (): void => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Welcome to Mosaic Stack"); expect(screen.getByText(/Your personal assistant platform/i)).toBeInTheDocument(); }); it("has proper layout styling", (): void => { mockFetchConfig(EMAIL_ONLY_CONFIG); const { container } = render(); const main = container.querySelector("main"); expect(main).toHaveClass("flex", "min-h-screen"); }); it("fetches /auth/config on mount", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(global.fetch).toHaveBeenCalledWith("http://localhost:3001/auth/config"); }); }); it("renders OAuth button when OIDC provider is in config", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); }); it("renders only LoginForm when only email provider is configured", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); }); it("renders both OAuth button and LoginForm with divider when both providers present", async (): Promise => { mockFetchConfig(BOTH_PROVIDERS_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); expect(screen.getByText(/or continue with email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); it("does not render divider when only OAuth providers present", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); expect(screen.queryByText(/or continue with email/i)).not.toBeInTheDocument(); }); it("falls back to email-only on fetch failure", async (): Promise => { mockFetchFailure(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); }); it("falls back to email-only on non-ok response", async (): Promise => { (global.fetch as Mock).mockResolvedValueOnce({ ok: false, status: 500, }); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect(screen.queryByRole("button", { name: /continue with/i })).not.toBeInTheDocument(); }); it("calls signIn.oauth2 when OAuth button is clicked", async (): Promise => { mockFetchConfig(OAUTH_ONLY_CONFIG); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByRole("button", { name: /continue with authentik/i })).toBeInTheDocument(); }); await user.click(screen.getByRole("button", { name: /continue with authentik/i })); expect(mockOAuth2).toHaveBeenCalledWith({ providerId: "authentik", callbackURL: "/", }); }); it("calls signIn.email and redirects on success", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ data: { user: {} } }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password123"); await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { expect(mockSignInEmail).toHaveBeenCalledWith({ email: "test@example.com", password: "password123", }); }); await waitFor((): void => { expect(mockPush).toHaveBeenCalledWith("/tasks"); }); }); it("shows error banner on sign-in failure", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockResolvedValueOnce({ error: { message: "Invalid credentials" }, }); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "wrong"); await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { expect(screen.getByText("Invalid credentials")).toBeInTheDocument(); }); expect(mockPush).not.toHaveBeenCalled(); }); it("shows generic error on unexpected sign-in exception", async (): Promise => { mockFetchConfig(EMAIL_ONLY_CONFIG); mockSignInEmail.mockRejectedValueOnce(new Error("Network failure")); const user = userEvent.setup(); render(); await waitFor((): void => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); await user.type(screen.getByLabelText(/email/i), "test@example.com"); await user.type(screen.getByLabelText(/password/i), "password"); await user.click(screen.getByRole("button", { name: /continue/i })); await waitFor((): void => { expect( screen.getByText("Something went wrong. Please try again in a moment.") ).toBeInTheDocument(); }); }); });