WFDASH-WEBAPP/src/lib/server/stock-items-service.test.ts
SJCoetz c4125ee40a feat: add split-invoice stock PO workflow and supporting tooling
Ship full split-invoice stock purchase order workflow:
- Add 5 new MCP tools for per-line stock purchase item management and catalogue updates
- Add idempotent database migration adding sku, unit, and supplier_sku fields to stock_purchase_items
- Implement service layers for all new operations, including auto-closing the parent PO when all lines are fully collected
- Add complete test coverage for new services and API routes
- Update all relevant documentation including MCP API specs, agent instructions, and feature request logs
2026-08-07 00:53:29 +02:00

367 lines
12 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// --- PocketBase mock ---------------------------------------------------------
//
// Hoisted mock so the service module can call `pb.collection(...)`.
// We seed two in-memory maps: `stock_items` rows and a small `suppliers`
// lookup so the `expand: "supplier"` call returns a populated
// `expand.supplier.suppliername` for the service's JS-side sort.
const items = new Map<string, Record<string, unknown>>();
const suppliers = new Map<string, Record<string, unknown>>();
const getFullList = vi.fn(async (opts: { filter?: string; sort?: string; expand?: string } = {}) => {
let rows = Array.from(items.values());
if (opts.filter?.includes("is_active = true")) {
rows = rows.filter((r) => r.is_active === true);
}
if (opts.expand?.includes("supplier")) {
rows = rows.map((r) => ({
...r,
expand: { supplier: suppliers.get(r.supplier as string) ?? null },
}));
}
if (opts.sort) {
// PB accepts a comma-separated sort list (e.g. "supplier,name")
// and applies them in priority order. The mock does the same.
const dir = opts.sort.startsWith("-") ? -1 : 1;
const keys = opts.sort.replace(/^-/, "").split(",");
rows = [...rows].sort((a, b) => {
for (const key of keys) {
const av = String(a[key] ?? "");
const bv = String(b[key] ?? "");
const cmp = dir * av.localeCompare(bv);
if (cmp !== 0) return cmp;
}
return 0;
});
}
return rows;
});
const getOne = vi.fn(async (id: string) => {
const row = items.get(id);
if (!row) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return { ...row };
});
const update = vi.fn(async (id: string, patch: Record<string, unknown>) => {
const row = items.get(id);
if (!row) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(patch)) {
row[key] = value;
}
row.updated = new Date().toISOString();
return { ...row };
});
const pb = {
collection: (id: string) => {
if (id !== "stock_items") throw new Error(`unexpected collection: ${id}`);
return { getFullList, getOne, update };
},
};
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: async () => pb,
}));
import {
listStockItemsForStocktake,
updateStockItemWithPb,
} from "./stock-items-service";
import type { StockItem } from "@/lib/types";
function seedSupplier(id: string, fields: Record<string, unknown>) {
suppliers.set(id, { id, ...fields });
}
function seedItem(id: string, fields: Record<string, unknown>) {
items.set(id, { id, ...fields });
}
beforeEach(() => {
items.clear();
suppliers.clear();
getFullList.mockClear();
getOne.mockClear();
update.mockClear();
});
// --- Tests -------------------------------------------------------------------
describe("listStockItemsForStocktake", () => {
it("returns active stock items sorted by supplier name, then by name", async () => {
seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" });
seedSupplier("sup-2", { suppliername: "E-WELD" });
seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" });
seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-2" });
seedItem("si-c", { name: "Axe", sku: "AXE-1", location: "A3", unit: "ea", current_qty: 5, is_active: true, supplier: "sup-1" });
const result = await listStockItemsForStocktake(pb as never);
// AFRIQ LUBE first (Axe < Widget within the supplier), then E-WELD.
expect(result.map((r) => r.name)).toEqual(["Axe", "Widget", "Bracket"]);
expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" }));
});
it("sorts items with no supplier to the end", async () => {
seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" });
seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" });
seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "" });
const result = await listStockItemsForStocktake(pb as never);
expect(result.map((r) => r.name)).toEqual(["Widget", "Bracket"]);
});
it("returns an empty array when there are no active stock items", async () => {
seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" });
const result = await listStockItemsForStocktake(pb as never);
expect(result).toEqual([]);
expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" }));
});
it("excludes items with is_active = false", async () => {
seedSupplier("sup-1", { suppliername: "AFRIQ LUBE" });
seedItem("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" });
seedItem("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" });
const result = await listStockItemsForStocktake(pb as never);
expect(result.map((r) => r.name)).toEqual(["Widget"]);
expect(getFullList).toHaveBeenCalledWith(expect.objectContaining({ expand: "supplier" }));
});
});
describe("updateStockItemWithPb", () => {
it("patches every whitelisted field and returns the previous values", async () => {
seedItem("si-a", {
name: "Widget",
sku: "OLD-SKU",
description: "old desc",
notes: "old notes",
unit: "ea",
selling_price: 10,
markup: 25,
});
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: {
sku: "NEW-SKU",
description: "new desc",
notes: "new notes",
unit: "box",
selling_price: 12.5,
markup: 30,
},
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.item.sku).toBe("NEW-SKU");
expect(result.item.description).toBe("new desc");
expect(result.item.notes).toBe("new notes");
expect(result.item.unit).toBe("box");
expect(result.item.selling_price).toBe(12.5);
expect(result.item.markup).toBe(30);
expect(result.previous).toEqual({
sku: "OLD-SKU",
description: "old desc",
notes: "old notes",
unit: "ea",
selling_price: 10,
markup: 25,
});
expect(update).toHaveBeenCalledWith("si-a", {
sku: "NEW-SKU",
description: "new desc",
notes: "new notes",
unit: "box",
selling_price: 12.5,
markup: 30,
});
});
it("returns previous only for fields that were present in the patch", async () => {
seedItem("si-a", {
name: "Widget",
sku: "OLD-SKU",
description: "old desc",
notes: "old notes",
unit: "ea",
});
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { sku: "NEW-SKU" },
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.previous).toEqual({ sku: "OLD-SKU" });
});
it("rejects forbidden fields (current_qty, cost_price, supplier, created, updated, id)", async () => {
seedItem("si-a", { name: "Widget" });
const forbidden = [
"current_qty",
"cost_price",
"supplier",
"created",
"updated",
"id",
];
for (const field of forbidden) {
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { [field]: "anything" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("forbidden_field");
expect(result.field).toBe(field);
expect(update).not.toHaveBeenCalled();
update.mockClear();
}
});
it("rejects an unknown field with forbidden_field", async () => {
seedItem("si-a", { name: "Widget" });
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { bogus_field: "anything" } as unknown as Parameters<
typeof updateStockItemWithPb
>[1]["patch"],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("forbidden_field");
expect(result.field).toBe("bogus_field");
expect(update).not.toHaveBeenCalled();
});
it("returns validation_error for non-finite numeric fields", async () => {
seedItem("si-a", { name: "Widget" });
for (const field of ["selling_price", "markup"] as const) {
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { [field]: Number.NaN },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation_error");
expect(result.field).toBe(field);
expect(update).not.toHaveBeenCalled();
update.mockClear();
const result2 = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { [field]: "not a number" as unknown as number },
});
expect(result2.ok).toBe(false);
if (result2.ok) return;
expect(result2.reason).toBe("validation_error");
expect(result2.field).toBe(field);
expect(update).not.toHaveBeenCalled();
update.mockClear();
}
});
it("returns not_found when the row is missing", async () => {
const result = await updateStockItemWithPb(pb as never, {
id: "missing-id",
patch: { sku: "X" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("not_found");
expect(result.message).toMatch(/missing-id/);
expect(update).not.toHaveBeenCalled();
});
it("returns validation_error when id is missing", async () => {
const result = await updateStockItemWithPb(pb as never, {
id: "",
patch: { sku: "X" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation_error");
expect(result.message).toMatch(/id is required/);
});
it("returns validation_error when patch is missing or not an object", async () => {
seedItem("si-a", { name: "Widget" });
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: null as unknown as Record<string, unknown>,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("validation_error");
expect(result.message).toMatch(/patch object is required/);
});
it("returns pb_error when the update call fails", async () => {
seedItem("si-a", { name: "Widget" });
update.mockRejectedValueOnce(new Error("write conflict"));
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { sku: "NEW-SKU" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("pb_error");
expect(result.message).toMatch(/write conflict/);
});
it("returns pb_error when the getOne call fails with a non-404 error", async () => {
seedItem("si-a", { name: "Widget" });
getOne.mockRejectedValueOnce(new Error("network down"));
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: { sku: "NEW-SKU" },
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("pb_error");
expect(result.message).toMatch(/network down/);
expect(update).not.toHaveBeenCalled();
});
it("treats the first key outside the whitelist as forbidden_field", async () => {
seedItem("si-a", { name: "Widget" });
const result = await updateStockItemWithPb(pb as never, {
id: "si-a",
patch: {
sku: "OK",
current_qty: 999, // forbidden
} as unknown as Parameters<typeof updateStockItemWithPb>[1]["patch"],
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.reason).toBe("forbidden_field");
expect(result.field).toBe("current_qty");
expect(update).not.toHaveBeenCalled();
});
});