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
149 changes: 149 additions & 0 deletions web/src/routes/HistoryPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, act } from "@testing-library/react";
import { HistoryPage } from "./HistoryPage";
import { renderWithProviders } from "../test/renderWithProviders";
import {
adjustDailyEntry, listDailyEntries, listEggGrades, listFlocks,
} from "../api/cluckwork";
import type { DailyEntry, EggGrade, Flock } from "../api/cluckwork";

// HistoryPage's only runtime dep on the API module is the network seam; mock all
// of it. ApiError comes from ../api/client (unmocked, real) so errText's
// instanceof checks still hold. useAuth + <Link> ride on renderWithProviders.
vi.mock("../api/cluckwork", () => ({
listFlocks: vi.fn(),
listEggGrades: vi.fn(),
listDailyEntries: vi.fn(),
getDailyEntry: vi.fn(),
adjustDailyEntry: vi.fn(),
voidDailyEntry: vi.fn(),
}));

const mockListFlocks = vi.mocked(listFlocks);
const mockListEggGrades = vi.mocked(listEggGrades);
const mockListDailyEntries = vi.mocked(listDailyEntries);
const mockAdjustDailyEntry = vi.mocked(adjustDailyEntry);

const FLOCK: Flock = {
id: "f1", farmId: "farm1", houseId: "h1", name: "Hen House 1", breed: "ISA",
placementDate: "2026-01-01", initialCount: 100, currentBirds: 98, status: "Active",
};
const ARCHIVED_FLOCK: Flock = { ...FLOCK, id: "f2", name: "Old Coop", status: "Archived" };
const GRADE_A: EggGrade = { id: "gr1", farmId: "farm1", name: "Grade A", gradeType: "Size", sortOrder: 1, isSaleable: true, active: true };
const GRADE_B: EggGrade = { id: "gr2", farmId: "farm1", name: "Grade B", gradeType: "Size", sortOrder: 2, isSaleable: true, active: true };

// sellable = 100 − 2 − 3 − 5 = 90; two graded lines summing to 60 (within).
const SUBMITTED: DailyEntry = {
id: "de1", farmId: "farm1", houseId: "h1", flockId: "f1", date: "2026-07-19", status: "Submitted",
totalEggs: 100, crackedEggs: 2, dirtyEggs: 3, discardedEggs: 5, mortalityCount: 1,
grades: [{ eggGradeId: "gr1", quantity: 40 }, { eggGradeId: "gr2", quantity: 20 }],
version: 1, adjustReason: null, voidReason: null, lockedAtUtc: null, adjustedFrom: null,
};
const DRAFT: DailyEntry = { ...SUBMITTED, id: "de2", date: "2026-07-18", status: "Draft", grades: [] };
const DRAFT_ARCHIVED: DailyEntry = { ...DRAFT, id: "de3", flockId: "f2" };

const ADMIN = { sub: "u1", role: "Admin" };

beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
// jsdom has no layout engine; the panel's focus effect calls scrollIntoView.
Element.prototype.scrollIntoView = vi.fn();
mockListFlocks.mockResolvedValue([FLOCK]);
mockListEggGrades.mockResolvedValue([GRADE_A, GRADE_B]);
mockListDailyEntries.mockResolvedValue([]);
});

async function openAdjustPanel() {
renderWithProviders(<HistoryPage />, { token: ADMIN });
fireEvent.click(await screen.findByRole("button", { name: "adjust" }));
}

describe("HistoryPage adjust — sellable guard", () => {
it("blocks and warns when the graded lines SUM past sellable (neither line alone over)", async () => {
mockListDailyEntries.mockResolvedValue([SUBMITTED]);
await openAdjustPanel();

// 46 + 45 = 91 > sellable 90, yet neither line individually exceeds 90 —
// so this only fails if the guard actually SUMS the lines
fireEvent.change(screen.getByLabelText("Grade A"), { target: { value: "46" } });
fireEvent.change(screen.getByLabelText("Grade B"), { target: { value: "45" } });
fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "recount" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save adjustment" }));
});

expect(await screen.findByRole("alert")).toHaveTextContent(/cannot exceed total eggs/);
expect(mockAdjustDailyEntry).not.toHaveBeenCalled(); // client cap short-circuits the write
});

