WFDASH-WEBAPP/src/lib/server/collection-confirm-service.test.ts

1065 lines
36 KiB
TypeScript
Raw Normal View History

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 {
confirmStockRows,
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 excludes lines the warehouse has already
// fully received (`quantity_received >= quantity`). The
// confirm-stock endpoint and the qty pencil both land the line
// at full received; the row should drop off the list either
// way. The status field stays at "collected" — only the
// accountant's booking flow flips it to "booked".
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as
{ filter?: string; expand?: string } | undefined;
expect(stockOpts?.filter).toBe(
'status = "collected" && quantity_received < quantity',
);
expect(stockOpts?.expand).toBe(
"purchase.supplier,purchase.stock_item,purchase.additional_attachments",
);
// 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 },
});
});
it("throws CollectionConfirmValidationError when cost_price is not a number", async () => {
// Defence-in-depth: the route validates the body and returns 400
// before this is ever called, but a non-numeric value reaching
// PocketBase silently coerces to NaN and the cost audit note gets
// a `prevCost: NaN` body. Pin the refusal.
const { admin, getOne, update } = makeAdminForUpdate({
existing: { id: "row-1", cost_price: 45, notes: null },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const promise = updateOrderItemCost("row-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional misuse for the test
cost_price: "abc" as any,
actor: { id: "u-1", name: "sj" },
});
await expect(promise).rejects.toMatchObject({
name: "CollectionConfirmValidationError",
code: "invalid_cost_price",
status: 400,
message: expect.stringMatching(/finite number/i),
});
// Nothing read or written — the service refuses before touching PB.
expect(getOne).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("throws CollectionConfirmValidationError when cost_price is NaN", async () => {
const { admin, getOne, update } = makeAdminForUpdate({
existing: { id: "row-1", cost_price: 45, notes: null },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
updateOrderItemCost("row-1", {
cost_price: Number.NaN,
actor: { id: "u-1", name: "sj" },
}),
).rejects.toMatchObject({
name: "CollectionConfirmValidationError",
code: "invalid_cost_price",
status: 400,
});
expect(getOne).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("throws CollectionConfirmValidationError when cost_price is negative", async () => {
const { admin, getOne, update } = makeAdminForUpdate({
existing: { id: "row-1", cost_price: 45, notes: null },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
updateOrderItemCost("row-1", {
cost_price: -1,
actor: { id: "u-1", name: "sj" },
}),
).rejects.toMatchObject({
name: "CollectionConfirmValidationError",
code: "invalid_cost_price",
status: 400,
message: expect.stringMatching(/non-negative/i),
});
expect(getOne).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("accepts a cost_price of 0 (zero-cost line is a real case)", async () => {
const existing = { id: "row-1", cost_price: 45, 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);
expect(result.cost_price).toBe(0);
});
});
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 });
});
});
describe("confirmStockRows", () => {
beforeEach(() => {
vi.clearAllMocks();
});
/**
* Mock admin client factory for the confirm-collection flow. The
* factory exposes just the `stock_purchase_items` collection —
* the new warehouse-side flow does NOT touch ledger, stock_items,
* or the parent PO header (those are the accountant's job in
* `bookStockFromInvoice`). If a test accidentally invokes one of
* those collections, the factory throws a clear error so the
* assertion failure is unambiguous.
*/
const makeConfirmAdmin = (opts: {
lineById: Record<string, Record<string, unknown>>;
}) => {
const getOne = vi.fn().mockImplementation((id: string) => {
const line = opts.lineById[id] ?? null;
if (!line) {
return Promise.reject(
Object.assign(new Error("not found"), { status: 404 }),
);
}
return Promise.resolve(line);
});
const lineUpdate = vi.fn().mockResolvedValue(undefined);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "stock_purchase_items") {
return { getOne, update: lineUpdate };
}
// The inventory write has been moved to the accountant's
// `bookStockFromInvoice` flow. The warehouse-side
// confirm-collection flow must NOT touch these collections.
throw new Error(
`unexpected collection ${name} — confirmStockRows should not touch this collection`,
);
}),
};
return { admin, getOne, lineUpdate };
};
it("returns an empty array when given no ids", async () => {
const { admin } = makeConfirmAdmin({ lineById: {} });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmStockRows([], {
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual([]);
// No admin calls expected — the empty-input branch bails early.
expect(admin.collection).not.toHaveBeenCalled();
});
it("snaps quantity_received to the full quantity and returns updated ids", async () => {
const line = {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 0,
cost_price: 5,
purchase: "sp-1",
};
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
lineById: { "spi-1": line },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmStockRows(["spi-1"], {
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual(["spi-1"]);
// The line is read first (so we have quantity + parent purchase id).
expect(getOne).toHaveBeenCalledWith("spi-1");
// The line update snaps quantity_received to the full qty.
// Crucially, status is NOT touched — the driver app sets it to
// `collected`, and the accountant's `bookStockFromInvoice` is
// what flips it to `booked` once payment is committed.
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
quantity_received: 10,
});
});
it("does not touch stock_items, stock_ledger, or stock_purchases", async () => {
// The confirm-collection flow is intentionally narrow. If a
// future change accidentally reintroduces ledger writes or
// header auto-derive, this test fires.
const line = {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 0,
purchase: "sp-1",
};
const { admin, lineUpdate } = makeConfirmAdmin({
lineById: { "spi-1": line },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await confirmStockRows(["spi-1"], {
actor: { id: "u-1", name: "sj" },
});
// Only stock_purchase_items was ever accessed. There are
// multiple .collection() calls (one for the read, one for the
// update) — check the unique set, not the call list.
const accessedCollections = new Set(
admin.collection.mock.calls.map(([name]) => name),
);
expect(accessedCollections).toEqual(new Set(["stock_purchase_items"]));
expect(lineUpdate).toHaveBeenCalledTimes(1);
});
it("throws and skips the row when quantity is zero", async () => {
const line = {
id: "spi-1",
stock_item: "st-1",
quantity: 0,
quantity_received: 0,
purchase: "sp-1",
};
const { admin, lineUpdate } = makeConfirmAdmin({
lineById: { "spi-1": line },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
).rejects.toThrow(/has zero quantity/i);
expect(lineUpdate).not.toHaveBeenCalled();
});
it("is idempotent: a line already at full quantity_received is omitted from updatedIds (no throw)", async () => {
// Old behaviour threw a plain Error here → 500. Once the qty
// pencil landed, snapping quantity_received to the full qty
// could leave the line "stuck" — the confirm-stock button
// would 500 forever. Now it's a no-op success: the row is
// dropped from updatedIds so the route returns 200 with the
// remaining rows only.
const line = {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 10,
purchase: "sp-1",
};
const { admin, lineUpdate } = makeConfirmAdmin({
lineById: { "spi-1": line },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmStockRows(["spi-1"], {
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual([]);
expect(lineUpdate).not.toHaveBeenCalled();
});
it("processes a mixed batch: full-qty rows are skipped, partial rows are advanced", async () => {
const lines = {
"spi-1": {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 10, // already full
purchase: "sp-1",
},
"spi-2": {
id: "spi-2",
stock_item: "st-2",
quantity: 4,
quantity_received: 2, // partial
purchase: "sp-1",
},
};
const { admin, lineUpdate } = makeConfirmAdmin({
lineById: lines,
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmStockRows(["spi-1", "spi-2"], {
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual(["spi-2"]);
expect(lineUpdate).toHaveBeenCalledTimes(1);
expect(lineUpdate).toHaveBeenCalledWith("spi-2", { quantity_received: 4 });
});
it("throws and skips the row when the parent purchase id is missing", async () => {
const line = {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 0,
// No `purchase` field — orphan row.
};
const { admin, lineUpdate } = makeConfirmAdmin({
lineById: { "spi-1": line },
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
confirmStockRows(["spi-1"], { actor: { id: "u-1", name: "sj" } }),
).rejects.toThrow(/missing the parent purchase id/i);
expect(lineUpdate).not.toHaveBeenCalled();
});
it("confirms multiple rows independently", async () => {
const lines = {
"spi-1": {
id: "spi-1",
stock_item: "st-1",
quantity: 10,
quantity_received: 0,
purchase: "sp-1",
},
"spi-2": {
id: "spi-2",
stock_item: "st-2",
quantity: 4,
quantity_received: 2,
purchase: "sp-1",
},
};
const { admin, getOne, lineUpdate } = makeConfirmAdmin({
lineById: lines,
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmStockRows(["spi-1", "spi-2"], {
actor: { id: "u-1", name: "sj" },
});
expect(result).toEqual(["spi-1", "spi-2"]);
expect(getOne).toHaveBeenCalledWith("spi-1");
expect(getOne).toHaveBeenCalledWith("spi-2");
expect(lineUpdate).toHaveBeenCalledWith("spi-1", {
quantity_received: 10,
});
expect(lineUpdate).toHaveBeenCalledWith("spi-2", {
quantity_received: 4,
});
});
});