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
367 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|