WFDASH-WEBAPP/src/lib/stock-status.test.ts
SJCoetz e9cf8aaf04 feat(stock): implement stock management workflow
This commit adds the complete v2/v3 stock management functionality:
- add new stock-specific item statuses and update the status visual map
- implement full stock purchase API suite (create, list, retrieve, send, receive)
- reorganize sidebar navigation to group stock-related pages together
- add stock item CRUD APIs, status utilities, low stock filtering, and surplus booking logic
- add database migration for the `stock_items.target_qty` field
- update type definitions and add comprehensive tests for all new stock components
2026-07-15 21:53:20 +02:00

137 lines
5.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
getStockStatus,
isLowStock,
isLowStockItem,
STOCK_STATUS_STYLES,
type StockStatus,
} from "./stock-status";
describe("getStockStatus", () => {
it("returns 'ok' when current_qty is strictly above min_qty", () => {
expect(getStockStatus(10, 5, true)).toBe<StockStatus>("ok");
expect(getStockStatus(1, 0, true)).toBe<StockStatus>("ok");
});
it("returns 'low' when current_qty equals min_qty", () => {
expect(getStockStatus(5, 5, true)).toBe<StockStatus>("low");
});
it("returns 'low' when current_qty is between 0 and min_qty (exclusive of 0)", () => {
expect(getStockStatus(1, 5, true)).toBe<StockStatus>("low");
expect(getStockStatus(3, 5, true)).toBe<StockStatus>("low");
});
it("returns 'out' when current_qty is zero", () => {
expect(getStockStatus(0, 5, true)).toBe<StockStatus>("out");
expect(getStockStatus(0, 0, true)).toBe<StockStatus>("out");
});
it("returns 'out' when current_qty is negative (treats as a bug, not a clamp)", () => {
expect(getStockStatus(-1, 5, true)).toBe<StockStatus>("out");
expect(getStockStatus(-100, 0, true)).toBe<StockStatus>("out");
});
it("returns 'inactive' regardless of qty when is_active is false", () => {
expect(getStockStatus(10, 5, false)).toBe<StockStatus>("inactive");
expect(getStockStatus(0, 5, false)).toBe<StockStatus>("inactive");
expect(getStockStatus(-1, 5, false)).toBe<StockStatus>("inactive");
});
it("treats null/undefined qty as zero (so missing fields default to 'out')", () => {
expect(getStockStatus(null, 5, true)).toBe<StockStatus>("out");
expect(getStockStatus(undefined, 5, true)).toBe<StockStatus>("out");
expect(getStockStatus(5, null, true)).toBe<StockStatus>("ok");
});
it("coerces string-like numbers (PB sometimes returns numeric fields as strings)", () => {
// `Number("5")` is 5, so this should work the same as the numeric path.
expect(getStockStatus("5" as unknown as number, 5, true)).toBe<StockStatus>("low");
expect(getStockStatus(3, "5" as unknown as number, true)).toBe<StockStatus>("low");
});
});
describe("isLowStock", () => {
it("includes 'low' AND 'out' but not 'ok' or 'inactive'", () => {
expect(isLowStock(0, 5, true)).toBe(true); // out
expect(isLowStock(3, 5, true)).toBe(true); // low
expect(isLowStock(5, 5, true)).toBe(true); // low (boundary)
expect(isLowStock(6, 5, true)).toBe(false); // ok
expect(isLowStock(10, 5, false)).toBe(false); // inactive excluded
});
});
describe("isLowStockItem", () => {
it("delegates to isLowStock using the StockItem field shape", () => {
expect(
isLowStockItem({ current_qty: 0, min_qty: 5, is_active: true }),
).toBe(true);
expect(
isLowStockItem({ current_qty: 100, min_qty: 5, is_active: true }),
).toBe(false);
// Inactive row with low stock — still excluded from the low-stock filter.
expect(
isLowStockItem({ current_qty: 0, min_qty: 5, is_active: false }),
).toBe(false);
});
});
describe("STOCK_STATUS_STYLES", () => {
it("exposes a labelled entry for every status", () => {
const labels = Object.values(STOCK_STATUS_STYLES).map((s) => s.label);
expect(labels).toContain("In stock");
expect(labels).toContain("Low stock");
expect(labels).toContain("Out of stock");
expect(labels).toContain("Inactive");
});
it("uses a red-leaning hex for 'out' so the list-page badge reads as urgent", () => {
const outHex = STOCK_STATUS_STYLES.out.hex.toLowerCase();
// #b91c1c is red-700. We don't pin the exact value — only assert
// the "red" intent: starts with #b and has a small green channel
// (1c). Future palette tweaks should not break this test.
expect(outHex.startsWith("#b")).toBe(true);
});
});
/* ============================================================================
* Reorder-alert badge test
*
* Pinned as a separate describe block so the "is this row flagged as
* needs-reordering" contract has a clearly-named home. A future
* "this row needs to be re-ordered" UI cue (toast, banner, dashboard
* widget) can target the same helper and add its own describe here.
* ========================================================================== */
describe("reorder-alert badge logic", () => {
it("flags 'low' as needing reorder (the operator's amber signal)", () => {
const status = getStockStatus(3, 5, true);
expect(status).toBe("low");
expect(STOCK_STATUS_STYLES[status].label).toBe("Low stock");
});
it("flags 'out' as needing reorder (the operator's red signal)", () => {
const status = getStockStatus(0, 5, true);
expect(status).toBe("out");
expect(STOCK_STATUS_STYLES[status].label).toBe("Out of stock");
});
it("does NOT flag 'ok' or 'inactive' as needing reorder", () => {
expect(getStockStatus(100, 5, true)).toBe("ok");
expect(getStockStatus(100, 5, false)).toBe("inactive");
});
it("treats the boundary case (current == min) as 'low', not 'ok'", () => {
// Boundary matters: a 5/5 item should already be in the
// re-order queue, not at full health.
expect(getStockStatus(5, 5, true)).toBe("low");
});
it("preserves the badge label across the list-page and detail-page call sites", () => {
// Same helper, same palette — no per-page override that could
// drift between the list and the detail view.
const s1 = getStockStatus(2, 10, true);
const s2 = getStockStatus(0, 10, true);
expect(STOCK_STATUS_STYLES[s1].label).toBe("Low stock");
expect(STOCK_STATUS_STYLES[s2].label).toBe("Out of stock");
});
});