diff --git a/web/src/routes/Login.test.tsx b/web/src/routes/Login.test.tsx new file mode 100644 index 000000000..be3d62532 --- /dev/null +++ b/web/src/routes/Login.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, fireEvent, act } from "@testing-library/react"; +import { Routes, Route } from "react-router-dom"; +import { Login } from "./Login"; +import { ProtectedRoute } from "./ProtectedRoute"; +import { renderWithProviders } from "../test/renderWithProviders"; +import { login as apiLogin, ApiError } from "../api/client"; +import { setStoredToken } from "../test/jwt"; + +// Keep the real ApiError (Login branches on `instanceof ApiError`) but stub the +// network + AuthProvider's registration hooks. +vi.mock("../api/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + login: vi.fn(), + logout: vi.fn(), + setOnTokensChanged: vi.fn(), + setOnUnauthenticated: vi.fn(), + }; +}); + +const mockApiLogin = vi.mocked(apiLogin); + +// /dashboard is behind the real ProtectedRoute, so navigation there only +// succeeds if login actually established authenticated state — a bare public +// route would false-green if login stopped authenticating. +function tree() { + return ( + + } /> + }> + dashboard (protected)} /> + + + ); +} + +function fillCredentials(email: string, password: string) { + fireEvent.change(screen.getByLabelText(/Email/), { target: { value: email } }); + fireEvent.change(screen.getByLabelText(/Password/), { target: { value: password } }); +} + +// resetAllMocks (not clearAllMocks) so a per-test implementation never leaks +// into the next case. +beforeEach(() => vi.resetAllMocks()); + +describe("Login", () => { + it("bounces an unauthenticated visit to /login, then returns to the original route after sign-in", async () => { + mockApiLogin.mockImplementation(async () => { + setStoredToken({ sub: "u1", role: "Sales" }); // server issued a session + return { accessToken: "a", refreshToken: "r", expiresAt: "2099-01-01T00:00:00Z" }; + }); + // Land on the protected route while logged out → ProtectedRoute redirects to + // /login, preserving `from = /dashboard` in router state. + renderWithProviders(tree(), { route: "/dashboard", token: null }); + + expect(await screen.findByRole("button", { name: "Sign in" })).toBeInTheDocument(); + expect(screen.queryByText("dashboard (protected)")).not.toBeInTheDocument(); + + fillCredentials("owner@farm.co", "pw"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + }); + + expect(mockApiLogin).toHaveBeenCalledWith({ email: "owner@farm.co", password: "pw" }); + // Returned to the originally requested route, now authenticated. + expect(await screen.findByText("dashboard (protected)")).toBeInTheDocument(); + }); + + it("shows an invalid-credentials message on a 401 and stays on /login", async () => { + mockApiLogin.mockRejectedValue(new ApiError(401, "Unauthorized", "bad creds")); + renderWithProviders(tree(), { route: "/login", token: null }); + + fillCredentials("owner@farm.co", "wrong"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + }); + + expect(await screen.findByText("Invalid email or password.")).toBeInTheDocument(); + expect(screen.queryByText("dashboard (protected)")).not.toBeInTheDocument(); + }); + + it("shows a generic error when the network fails (a non-ApiError rejection)", async () => { + mockApiLogin.mockRejectedValue(new TypeError("Failed to fetch")); + renderWithProviders(tree(), { route: "/login", token: null }); + + fillCredentials("owner@farm.co", "pw"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + }); + + expect(await screen.findByText(/Could not sign in/)).toBeInTheDocument(); + }); + + it("disables submit while a sign-in is in flight, then re-enables it on failure", async () => { + let rejectLogin!: (err: unknown) => void; + mockApiLogin.mockReturnValue(new Promise((_, reject) => (rejectLogin = reject))); + renderWithProviders(tree(), { route: "/login", token: null }); + + fillCredentials("owner@farm.co", "pw"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Sign in" })); + }); + + // in-flight (login promise still pending): label switched and the button is + // disabled, blocking a double submit. + expect(screen.getByRole("button", { name: "Signing in…" })).toBeDisabled(); + + await act(async () => { + rejectLogin(new ApiError(401, "Unauthorized", "bad creds")); + }); + + // settled: re-enabled and back to the idle label + expect(await screen.findByRole("button", { name: "Sign in" })).toBeEnabled(); + }); +}); diff --git a/web/src/routes/ProtectedRoute.test.tsx b/web/src/routes/ProtectedRoute.test.tsx new file mode 100644 index 000000000..446a78e08 --- /dev/null +++ b/web/src/routes/ProtectedRoute.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { screen } from "@testing-library/react"; +import { Routes, Route } from "react-router-dom"; +import { ProtectedRoute } from "./ProtectedRoute"; +import { renderWithProviders } from "../test/renderWithProviders"; + +// A minimal route tree: "/" is behind the gate, "/login" is the public landing. +function tree() { + return ( + + login screen} /> + }> + protected home} /> + + + ); +} + +describe("ProtectedRoute", () => { + it("redirects to /login when there is no session", () => { + renderWithProviders(tree(), { route: "/", token: null }); + expect(screen.getByText("login screen")).toBeInTheDocument(); + expect(screen.queryByText("protected home")).not.toBeInTheDocument(); + }); + + it("renders the outlet when authenticated", () => { + renderWithProviders(tree(), { route: "/", token: { sub: "u1", role: "Admin" } }); + expect(screen.getByText("protected home")).toBeInTheDocument(); + expect(screen.queryByText("login screen")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/test/renderWithProviders.tsx b/web/src/test/renderWithProviders.tsx new file mode 100644 index 000000000..065f004b8 --- /dev/null +++ b/web/src/test/renderWithProviders.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { render } from "@testing-library/react"; +import { AuthProvider } from "../auth/AuthContext"; +import { setStoredToken } from "./jwt"; +import { clearTokens } from "../auth/tokenStore"; + +// Shared render harness for screen tests: wraps the UI in a MemoryRouter (so +// components using router hooks / navigation work) and the real AuthProvider (so +// role gating reflects a seeded token). Pass `token` to start authenticated as a +// given role, or omit/null for a logged-out session. Not matched by the Vitest +// `include` glob, so importing it never registers a suite. +export function renderWithProviders( + ui: ReactNode, + opts: { route?: string; token?: Record | null } = {}, +) { + if (opts.token) setStoredToken(opts.token); + else clearTokens(); + + return render( + + {ui} + , + ); +}