it("submits the corrected lines at the exact boundary sum === sellable (guard is >, not >=)", async () => {
mockListDailyEntries.mockResolvedValue([SUBMITTED]);
mockAdjustDailyEntry.mockResolvedValue({ id: "de1", status: "ManagerAdjusted", version: 2 });
await openAdjustPanel();

fireEvent.change(screen.getByLabelText("Grade A"), { target: { value: "45" } });
fireEvent.change(screen.getByLabelText("Grade B"), { target: { value: "45" } }); // 90 === sellable
fireEvent.change(screen.getByLabelText(/Reason/), { target: { value: "recount" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Save adjustment" }));
});

expect(await screen.findByRole("status")).toHaveTextContent(/adjusted/);
const [id, body] = mockAdjustDailyEntry.mock.calls[0];
expect(id).toBe("de1");
expect(body).toMatchObject({
version: 1, totalEggs: 100, crackedEggs: 2, dirtyEggs: 3, discardedEggs: 5, mortalityCount: 1,
reason: "recount", grades: [{ eggGradeId: "gr1", quantity: 45 }, { eggGradeId: "gr2", quantity: 45 }],
});
});
});

describe("HistoryPage draft edit link", () => {
it("links a draft row to the Daily entry screen with its flock and date in the query", async () => {
mockListDailyEntries.mockResolvedValue([DRAFT]);
renderWithProviders(<HistoryPage />, { token: ADMIN });

const link = await screen.findByRole("link", { name: "edit" });
expect(link).toHaveAttribute("href", "/daily-entry?flockId=f1&date=2026-07-18");
});

it("omits the edit link when the draft's flock is archived", async () => {
mockListFlocks.mockResolvedValue([ARCHIVED_FLOCK]);
mockListDailyEntries.mockResolvedValue([DRAFT_ARCHIVED]);
renderWithProviders(<HistoryPage />, { token: ADMIN });

// Wait for BOTH the row AND the flock metadata (the filter lists "Old Coop")
// so the missing link reflects the archived status — not an unrendered row or
// an unresolved flock (codex review of PR #122 / #86).
await screen.findByText("2026-07-18");
await screen.findByRole("option", { name: "Old Coop" });
expect(screen.queryByRole("link", { name: "edit" })).not.toBeInTheDocument();
});
});

describe("HistoryPage role gating", () => {
// adjust/void are gated on isAdmin = Admin || Manager (claims.ts); every other
// role — including a plain Worker with no role claim — sees neither control.
it.each([
{ label: "Admin", token: { sub: "u1", role: "Admin" }, allowed: true },
{ label: "Manager", token: { sub: "u1", role: "Manager" }, allowed: true },
{ label: "Sales", token: { sub: "u1", role: "Sales" }, allowed: false },
{ label: "ReadOnly", token: { sub: "u1", role: "ReadOnly" }, allowed: false },
{ label: "Worker (no role claim)", token: { sub: "u1" }, allowed: false },
])("$label sees the adjust/void controls: $allowed", async ({ token, allowed }) => {
mockListDailyEntries.mockResolvedValue([SUBMITTED]);
renderWithProviders(<HistoryPage />, { token });

await screen.findByText("2026-07-19"); // the submitted (correctable) row
const adjust = screen.queryByRole("button", { name: "adjust" });
const voidBtn = screen.queryByRole("button", { name: "void" });
if (allowed) {
expect(adjust).toBeInTheDocument();
expect(voidBtn).toBeInTheDocument();
} else {
expect(adjust).not.toBeInTheDocument();
expect(voidBtn).not.toBeInTheDocument();
}
});
});
235 changes: 235 additions & 0 deletions web/src/routes/SalesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, within, fireEvent, act } from "@testing-library/react";
import { SalesPage } from "./SalesPage";
import { renderWithProviders } from "../test/renderWithProviders";
import {
addOrderItem, createOrder, getOrder, listCustomers, listEggGrades,
listOrderPayments, listOrders, listProducts, updateOrderItem,
} from "../api/cluckwork";
import type { Customer, EggGrade, OrderItem, Product, SalesOrder } from "../api/cluckwork";

