WFDASH-WEBAPP/src/lib/server/collection-confirm-service.test.ts
2026-08-03 00:46:50 +02:00

698 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/orders-service", () => ({
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
}));
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
listCollectedItems,
updateOrderItemCost,
confirmOrderItemsForInvoicing,
} from "./collection-confirm-service";
/**
* Mock admin client factory.
*
* Mirrors the page's two-leg union query: one `getFullList` call against
* `order_items` (with the `collected` filter) and one against
* `stock_purchase_items` (with `status = "collected"`). The mock lets
* each test supply the items each leg should return.
*/
const makeAdmin = (opts: {
orderItems?: Record<string, unknown>[];
stockItems?: Record<string, unknown>[];
}) => {
const orderItemsGetFullList = vi
.fn()
.mockResolvedValue(opts.orderItems ?? []);
const stockItemsGetFullList = vi
.fn()
.mockResolvedValue(opts.stockItems ?? []);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getFullList: orderItemsGetFullList };
}
if (name === "stock_purchase_items") {
return { getFullList: stockItemsGetFullList };
}
throw new Error(`unexpected collection ${name}`);
}),
};
return { admin, orderItemsGetFullList, stockItemsGetFullList };
};
describe("listCollectedItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses getOrderItemsCollectionName to resolve the order-items collection", async () => {
const { admin } = makeAdmin({});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await listCollectedItems();
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
expect(admin.collection).toHaveBeenCalledWith("order_items_v2");
});
it("returns order_items rows expanded with purchase_order, supplier, order, company, customer", async () => {
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
quantity: 100,
cost_price: 8,
order_item_attachements: ["invoice.pdf"],
expand: {
purchase_order: {
id: "po-1",
po_number: "PO-1",
expand: { supplier: { id: "s-1", suppliername: "Acme" } },
},
order: {
id: "o-1",
order_number: "ORD-1",
expand: {
company: { id: "co-1", company: "Acme Co" },
customer: { id: "cu-1", customer: "J. Smith" },
},
},
},
};
const { admin, orderItemsGetFullList, stockItemsGetFullList } =
makeAdmin({ orderItems: [orderItem] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("oi-1");
expect(row.source).toBe("customer");
expect(row.description).toBe("M20 × 40 hex nuts");
expect(row.qty_collected).toBe(100);
expect(row.order_qty).toBe(100);
expect(row.item_status).toBe("collected");
expect(row.expand?.purchase_order?.po_number).toBe("PO-1");
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("Acme");
expect(row.expand?.order?.order_number).toBe("ORD-1");
expect(row.expand?.order?.expand?.customer?.customer).toBe("J. Smith");
expect(row.expand?.order?.expand?.company?.company).toBe("Acme Co");
// Order-items leg must use the `collected` filter and request the
// expansion the page relies on (PO supplier + order customer/company).
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
const orderOpts = orderItemsGetFullList.mock.calls[0]?.[0] as
| { filter?: string; expand?: string }
| undefined;
expect(orderOpts?.filter).toBe('item_status = "collected"');
expect(orderOpts?.expand).toBe(
"purchase_order.supplier,order.company,order.customer",
);
// Stock-leg is also called (the page runs both legs in parallel);
// it just returned an empty list for this test, so its items
// contribute nothing to the merged output.
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
});
it("returns stock_purchase_items rows normalised to ConfirmRow with source=stock", async () => {
const stockItem = {
id: "spi-1",
description: "Stock cable ties",
stock_item: "st-1",
quantity: 50,
quantity_received: 50,
cost_price: 1.5,
status: "collected",
assigned_at: "2026-08-01T10:00:00.000Z",
expand: {
purchase: {
id: "sp-1",
purchase_number: "SP-001",
expand: { supplier: { id: "s-2", suppliername: "CableCo" } },
},
stock_item: {
id: "st-1",
sku: "CT-200",
name: "Cable tie 200mm",
description: "Black UV-stable cable tie",
},
},
};
const { admin, orderItemsGetFullList, stockItemsGetFullList } =
makeAdmin({ stockItems: [stockItem] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("spi-1");
expect(row.source).toBe("stock");
expect(row.description).toBe("Stock cable ties");
expect(row.qty_collected).toBe(50);
expect(row.order_qty).toBe(50);
expect(row.quantity).toBe(50);
expect(row.item_status).toBe("collected");
expect(row.stock_line_status).toBe("collected");
expect(row.assigned_at).toBe("2026-08-01T10:00:00.000Z");
expect(row.stock_purchase_id).toBe("sp-1");
expect(row.poNumber).toBe("SP-001");
expect(row.sku).toBe("CT-200");
expect(row.supplier).toBe("s-2");
// The purchase data is mapped onto the shared `expand.purchase_order`
// shape so the page's supplier link works without a branch.
expect(row.expand?.purchase_order?.id).toBe("sp-1");
expect(row.expand?.purchase_order?.po_number).toBe("SP-001");
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("CableCo");
// Stock rows have no customer side.
expect(row.expand?.order).toBeUndefined();
// Stock leg filter must be the status-based variant (NOT item_status).
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as
| { filter?: string; expand?: string }
| undefined;
expect(stockOpts?.filter).toBe('status = "collected"');
expect(stockOpts?.expand).toBe("purchase.supplier,purchase,stock_item");
// Order-items leg must NOT be called when we only returned stock rows.
// (In the real code it IS called — but it returned empty.)
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
expect(orderItemsGetFullList.mock.calls[0]?.[0]).toMatchObject({
filter: 'item_status = "collected"',
});
});
it("returns an empty array when both legs return zero rows", async () => {
const { admin } = makeAdmin({ orderItems: [], stockItems: [] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toEqual([]);
});
it("merges order_items and stock_purchase_items into one ConfirmRow[]", async () => {
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
expand: {
purchase_order: { id: "po-1", po_number: "PO-1" },
order: { id: "o-1" },
},
};
const stockItem = {
id: "spi-1",
description: "Cable ties",
quantity: 50,
quantity_received: 50,
status: "collected",
expand: {
purchase: { id: "sp-1", purchase_number: "SP-001" },
},
};
const { admin } = makeAdmin({
orderItems: [orderItem],
stockItems: [stockItem],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(2);
const sources = rows.map((r) => r.source).sort();
expect(sources).toEqual(["customer", "stock"]);
});
it("returns order_items rows when stock_purchase_items leg throws", async () => {
// Mirrors the page's `load()` fail-soft behaviour at page.tsx:294:
// the stock leg is wrapped in `.catch(() => [])` so a stock-side
// outage doesn't kill the customer-leg results.
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
expand: {
purchase_order: { id: "po-1", po_number: "PO-1" },
order: { id: "o-1" },
},
};
const orderItemsGetFullList = vi.fn().mockResolvedValue([orderItem]);
const stockItemsGetFullList = vi
.fn()
.mockRejectedValue(new Error("stock side down"));
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getFullList: orderItemsGetFullList };
}
if (name === "stock_purchase_items") {
return { getFullList: stockItemsGetFullList };
}
throw new Error(`unexpected collection ${name}`);
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("oi-1");
expect(row.source).toBe("customer");
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
});
});
describe("updateOrderItemCost", () => {
beforeEach(() => {
vi.clearAllMocks();
});
/**
* Build an admin mock that exposes `getOne` and `update` for the
* order-items collection only. `getOne` returns the row that the
* service reads to capture the previous cost; `update` returns the
* fresh row that the service writes back.
*
* The default `update` resolution is the existing row overlaid with
* the patch the service passed (so `cost_price` and `notes` are
* visible on the returned object even if the test doesn't supply a
* custom `updated` row).
*/
const makeAdminForUpdate = (opts: {
existing: Record<string, unknown>;
updated?: Record<string, unknown>;
}) => {
const getOne = vi.fn().mockResolvedValue(opts.existing);
const update = vi.fn().mockImplementation(
(_id: string, patch: Record<string, unknown>) =>
Promise.resolve({ ...opts.existing, ...(opts.updated ?? {}), ...patch }),
);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getOne, update };
}
throw new Error(`unexpected collection ${name}`);
}),
};
return { admin, getOne, update };
};
it("writes cost_price and appends a category='cost' note when the price changes", async () => {
const existing = { id: "row-1", cost_price: 45, notes: null };
const { admin, getOne, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await updateOrderItemCost("row-1", {
cost_price: 50,
actor: { id: "u-1", name: "sj" },
});
// Read-then-write through the resolved collection name.
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
expect(getOne).toHaveBeenCalledTimes(1);
expect(getOne).toHaveBeenCalledWith("row-1");
expect(update).toHaveBeenCalledTimes(1);
const [updatedId, patch] = update.mock.calls[0] as [
string,
{ cost_price: number; notes: string },
];
expect(updatedId).toBe("row-1");
expect(patch.cost_price).toBe(50);
expect(typeof patch.notes).toBe("string");
// The new notes feed is a JSON array containing a single cost entry.
const parsed = JSON.parse(patch.notes);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(1);
const entry = parsed[0];
expect(entry.category).toBe("cost");
expect(entry.actorId).toBe("u-1");
expect(entry.actorName).toBe("sj");
expect(entry.body).toEqual({ prevCost: 45, newCost: 50 });
expect(typeof entry.ts).toBe("string");
// Returned row carries the new cost price (mock resolution merges
// the patch into the existing row).
expect(result.id).toBe("row-1");
expect(result.cost_price).toBe(50);
});
it("is a no-op when the new cost equals the current cost (no write, no note)", async () => {
const existing = { id: "row-1", cost_price: 45, notes: null };
const { admin, getOne, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await updateOrderItemCost("row-1", {
cost_price: 45,
actor: { id: "u-1", name: "sj" },
});
// The read still happens (we need prevCost to make the no-op
// decision), but the write is skipped entirely.
expect(getOne).toHaveBeenCalledTimes(1);
expect(update).not.toHaveBeenCalled();
// The unchanged existing row is returned as-is.
expect(result).toEqual(existing);
});
it("preserves existing notes when appending the cost entry", async () => {
const priorEntry = {
id: "note-1",
ts: "2026-08-01T00:00:00.000Z",
actorId: "u-0",
actorName: "previous-actor",
category: "note",
body: "A prior note",
};
const existing = {
id: "row-1",
cost_price: 45,
notes: JSON.stringify([priorEntry]),
};
const { admin, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await updateOrderItemCost("row-1", {
cost_price: 50,
actor: { id: "u-1", name: "sj" },
});
expect(update).toHaveBeenCalledTimes(1);
const patch = update.mock.calls[0]?.[1] as { cost_price: number; notes: string };
const parsed = JSON.parse(patch.notes);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(2);
// The prior entry is preserved in its original form (head of the
// array, since `appendCostNote` appends at the tail).
expect(parsed[0]).toEqual(priorEntry);
// The new cost entry is appended at the tail with the correct
// prev/new cost values.
expect(parsed[1].category).toBe("cost");
expect(parsed[1].actorId).toBe("u-1");
expect(parsed[1].actorName).toBe("sj");
expect(parsed[1].body).toEqual({ prevCost: 45, newCost: 50 });
});
it("propagates errors from pb.collection.getOne", async () => {
const failingGetOne = vi.fn().mockRejectedValue(new Error("not found"));
const dummyUpdate = vi.fn();
const admin = {
collection: vi.fn().mockReturnValue({
getOne: failingGetOne,
update: dummyUpdate,
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
updateOrderItemCost("row-1", {
cost_price: 50,
actor: { id: "u-1", name: "sj" },
}),
).rejects.toThrow("not found");
expect(failingGetOne).toHaveBeenCalledWith("row-1");
expect(dummyUpdate).not.toHaveBeenCalled();
});
it("writes cost_price and appends a cost note when going from null to 0", async () => {
// Regression: the lossy fallback `prevCost ?? 0` collapsed a true
// `null → 0` transition into `0 → 0` inside appendCostNote, which
// short-circuited and silently dropped the audit note. The fix
// threads the original `null` through; this test pins that
// behaviour.
const existing = { id: "row-1", cost_price: null, notes: null };
const { admin, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await updateOrderItemCost("row-1", {
cost_price: 0,
actor: { id: "u-1", name: "sj" },
});
expect(update).toHaveBeenCalledTimes(1);
const [, patch] = update.mock.calls[0] as [
string,
{ cost_price: number; notes: string },
];
expect(patch.cost_price).toBe(0);
const parsed = JSON.parse(patch.notes) as Array<Record<string, unknown>>;
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({
category: "cost",
actorId: "u-1",
actorName: "sj",
body: { prevCost: null, newCost: 0 },
});
expect(result.cost_price).toBe(0);
});
it("writes cost_price and appends a cost note when going from null to a positive number", async () => {
const existing = { id: "row-1", cost_price: null, notes: null };
const { admin, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await updateOrderItemCost("row-1", {
cost_price: 50,
actor: { id: "u-1", name: "sj" },
});
const [, patch] = update.mock.calls[0] as [
string,
{ cost_price: number; notes: string },
];
expect(patch.cost_price).toBe(50);
const parsed = JSON.parse(patch.notes) as Array<Record<string, unknown>>;
expect(parsed).toHaveLength(1);
expect(parsed[0]).toMatchObject({
category: "cost",
body: { prevCost: null, newCost: 50 },
});
});
});
describe("confirmOrderItemsForInvoicing", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const makeAdminForConfirm = (opts: {
rows: Record<string, unknown>[];
send?: ReturnType<typeof vi.fn>;
}) => {
const getList = vi.fn().mockResolvedValue({ items: opts.rows });
const update = vi.fn().mockResolvedValue(undefined);
const batchCollection = vi.fn().mockReturnValue({ update });
const send = opts.send ?? vi.fn().mockResolvedValue([]);
const createBatch = vi.fn().mockReturnValue({
collection: batchCollection,
send,
});
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") return { getList };
throw new Error(`unexpected collection ${name}`);
}),
createBatch,
};
return { admin, getList, createBatch, batchCollection, update, send };
};
it("writes cost_price, invoice details, status, and audit notes in one batch", async () => {
const rows = [
{
id: "row-1",
item_status: "collected",
cost_price: 45,
notes: null,
order_item_details: null,
},
{
id: "row-2",
item_status: "collected",
cost_price: 10,
notes: null,
order_item_details: null,
},
];
const { admin, getList, createBatch, batchCollection, update } =
makeAdminForConfirm({ rows });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsForInvoicing(["row-1", "row-2"], {
invoice: { date: "2026-08-03", number: "INV-1" },
costOverrides: { "row-1": 50, "row-2": 12 },
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual(["row-1", "row-2"]);
expect(getList).toHaveBeenCalledWith(1, 2, {
filter: 'id="row-1" || id="row-2"',
});
expect(createBatch).toHaveBeenCalledTimes(1);
expect(batchCollection).toHaveBeenCalledWith("order_items_v2");
expect(update).toHaveBeenCalledTimes(2);
for (const [id, patch] of update.mock.calls as Array<[
string,
Record<string, unknown>,
]>) {
expect(["row-1", "row-2"]).toContain(id);
expect(patch).toMatchObject({
item_status: "ready_invoice",
cost_price: id === "row-1" ? 50 : 12,
});
expect(typeof patch.notes).toBe("string");
const notes = JSON.parse(patch.notes as string) as Array<Record<string, unknown>>;
expect(notes.map((entry) => entry.category)).toEqual(["confirm", "cost"]);
expect(notes[0]?.body).toBe("Confirmed for invoicing — INV-1 (2026-08-03)");
expect(notes[1]?.body).toEqual({
prevCost: id === "row-1" ? 45 : 10,
newCost: id === "row-1" ? 50 : 12,
});
expect(typeof patch.order_item_details).toBe("string");
const details = JSON.parse(patch.order_item_details as string) as {
invoice?: Record<string, unknown>;
};
expect(details.invoice).toMatchObject({
date: "2026-08-03",
number: "INV-1",
setBy: "u-1",
setByName: "sj",
});
expect(typeof details.invoice?.setAt).toBe("string");
}
});
it("behaves like buildConfirmPatch when no cost overrides are provided", async () => {
const rows = [
{
id: "row-1",
item_status: "collected",
cost_price: 45,
notes: null,
order_item_details: null,
},
];
const { admin, update } = makeAdminForConfirm({ rows });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsForInvoicing(["row-1"], {
invoice: { date: "2026-08-03", number: "INV-1" },
costOverrides: {},
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual(["row-1"]);
expect(update).toHaveBeenCalledTimes(1);
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(patch).toMatchObject({ item_status: "ready_invoice" });
expect(patch).toHaveProperty("notes");
expect(patch).toHaveProperty("order_item_details");
expect(patch).not.toHaveProperty("cost_price");
const notes = JSON.parse(patch.notes as string) as Array<Record<string, unknown>>;
expect(notes).toHaveLength(1);
expect(notes[0]?.category).toBe("confirm");
});
it("aborts the batch and surfaces the error when send rejects", async () => {
const send = vi.fn().mockRejectedValue(new Error("batch failed"));
const rows = [
{ id: "row-1", item_status: "collected", cost_price: 45, notes: null },
{ id: "row-2", item_status: "collected", cost_price: 12, notes: null },
];
const { admin, createBatch, update } = makeAdminForConfirm({ rows, send });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
confirmOrderItemsForInvoicing(["row-1", "row-2"], {
invoice: { date: "2026-08-03", number: "INV-1" },
costOverrides: { "row-1": 50 },
actor: { id: "u-1", name: "sj" },
}),
).rejects.toThrow("batch failed");
expect(createBatch).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledTimes(1);
// The SDK only queues update operations before send; a rejected send
// means no individual collection update was issued outside the batch.
expect(update).toHaveBeenCalledTimes(2);
});
it("does not write cost_price when an override equals the current cost", async () => {
const rows = [
{
id: "row-1",
item_status: "collected",
cost_price: 50,
notes: null,
order_item_details: null,
},
];
const { admin, update } = makeAdminForConfirm({ rows });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await confirmOrderItemsForInvoicing(["row-1"], {
invoice: { date: "2026-08-03", number: "INV-1" },
costOverrides: { "row-1": 50 },
actor: { id: "u-1", name: "sj" },
});
expect(update).toHaveBeenCalledTimes(1);
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(patch).not.toHaveProperty("cost_price");
const notes = JSON.parse(patch.notes as string) as Array<Record<string, unknown>>;
expect(notes.map((entry) => entry.category)).toEqual(["confirm"]);
});
it("writes cost_price and appends a cost note when overriding from null to 0", async () => {
// Regression for the lossy `prevCost ?? 0` fallback that
// collapsed a `null → 0` transition into `0 → 0` and skipped the
// cost note. The fix threads null through; this test pins it.
const rows = [
{
id: "row-1",
item_status: "collected",
cost_price: null,
notes: null,
order_item_details: null,
},
];
const { admin, update } = makeAdminForConfirm({ rows });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await confirmOrderItemsForInvoicing(["row-1"], {
invoice: { date: "2026-08-03", number: "INV-1" },
costOverrides: { "row-1": 0 },
actor: { id: "u-1", name: "sj" },
});
expect(update).toHaveBeenCalledTimes(1);
const patch = update.mock.calls[0]?.[1] as Record<string, unknown>;
expect(patch.cost_price).toBe(0);
const notes = JSON.parse(patch.notes as string) as Array<Record<string, unknown>>;
// The confirm note (from buildConfirmPatch) is appended first,
// then the cost note — same shape as any other override.
expect(notes.map((entry) => entry.category)).toEqual(["confirm", "cost"]);
expect(notes[1]?.body).toEqual({ prevCost: null, newCost: 0 });
});
});