Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions web/src/routes/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("../api/client")>();
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 (
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<div>dashboard (protected)</div>} />
</Route>
</Routes>
);
}

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();
});
});
31 changes: 31 additions & 0 deletions web/src/routes/ProtectedRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Routes>
<Route path="/login" element={<div>login screen</div>} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<div>protected home</div>} />
</Route>
</Routes>
);
}

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();
});
});
25 changes: 25 additions & 0 deletions web/src/test/renderWithProviders.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null } = {},
) {
if (opts.token) setStoredToken(opts.token);
else clearTokens();

return render(
<MemoryRouter initialEntries={[opts.route ?? "/"]}>
<AuthProvider>{ui}</AuthProvider>
</MemoryRouter>,
);
}
Loading