// Keep the REAL formatMoney + parseMoneyToMinorUnits (the money math under test)
// via importOriginal; stub only the network seam. Every network call the screen
// can make is stubbed — even the ones no current test triggers (confirm/cancel/
// void/remove/pay) — so a future edit that clicks them can't silently hit the
// real fetch client. The screen also uses useAuth + the router → renderWithProviders.
vi.mock("../api/cluckwork", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/cluckwork")>();
return {
...actual,
listCustomers: vi.fn(),
listProducts: vi.fn(),
listEggGrades: vi.fn(),
listOrders: vi.fn(),
listOrderPayments: vi.fn(),
createOrder: vi.fn(),
getOrder: vi.fn(),
addOrderItem: vi.fn(),
updateOrderItem: vi.fn(),
removeOrderItem: vi.fn(),
cancelOrder: vi.fn(),
confirmOrder: vi.fn(),
voidOrder: vi.fn(),
recordPayment: vi.fn(),
voidPayment: vi.fn(),
};
});

const mockListCustomers = vi.mocked(listCustomers);
const mockListProducts = vi.mocked(listProducts);
const mockListEggGrades = vi.mocked(listEggGrades);
const mockListOrders = vi.mocked(listOrders);
const mockListOrderPayments = vi.mocked(listOrderPayments);
const mockCreateOrder = vi.mocked(createOrder);
const mockGetOrder = vi.mocked(getOrder);
const mockAddOrderItem = vi.mocked(addOrderItem);
const mockUpdateOrderItem = vi.mocked(updateOrderItem);

const CUSTOMER: Customer = { id: "c1", name: "Acme Eggs", phone: "555", email: null, address: null, note: null };
// Only gr1 is saleable → the picker offers PRODUCT_A only; gr2/PRODUCT_B exists
// solely to resolve the second line's display name (allProducts).
const GRADE: EggGrade = { id: "gr1", farmId: "farm1", name: "Grade A", gradeType: "Size", sortOrder: 1, isSaleable: true, active: true };
const PRODUCT_A: Product = {
id: "p1", name: "Grade A Dozen", productType: "Egg", defaultUnit: "Dozen",
defaultPriceMinorUnits: 300, currencyCode: "USD", currencyMinorUnit: 2,
eggGradeId: "gr1", notes: null, active: true, version: 1,
};
const PRODUCT_B: Product = {
id: "p2", name: "Grade B Tray", productType: "Egg", defaultUnit: "Tray",
defaultPriceMinorUnits: 1000, currencyCode: "USD", currencyMinorUnit: 2,
eggGradeId: "gr2", notes: null, active: true, version: 1,
};

// An empty draft in the given currency — used to prove the parse honours the
// ORDER's currencyMinorUnit, not a hard-coded 2.
function draftEmpty(currencyMinorUnit: number, currencyCode: string, id = "o1"): SalesOrder {
return {
id, customerId: "c1", referenceNumber: "SO-1", orderDate: "2026-07-20",
status: "Draft", totalMinorUnits: 0, currencyCode, currencyMinorUnit, voidReason: null, items: [],
};
}

// A single-line draft for edit/display, price + scale parametrised by currency.
function draftWithItem(currencyMinorUnit: number, currencyCode: string, unitPrice: number, id = "o5"): SalesOrder {
const item: OrderItem = {
id: "e1", productId: "p1", eggGradeId: "gr1", unit: "Dozen", baseUnitFactor: 12,
quantity: 3, quantityBase: 36, unitPriceMinorUnits: unitPrice, currencyCode, currencyMinorUnit,
};
return { ...draftEmpty(currencyMinorUnit, currencyCode, id), referenceNumber: "SO-5", items: [item], totalMinorUnits: unitPrice * 3 };
}

