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
133 changes: 133 additions & 0 deletions web/src/auth/AuthContext.lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { AuthProvider } from "./AuthContext";
import { useAuth } from "./useAuth";
import { setStoredToken } from "../test/jwt";
import { clearTokens } from "./tokenStore";
import { login as apiLogin, logout as apiLogout, setOnTokensChanged, setOnUnauthenticated } from "../api/client";

// Mock the transport: AuthProvider drives session STATE, the client drives the
// network. We simulate the server side (login stores a token, logout clears it)
// and capture the refresh/unauth callbacks AuthProvider registers.
vi.mock("../api/client", () => ({
login: vi.fn(),
logout: vi.fn(),
setOnTokensChanged: vi.fn(),
setOnUnauthenticated: vi.fn(),
}));

const mockApiLogin = vi.mocked(apiLogin);
const mockApiLogout = vi.mocked(apiLogout);
const mockSetOnTokensChanged = vi.mocked(setOnTokensChanged);
const mockSetOnUnauthenticated = vi.mocked(setOnUnauthenticated);

function Probe() {
const { role, isAdmin, isAuthenticated, login, logout } = useAuth();
return (
<div>
<span data-testid="role">{role}</span>
<span data-testid="admin">{String(isAdmin)}</span>
<span data-testid="auth">{String(isAuthenticated)}</span>
<button onClick={() => void login("a@b.co", "pw")}>login</button>
<button onClick={() => void logout()}>logout</button>
</div>
);
}

const renderAuth = () => render(<AuthProvider><Probe /></AuthProvider>);

beforeEach(() => {
vi.clearAllMocks();
clearTokens();
});

describe("AuthProvider lifecycle", () => {
it("awaits login, then derives role from the STORED token (not the response)", async () => {
// Deferred so we can prove auth does not flip until login resolves, and use a
// SALES token so the test fails if login hard-coded Admin/true instead of
// reading storage.
let resolveLogin!: () => void;
mockApiLogin.mockImplementation(
() =>
new Promise((resolve) => {
resolveLogin = () => {
setStoredToken({ sub: "u1", role: "Sales" }); // server issued a Sales token
resolve({ accessToken: "a", refreshToken: "r", expiresAt: "2099-01-01T00:00:00Z" });
};
}),
);
renderAuth();
expect(screen.getByTestId("auth")).toHaveTextContent("false");

fireEvent.click(screen.getByText("login"));
await Promise.resolve(); // let the click's microtasks run
expect(screen.getByTestId("auth")).toHaveTextContent("false"); // still pending → not authenticated

await act(async () => resolveLogin());

expect(mockApiLogin).toHaveBeenCalledWith({ email: "a@b.co", password: "pw" });
expect(screen.getByTestId("role")).toHaveTextContent("Sales");
expect(screen.getByTestId("admin")).toHaveTextContent("false");
expect(screen.getByTestId("auth")).toHaveTextContent("true");
});

it("logout clears auth state back to Worker", async () => {
setStoredToken({ sub: "u1", role: "Admin" }); // start logged in
mockApiLogout.mockImplementation(async () => clearTokens());
renderAuth();
expect(screen.getByTestId("admin")).toHaveTextContent("true");

await act(async () => {
fireEvent.click(screen.getByText("logout"));
});

expect(mockApiLogout).toHaveBeenCalledTimes(1);
expect(screen.getByTestId("role")).toHaveTextContent("Worker");
expect(screen.getByTestId("admin")).toHaveTextContent("false");
expect(screen.getByTestId("auth")).toHaveTextContent("false");
});

it("re-derives the role when the token is rotated (onTokensChanged from a refresh)", async () => {
setStoredToken({ sub: "u1", role: "Admin" });
renderAuth();
expect(screen.getByTestId("admin")).toHaveTextContent("true");

// Exactly one registration — catches a StrictMode double-fire / missing cleanup.
expect(mockSetOnTokensChanged).toHaveBeenCalledTimes(1);
const onTokensChanged = mockSetOnTokensChanged.mock.calls[0][0];
expect(onTokensChanged).toBeTypeOf("function");

// A transparent refresh rotated the token to a demoted role; the client fires
// the callback and the UI must follow within the token lifetime.
setStoredToken({ sub: "u1", role: "Sales" });
await act(async () => onTokensChanged!());

expect(screen.getByTestId("role")).toHaveTextContent("Sales");
expect(screen.getByTestId("admin")).toHaveTextContent("false");
});

it("drops authentication when onUnauthenticated fires (refresh exhausted)", async () => {
setStoredToken({ sub: "u1", role: "Admin" });
renderAuth();
expect(screen.getByTestId("auth")).toHaveTextContent("true");

expect(mockSetOnUnauthenticated).toHaveBeenCalledTimes(1);
const onUnauth = mockSetOnUnauthenticated.mock.calls[0][0];
expect(onUnauth).toBeTypeOf("function");
await act(async () => onUnauth!());

expect(screen.getByTestId("auth")).toHaveTextContent("false");
});

it("unregisters its client callbacks on unmount", () => {
setStoredToken({ sub: "u1", role: "Admin" });
const { unmount } = renderAuth();
mockSetOnTokensChanged.mockClear();
mockSetOnUnauthenticated.mockClear();

unmount();

expect(mockSetOnTokensChanged).toHaveBeenCalledWith(null);
expect(mockSetOnUnauthenticated).toHaveBeenCalledWith(null);
});
});
110 changes: 105 additions & 5 deletions web/src/routes/StockPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, within } from "@testing-library/react";
import { render, screen, within, fireEvent } from "@testing-library/react";
import { StockPage } from "./StockPage";
import { getStock } from "../api/cluckwork";
import type { StockRow } from "../api/cluckwork";
import { getStock, listEggLots, listEggLotMovements } from "../api/cluckwork";
import type { StockRow, EggLotRow, EggMovementRow } from "../api/cluckwork";

