// ABOUTME: Unit tests for the Login page component. // ABOUTME: Tests form rendering, validation, auth flow, and error handling. import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; // Mock next/navigation const mockPush = vi.fn(); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mockPush, }), })); // Mock PocketBase const mockAuthWithPassword = vi.fn(); const mockAuthWithOAuth2 = vi.fn(); const mockListAuthMethods = vi.fn(); vi.mock("@/lib/pocketbase", () => ({ pb: { collection: () => ({ authWithPassword: mockAuthWithPassword, authWithOAuth2: mockAuthWithOAuth2, listAuthMethods: mockListAuthMethods, }), }, })); import LoginPage from "./page"; describe("LoginPage", () => { beforeEach(() => { vi.clearAllMocks(); // Default: no OIDC configured, show email/password form mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: false, providers: [], }, }); }); describe("rendering", () => { it("renders the login form with email and password inputs", async () => { render(); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); }); it("renders a sign in button", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in/i }), ).toBeInTheDocument(); }); }); it("renders the PhaseFlow branding", () => { render(); expect(screen.getByText(/phaseflow/i)).toBeInTheDocument(); }); it("has email input with type email", async () => { render(); await waitFor(() => { const emailInput = screen.getByLabelText(/email/i); expect(emailInput).toHaveAttribute("type", "email"); }); }); it("has password input with type password", async () => { render(); await waitFor(() => { const passwordInput = screen.getByLabelText(/password/i); expect(passwordInput).toHaveAttribute("type", "password"); }); }); }); describe("form submission", () => { it("calls PocketBase auth with email and password on submit", async () => { mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "password123" } }); fireEvent.click(submitButton); await waitFor(() => { expect(mockAuthWithPassword).toHaveBeenCalledWith( "test@example.com", "password123", ); }); }); it("redirects to dashboard on successful login", async () => { mockAuthWithPassword.mockResolvedValueOnce({ token: "test-token" }); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "password123" } }); fireEvent.click(submitButton); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/"); }); }); it("shows loading state while authenticating", async () => { // Create a promise that we can control let resolveAuth: (value: unknown) => void = () => {}; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); mockAuthWithPassword.mockReturnValue(authPromise); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "password123" } }); fireEvent.click(submitButton); // Button should show loading state await waitFor(() => { expect( screen.getByRole("button", { name: /signing in/i }), ).toBeInTheDocument(); }); // Resolve the auth resolveAuth({ token: "test-token" }); }); it("disables form inputs while loading", async () => { let resolveAuth: (value: unknown) => void = () => {}; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); mockAuthWithPassword.mockReturnValue(authPromise); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "password123" } }); fireEvent.click(submitButton); await waitFor(() => { expect(emailInput).toBeDisabled(); expect(passwordInput).toBeDisabled(); expect(screen.getByRole("button")).toBeDisabled(); }); resolveAuth({ token: "test-token" }); }); }); describe("error handling", () => { it("shows error message on failed login", async () => { mockAuthWithPassword.mockRejectedValueOnce( new Error("Invalid credentials"), ); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument(); }); }); it("clears error when user starts typing again", async () => { mockAuthWithPassword.mockRejectedValueOnce( new Error("Invalid credentials"), ); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); // Start typing again fireEvent.change(emailInput, { target: { value: "new@example.com" } }); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("re-enables form after error", async () => { mockAuthWithPassword.mockRejectedValueOnce( new Error("Invalid credentials"), ); render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.change(passwordInput, { target: { value: "wrongpassword" } }); fireEvent.click(submitButton); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); // Form should be re-enabled expect(emailInput).not.toBeDisabled(); expect(passwordInput).not.toBeDisabled(); expect( screen.getByRole("button", { name: /sign in/i }), ).not.toBeDisabled(); }); }); describe("validation", () => { it("does not submit with empty email", async () => { render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(passwordInput, { target: { value: "password123" } }); fireEvent.click(submitButton); // Should not call auth expect(mockAuthWithPassword).not.toHaveBeenCalled(); }); it("does not submit with empty password", async () => { render(); // Wait for auth check to complete await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); const emailInput = screen.getByLabelText(/email/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); fireEvent.change(emailInput, { target: { value: "test@example.com" } }); fireEvent.click(submitButton); // Should not call auth expect(mockAuthWithPassword).not.toHaveBeenCalled(); }); }); describe("OIDC authentication", () => { beforeEach(() => { // Configure OIDC provider available mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: true, providers: [ { name: "oidc", displayName: "Pocket-ID", state: "test-state", codeVerifier: "test-verifier", codeChallenge: "test-challenge", codeChallengeMethod: "S256", authURL: "https://id.example.com/auth", }, ], }, }); }); it("shows OIDC button when provider is configured", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); }); it("hides email/password form when OIDC is available", async () => { render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); // Email/password form should not be visible expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument(); expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument(); }); it("calls authWithOAuth2 when OIDC button is clicked", async () => { mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token" }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); const oidcButton = screen.getByRole("button", { name: /sign in with pocket-id/i, }); fireEvent.click(oidcButton); await waitFor(() => { expect(mockAuthWithOAuth2).toHaveBeenCalledWith({ provider: "oidc" }); }); }); it("redirects to dashboard on successful OIDC login", async () => { mockAuthWithOAuth2.mockResolvedValueOnce({ token: "test-token", record: { id: "user-123" }, }); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); const oidcButton = screen.getByRole("button", { name: /sign in with pocket-id/i, }); fireEvent.click(oidcButton); await waitFor(() => { expect(mockPush).toHaveBeenCalledWith("/"); }); }); it("shows loading state during OIDC authentication", async () => { let resolveAuth: (value: unknown) => void = () => {}; const authPromise = new Promise((resolve) => { resolveAuth = resolve; }); mockAuthWithOAuth2.mockReturnValue(authPromise); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); const oidcButton = screen.getByRole("button", { name: /sign in with pocket-id/i, }); fireEvent.click(oidcButton); await waitFor(() => { expect( screen.getByRole("button", { name: /signing in/i }), ).toBeInTheDocument(); expect(screen.getByRole("button")).toBeDisabled(); }); resolveAuth({ token: "test-token" }); }); it("shows error message on OIDC failure", async () => { mockAuthWithOAuth2.mockRejectedValueOnce( new Error("Authentication cancelled"), ); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); const oidcButton = screen.getByRole("button", { name: /sign in with pocket-id/i, }); fireEvent.click(oidcButton); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); expect( screen.getByText(/authentication cancelled/i), ).toBeInTheDocument(); }); }); it("re-enables OIDC button after error", async () => { mockAuthWithOAuth2.mockRejectedValueOnce( new Error("Authentication cancelled"), ); render(); await waitFor(() => { expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).toBeInTheDocument(); }); const oidcButton = screen.getByRole("button", { name: /sign in with pocket-id/i, }); fireEvent.click(oidcButton); await waitFor(() => { expect(screen.getByRole("alert")).toBeInTheDocument(); }); // Button should be re-enabled expect( screen.getByRole("button", { name: /sign in with pocket-id/i }), ).not.toBeDisabled(); }); }); describe("fallback to email/password", () => { it("shows email/password form when OIDC is not configured", async () => { mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: false, providers: [], }, }); render(); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); }); it("shows email/password form when listAuthMethods fails", async () => { mockListAuthMethods.mockRejectedValue(new Error("Network error")); render(); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); }); }); it("does not show OIDC button when no providers configured", async () => { mockListAuthMethods.mockResolvedValue({ oauth2: { enabled: false, providers: [], }, }); render(); await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); }); expect( screen.queryByRole("button", { name: /sign in with pocket-id/i }), ).not.toBeInTheDocument(); }); }); });