// Two lines with DIFFERENT line totals so the order total can't be confused with
// any single line: A = 300×3 = 900 (9.00), B = 1000×2 = 2000 (20.00), order 2900.
const ITEM_A: OrderItem = {
id: "it1", productId: "p1", eggGradeId: "gr1", unit: "Dozen", baseUnitFactor: 12,
quantity: 3, quantityBase: 36, unitPriceMinorUnits: 300, currencyCode: "USD", currencyMinorUnit: 2,
};
const ITEM_B: OrderItem = {
id: "it2", productId: "p2", eggGradeId: "gr2", unit: "Tray", baseUnitFactor: 30,
quantity: 2, quantityBase: 60, unitPriceMinorUnits: 1000, currencyCode: "USD", currencyMinorUnit: 2,
};
const DRAFT_TWO: SalesOrder = {
...draftEmpty(2, "USD", "o2"), referenceNumber: "SO-2", totalMinorUnits: 2900, items: [ITEM_A, ITEM_B],
};

// role irrelevant to add/update/display (Admin only unlocks void + payments,
// which these tests don't touch) — just a stable authenticated session.
const ADMIN = { sub: "u1", role: "Admin" };

beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
mockListCustomers.mockResolvedValue([CUSTOMER]);
mockListProducts.mockResolvedValue([PRODUCT_A, PRODUCT_B]);
mockListEggGrades.mockResolvedValue([GRADE]);
mockListOrders.mockResolvedValue([]);
mockListOrderPayments.mockResolvedValue({
items: [], paidMinorUnits: 0, outstandingMinorUnits: 0, totalMinorUnits: 0,
currencyCode: "USD", currencyMinorUnit: 2,
});
});

// The create form only appears once customers + the (initially empty) order list
// have loaded; wait on it so the mount effects have settled.
async function renderReady() {
renderWithProviders(<SalesPage />, { token: ADMIN });
await screen.findByRole("button", { name: "New draft order" });
}

async function createDraft(order: SalesOrder) {
mockCreateOrder.mockResolvedValue({ id: order.id });
mockGetOrder.mockResolvedValue(order);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "New draft order" }));
});
await screen.findByText(new RegExp(order.referenceNumber)); // panel header
}

async function openOrder(order: SalesOrder, rowName: RegExp) {
mockListOrders.mockResolvedValue([order]);
mockGetOrder.mockResolvedValue(order);
await renderReady();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "open" }));
});
return screen.findByRole("row", { name: rowName });
}

describe("SalesPage line display", () => {
it("shows per-line base eggs and money, with the order total distinct from any single line", async () => {
const rowA = await openOrder(DRAFT_TWO, /Grade A Dozen/);

// baseUnitFactor > 1 → the "(N eggs)" note; quantityBase in the Eggs column
expect(within(rowA).getByText(/per dozen \(12 eggs\)/)).toBeInTheDocument();
expect(within(rowA).getByText("36")).toBeInTheDocument();
// line total = unitPrice × quantity (300 × 3), NOT the order total
expect(within(rowA).getByText("3.00 USD")).toBeInTheDocument();
expect(within(rowA).getByText("9.00 USD")).toBeInTheDocument();

const rowB = screen.getByRole("row", { name: /Grade B Tray/ });
expect(within(rowB).getByText("60")).toBeInTheDocument();
expect(within(rowB).getByText("20.00 USD")).toBeInTheDocument(); // 1000 × 2

// order total (2900) differs from both line totals (900, 2000) → this pins
// that the line cell renders its own line, not active.totalMinorUnits
expect(screen.getByText(/Total: 29\.00 USD/)).toBeInTheDocument();
});

it("omits the egg-multiplier note and shows eggs === quantity for a per-egg line (factor 1)", async () => {
const eggItem: OrderItem = { ...ITEM_A, id: "it9", unit: "Egg", baseUnitFactor: 1, quantity: 30, quantityBase: 30 };
const order: SalesOrder = { ...DRAFT_TWO, id: "o3", items: [eggItem], totalMinorUnits: 9000 };
const row = await openOrder(order, /Grade A Dozen/);

expect(within(row).getByText(/per egg/)).toBeInTheDocument();
expect(within(row).queryByText(/eggs\)/)).not.toBeInTheDocument(); // no "(… eggs)" suffix at factor 1
expect(within(row).getAllByText("30")).toHaveLength(2); // quantity and quantityBase coincide at factor 1
});

it("renders line money at the order's currency scale (3-decimal)", async () => {
// 1500 minor units @ 3 decimals → "1.500 BHD" (would read "15.00" at 2dp) —
// proves formatMoney uses the item's currencyMinorUnit, not a hard-coded 2.
const row = await openOrder(draftWithItem(3, "BHD", 1500, "o4"), /Grade A Dozen/);
expect(within(row).getByText("1.500 BHD")).toBeInTheDocument(); // unit price
expect(within(row).getByText("4.500 BHD")).toBeInTheDocument(); // line total 1500 × 3
});
});