// Mock the API seam so the screen renders against controlled data — no network,
// no backend. This proves the component test harness handles an async data load,
Expand All @@ -14,13 +14,27 @@ vi.mock("../api/cluckwork", () => ({
}));

const mockGetStock = vi.mocked(getStock);
const mockListEggLots = vi.mocked(listEggLots);
const mockListEggLotMovements = vi.mocked(listEggLotMovements);

const ROWS: StockRow[] = [
{ eggGradeId: "g1", gradeName: "Grade A", available: 100, restricted: 0 },
{ eggGradeId: "g2", gradeName: "Grade B", available: 50, restricted: 5 },
];

beforeEach(() => mockGetStock.mockReset());
const LOTS: EggLotRow[] = [
{ id: "lot1", eggGradeId: "g1", productionDate: "2026-07-01", quantityProduced: 120, quantityAvailable: 99, restrictedUntil: null, dailyEntryId: "de1" },
];

const MOVEMENTS: EggMovementRow[] = [
{ id: "mv1", movementType: "Production", quantityDelta: 120, referenceType: "DailyEntry", referenceId: "de1", reason: null, createdAtUtc: "2026-07-01T08:00:00Z" },
];

beforeEach(() => {
mockGetStock.mockReset();
mockListEggLots.mockReset();
mockListEggLotMovements.mockReset();
});

describe("StockPage", () => {
it("shows a loading state until the stock request resolves", async () => {
Expand Down Expand Up @@ -55,7 +69,7 @@ describe("StockPage", () => {
expect(await screen.findByText("Grade A")).toBeInTheDocument();
// Scope the restricted-count assertion to Grade B's row so it pins that
// cell, not just "some 5 rendered somewhere".
const gradeBRow = screen.getByRole("row", { name: /Grade B/ });
const gradeBRow = screen.getByRole("row", { name: /Grade B\b/ });
expect(within(gradeBRow).getByText("5")).toBeInTheDocument();

// 100 + 50 = 150 across 2 grades — the client-side reduce.
Expand All @@ -66,3 +80,89 @@ describe("StockPage", () => {
).toBeInTheDocument();
});
});

describe("StockPage drill-down", () => {
async function renderWithData() {
mockGetStock.mockResolvedValue(ROWS);
render(<StockPage />);
await screen.findByText("Grade A");
}

it("expands a grade into its lots on 'lots', scoped to that grade", async () => {
mockListEggLots.mockResolvedValue(LOTS);
await renderWithData();

const gradeA = screen.getByRole("row", { name: /Grade A\b/ });
fireEvent.click(within(gradeA).getByRole("button", { name: "lots" }));

const lotRow = await screen.findByRole("row", { name: /2026-07-01/ });
expect(mockListEggLots).toHaveBeenCalledWith({ gradeId: "g1" });
expect(mockListEggLots).toHaveBeenCalledTimes(1);
// Values scoped to the lot row — pins WHERE they render, not just that they exist.
expect(within(lotRow).getByText("120")).toBeInTheDocument(); // quantityProduced
expect(within(lotRow).getByText("99")).toBeInTheDocument(); // quantityAvailable
// toggle text flips to "hide lots"; the other grade is untouched (still "lots").
expect(within(gradeA).getByRole("button", { name: "hide lots" })).toBeInTheDocument();
const gradeB = screen.getByRole("row", { name: /Grade B\b/ });
expect(within(gradeB).getByRole("button", { name: "lots" })).toBeInTheDocument();
});

it("shows the empty-lots hint when a grade has no lots", async () => {
mockListEggLots.mockResolvedValue([]);
await renderWithData();
const gradeB = screen.getByRole("row", { name: /Grade B\b/ });
fireEvent.click(within(gradeB).getByRole("button", { name: "lots" }));
expect(await screen.findByText(/No lots for this grade yet/)).toBeInTheDocument();
expect(mockListEggLots).toHaveBeenCalledWith({ gradeId: "g2" });
});

it("collapses the lots again on 'hide lots'", async () => {
mockListEggLots.mockResolvedValue(LOTS);
await renderWithData();
const gradeA = screen.getByRole("row", { name: /Grade A\b/ });
fireEvent.click(within(gradeA).getByRole("button", { name: "lots" }));
await screen.findByText("2026-07-01");
fireEvent.click(within(gradeA).getByRole("button", { name: "hide lots" }));
expect(screen.queryByText("2026-07-01")).not.toBeInTheDocument();
});

it("expands a lot into its movement ledger on 'history'", async () => {
mockListEggLots.mockResolvedValue(LOTS);
mockListEggLotMovements.mockResolvedValue(MOVEMENTS);
await renderWithData();

fireEvent.click(within(screen.getByRole("row", { name: /Grade A\b/ })).getByRole("button", { name: "lots" }));
await screen.findByText("2026-07-01");
// the lot row's history button
const lotRow = screen.getByRole("row", { name: /2026-07-01/ });
fireEvent.click(within(lotRow).getByRole("button", { name: "history" }));

const mvRow = await screen.findByRole("row", { name: /Production/ });
expect(mockListEggLotMovements).toHaveBeenCalledWith("lot1");
expect(within(mvRow).getByText("+120")).toBeInTheDocument(); // signed positive delta, scoped

// collapse: 'hide history' removes the ledger
fireEvent.click(within(lotRow).getByRole("button", { name: "hide history" }));
expect(screen.queryByText("Production")).not.toBeInTheDocument();
});

it("surfaces an error if the movement request fails", async () => {
mockListEggLots.mockResolvedValue(LOTS);
mockListEggLotMovements.mockRejectedValue(new Error("movements down"));
await renderWithData();
fireEvent.click(within(screen.getByRole("row", { name: /Grade A\b/ })).getByRole("button", { name: "lots" }));
const lotRow = await screen.findByRole("row", { name: /2026-07-01/ });
fireEvent.click(within(lotRow).getByRole("button", { name: "history" }));
expect(await screen.findByText(/Could not load the lot's movements/)).toBeInTheDocument();
});

it("surfaces an error if the lots request fails, without unmounting the grade table", async () => {
// toggleGrade awaits inside try/catch (not a mount-effect chain), so a lazily
// created rejection is awaited immediately — no dangling unhandled promise.
mockListEggLots.mockRejectedValue(new Error("lots down"));
await renderWithData();
fireEvent.click(within(screen.getByRole("row", { name: /Grade A\b/ })).getByRole("button", { name: "lots" }));
expect(await screen.findByText(/Could not load the grade's lots/)).toBeInTheDocument();
expect(screen.getByText("Grade A")).toBeInTheDocument(); // table still there
});
});
Loading