describe("SalesPage unit-price parsing", () => {
// Different currency scales prove parseMoneyToMinorUnits uses the order's
// currencyMinorUnit: "5" is 5 in JPY (0dp) but would be 500 at 2dp.
it.each([
{ code: "USD", minorUnit: 2, typed: "1.50", expected: 150 },
{ code: "JPY", minorUnit: 0, typed: "5", expected: 5 },
{ code: "BHD", minorUnit: 3, typed: "1.5", expected: 1500 },
])("parses the entered price into $code minor units on add ($typed → $expected)", async ({ code, minorUnit, typed, expected }) => {
const order = draftEmpty(minorUnit, code);
await renderReady();
await createDraft(order);
mockAddOrderItem.mockResolvedValue({ orderId: order.id, itemId: "new" });

fireEvent.change(screen.getByLabelText(/Unit price/), { target: { value: typed } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add line" }));
});

// full body: a swapped/dropped field (quantity, unit, productId) would fail too
expect(mockAddOrderItem.mock.calls[0][1]).toMatchObject({
productId: "p1", quantity: 30, unit: "Dozen", unitPriceMinorUnits: expected,
});
});

it("omits the unit price from the request when the field is blank", async () => {
await renderReady();
await createDraft(draftEmpty(2, "USD"));
mockAddOrderItem.mockResolvedValue({ orderId: "o1", itemId: "new" });

fireEvent.change(screen.getByLabelText(/Unit price/), { target: { value: "" } });
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add line" }));
});

const body = mockAddOrderItem.mock.calls[0][1];
expect(body).toMatchObject({ productId: "p1", quantity: 30, unit: "Dozen" });
expect(body.unitPriceMinorUnits).toBeUndefined(); // server falls back to the product default
});

it.each([
{ code: "USD", minorUnit: 2, seedPrice: 300, typed: "2.50", expected: 250 },
{ code: "JPY", minorUnit: 0, seedPrice: 100, typed: "5", expected: 5 },
])("parses the edited price into $code minor units on update ($typed → $expected)", async ({ code, minorUnit, seedPrice, typed, expected }) => {
mockUpdateOrderItem.mockResolvedValue(undefined);
const row = await openOrder(draftWithItem(minorUnit, code, seedPrice), /Grade A Dozen/);

fireEvent.click(within(row).getByRole("button", { name: "edit" }));
const editRow = screen.getByRole("row", { name: /Grade A Dozen/ });
// query the price input by its accessible name (not a positional spinbutton index)
fireEvent.change(within(editRow).getByRole("spinbutton", { name: /unit price/i }), { target: { value: typed } });
await act(async () => {
fireEvent.click(within(editRow).getByRole("button", { name: "save" }));
});

expect(mockUpdateOrderItem.mock.calls[0][2]).toMatchObject({
quantity: 3, unitPriceMinorUnits: expected, // quantity prefilled from the item, unchanged
});
});
});
2 changes: 2 additions & 0 deletions web/src/routes/SalesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,9 +386,11 @@ export function SalesPage() {
{editItemId === i.id ? (
<>
<td><input className="cell" type="number" min={1} value={editQty}
aria-label="Edit quantity"
onChange={(e) => setEditQty(Math.max(1, e.target.valueAsNumber || 1))} /></td>
<td>—</td>
<td><input className="cell" type="number" min={0}
aria-label="Edit unit price"
step={10 ** -active.currencyMinorUnit} value={editPrice}
onChange={(e) => setEditPrice(e.target.value)} /></td>
<td>—</td>
Expand Down
Loading