WFDASH-WEBAPP/src/lib/server/collection-confirm-service.test.ts
2026-10-17 09:45:12 +02:00

1514 lines
52 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"),
}));
vi.mock("@/lib/server/stock-items-service", () => ({
createStockItem: vi.fn(),
}));
import { createStockItem } from "@/lib/server/stock-items-service";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
confirmStockRows,
listCollectedItems,
updateOrderItemCost, confirmOrderItemsForInvoicing,
clearCollectedRows,
addRowsToPos,
} 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,
});
});
});
describe("clearCollectedRows", () => {
beforeEach(() => {
vi.clearAllMocks();
});
/** Mock admin exposing getOne + update on the order-items collection. */
const makeClearAdmin = (
existing: Record<string, unknown> | null,
opts: { throws?: boolean } = {},
) => {
const getOne = opts.throws
? vi.fn().mockRejectedValue(new Error("404 not found"))
: vi.fn().mockResolvedValue(existing);
const update = vi.fn().mockImplementation((_id: string, patch: unknown) =>
Promise.resolve({ ...(existing ?? {}), ...(patch as object) }),
);
const admin = {
collection: vi.fn().mockReturnValue({ getOne, update }),
};
return { admin, getOne, update };
};
const actor = { id: "yse", name: "Ysterbaard" };
it("moves a collected row to the chosen status and stamps a status audit note", async () => {
const { admin, update } = makeClearAdmin({
id: "r1",
item_status: "collected",
notes: "",
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await clearCollectedRows(["r1"], {
action: "delivered",
reason: "Customer already has these",
actor,
});
expect(res).toEqual({ cleared: ["r1"], skipped: [] });
const patch = update.mock.calls[0][1] as Record<string, unknown>;
expect(patch.item_status).toBe("delivered");
const notes = JSON.parse(patch.notes as string) as Array<{
category: string;
body: string;
actorName: string;
}>;
// The `status` category must survive the read-back filter, and the
// reason must be in the body or the audit trail says nothing.
expect(notes).toHaveLength(1);
expect(notes[0].category).toBe("status");
expect(notes[0].actorName).toBe("Ysterbaard");
expect(notes[0].body).toContain("Customer already has these");
});
it("refuses a row that is no longer collected and reports it as skipped", async () => {
const { admin, update } = makeClearAdmin({
id: "r1",
item_status: "delivered",
notes: "",
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await clearCollectedRows(["r1"], {
action: "invoiced",
reason: "duplicate click",
actor,
});
expect(res.cleared).toEqual([]);
expect(res.skipped).toEqual([{ id: "r1", reason: "already delivered" }]);
expect(update).not.toHaveBeenCalled();
});
it("skips an id that no longer exists instead of throwing", async () => {
const { admin } = makeClearAdmin(null, { throws: true });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const res = await clearCollectedRows(["gone"], {
action: "delivered",
reason: "x",
actor,
});
expect(res).toEqual({ cleared: [], skipped: [{ id: "gone", reason: "not found" }] });
});
it("writes the invoice number and date only when they are supplied", async () => {
const { admin, update } = makeClearAdmin({
id: "r1",
item_status: "collected",
notes: "",
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await clearCollectedRows(["r1"], {
action: "invoiced",
reason: "raised on the old sheet",
actor,
invoice: { number: "INV-1042", date: "2026-09-02" },
});
const withInvoice = update.mock.calls[0][1] as Record<string, unknown>;
expect(withInvoice.customer_invoice_number).toBe("INV-1042");
expect(withInvoice.invoiced_date).toBe("2026-09-02");
vi.clearAllMocks();
const second = makeClearAdmin({ id: "r2", item_status: "collected", notes: "" });
vi.mocked(getAdminPocketBase).mockResolvedValue(second.admin as never);
await clearCollectedRows(["r2"], {
action: "invoiced",
reason: "number not to hand",
actor,
});
const noInvoice = second.update.mock.calls[0][1] as Record<string, unknown>;
// Critically: must not write an EMPTY invoice number over whatever
// the accountant already put there.
expect(noInvoice).not.toHaveProperty("customer_invoice_number");
expect(noInvoice).not.toHaveProperty("invoiced_date");
});
it("rejects an invoice number with no date", async () => {
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected", notes: "" });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
clearCollectedRows(["r1"], {
action: "invoiced",
reason: "x",
actor,
invoice: { number: "INV-1", date: "" },
}),
).rejects.toMatchObject({ code: "invalid_invoice_date" });
});
it("requires a reason", async () => {
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected" });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
clearCollectedRows(["r1"], { action: "delivered", reason: " ", actor }),
).rejects.toMatchObject({ code: "reason_required" });
});
it("rejects an unknown action", async () => {
const { admin } = makeClearAdmin({ id: "r1", item_status: "collected" });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(
clearCollectedRows(["r1"], {
action: "deleted" as never,
reason: "x",
actor,
}),
).rejects.toMatchObject({ code: "invalid_action" });
});
});
describe("listCollectedItems — AI check passthrough", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const check = (read_id: string) => ({
read_id,
document_no: "5028256",
doc_date: "2026-10-16",
doc_type: "tax_invoice",
doc_line: null,
same_item: null,
reason: "",
sku_filled_value: "",
findings: [],
checked_at: "2026-10-17T08:00:00.000Z",
model: "MiniMax-M3",
});
const makeAiAdmin = (opts: {
orderItems?: Record<string, unknown>[];
stockItems?: Record<string, unknown>[];
docReads?: Record<string, unknown>[];
posItems?: Record<string, unknown>[];
}) => {
const docReadsGetFullList = vi.fn().mockResolvedValue(opts.docReads ?? []);
const posGetFullList = vi.fn().mockResolvedValue(opts.posItems ?? []);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getFullList: vi.fn().mockResolvedValue(opts.orderItems ?? []) };
}
if (name === "stock_purchase_items") {
return { getFullList: vi.fn().mockResolvedValue(opts.stockItems ?? []) };
}
if (name === "collection_doc_reads") return { getFullList: docReadsGetFullList };
if (name === "stock_items") return { getFullList: posGetFullList };
throw new Error(`unexpected collection ${name}`);
}),
};
return { admin, docReadsGetFullList, posGetFullList };
};
const DOC_LINE = {
line_no: 3,
code: "M8X25",
description: "Bolt M8x25",
quantity: 100,
unit: "ea",
unit_price: 0.33,
line_total: 33,
to_follow: null,
price_note: "",
};
it("attaches ai_doc (with findings and all document lines) from the read named by ai_check.read_id, loading reads in one query", async () => {
const { admin, docReadsGetFullList } = makeAiAdmin({
orderItems: [
{ id: "oi-1", item_status: "collected", sku: "", ai_check: check("r1") },
{ id: "oi-2", item_status: "collected", sku: "", ai_check: check("r1") },
{ id: "oi-3", item_status: "collected", sku: "", ai_check: null },
],
docReads: [
{
id: "r1",
doc_type: "tax_invoice",
document_no: "5028256",
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
lines: [DOC_LINE],
},
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
const byId = new Map(rows.map((r) => [r.id, r]));
expect(docReadsGetFullList).toHaveBeenCalledTimes(1);
expect(docReadsGetFullList.mock.calls[0]?.[0]).toMatchObject({ filter: 'id = "r1"' });
expect(byId.get("oi-1")?.ai_check?.read_id).toBe("r1");
expect(byId.get("oi-1")?.ai_doc).toEqual({
read_id: "r1",
doc_type: "tax_invoice",
document_no: "5028256",
doc_date: "2026-10-16",
supplier_name: "NSS Fasteners",
findings: ["Document 5028256 covers 2 POs: A, B."],
lines: [DOC_LINE],
});
expect(byId.get("oi-2")?.ai_doc?.read_id).toBe("r1");
expect(byId.get("oi-3")?.ai_doc).toBeNull();
});
it("gives rows without ai_check ai_check: null and ai_doc: null", async () => {
const { admin, docReadsGetFullList } = makeAiAdmin({
orderItems: [{ id: "oi-1", item_status: "collected" }],
stockItems: [
{ id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(2);
for (const r of rows) {
expect(r.ai_check).toBeNull();
expect(r.ai_doc).toBeNull();
}
expect(docReadsGetFullList).not.toHaveBeenCalled();
});
it("passes a stock row's ai_check through", async () => {
const { admin } = makeAiAdmin({
stockItems: [
{
id: "spi-1",
description: "x",
quantity: 2,
quantity_received: 1,
status: "collected",
stock_item: "st-1",
ai_check: check("r9"),
},
],
docReads: [{ id: "r9", doc_type: "delivery_note", document_no: "DN1", doc_date: "", supplier_name: "", findings: [] }],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row?.ai_check?.read_id).toBe("r9");
expect(row?.ai_doc).toMatchObject({ read_id: "r9", doc_type: "delivery_note", document_no: "DN1" });
});
it("sets in_pos and pos_stock_item_id when a stock_items SKU normalises to the row's SKU", async () => {
const { admin, posGetFullList } = makeAiAdmin({
orderItems: [
{ id: "oi-1", item_status: "collected", sku: "sm293306030" },
{ id: "oi-2", item_status: "collected", sku: "NOPE-1" },
{ id: "oi-3", item_status: "collected", sku: "" },
],
posItems: [
{ id: "st-9", sku: "SM-293306030" },
{ id: "st-8", sku: "NOPE-12" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
const byId = new Map(rows.map((r) => [r.id, r]));
expect(posGetFullList).toHaveBeenCalledTimes(1);
const filter = (posGetFullList.mock.calls[0]?.[0] as { filter?: string }).filter ?? "";
expect(filter).toContain('sku ~ "sm293306030"');
expect(filter).toContain('sku ~ "NOPE-1"');
expect(byId.get("oi-1")).toMatchObject({ in_pos: true, pos_stock_item_id: "st-9" });
expect(byId.get("oi-2")).toMatchObject({ in_pos: false, pos_stock_item_id: null });
expect(byId.get("oi-3")).toMatchObject({ in_pos: false, pos_stock_item_id: null });
});
it("marks stock rows in_pos with their catalogue item", async () => {
const { admin, posGetFullList } = makeAiAdmin({
stockItems: [
{ id: "spi-1", description: "x", quantity: 2, quantity_received: 1, status: "collected", stock_item: "st-1" },
],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row).toMatchObject({ in_pos: true, pos_stock_item_id: "st-1" });
expect(posGetFullList).not.toHaveBeenCalled();
});
it("stays fail-soft when the reads or POS lookups fail", async () => {
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return {
getFullList: vi.fn().mockResolvedValue([
{ id: "oi-1", item_status: "collected", sku: "A1", ai_check: check("r1") },
]),
};
}
if (name === "stock_purchase_items") return { getFullList: vi.fn().mockResolvedValue([]) };
return { getFullList: vi.fn().mockRejectedValue(new Error("down")) };
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const [row] = await listCollectedItems();
expect(row?.ai_check?.read_id).toBe("r1");
expect(row?.ai_doc).toMatchObject({ read_id: "r1", document_no: "5028256", findings: [] });
expect(row?.in_pos).toBeNull();
});
});
describe("addRowsToPos", () => {
const oi = (over: Record<string, unknown> = {}) => ({
id: "oi-1",
description: "M6 nylock nut",
po_description: "NUT NYLOCK M6 ZP",
sku: "SM2-NUT9B506",
cost_price: 0.2,
expand: { purchase_order: { id: "po-1", expand: { supplier: { id: "sup-1" } } } },
...over,
});
const makeAdmin = (opts: {
orderItems?: Record<string, unknown>[];
stockPurchaseItems?: Record<string, unknown>[];
posItems?: Record<string, unknown>[];
}) => ({
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") return { getFullList: vi.fn().mockResolvedValue(opts.orderItems ?? []) };
if (name === "stock_purchase_items") return { getFullList: vi.fn().mockResolvedValue(opts.stockPurchaseItems ?? []) };
if (name === "stock_items") return { getFullList: vi.fn().mockResolvedValue(opts.posItems ?? []) };
throw new Error(`unexpected collection ${name}`);
}),
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createStockItem).mockResolvedValue({ id: "si-new" } as never);
});
it("creates a POS item for a customer row from its line fields", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [oi()] }) as never);
const results = await addRowsToPos(["oi-1"]);
expect(results).toEqual([{ row_id: "oi-1", status: "created", stock_item_id: "si-new" }]);
expect(createStockItem).toHaveBeenCalledWith({
name: "M6 nylock nut",
description: "NUT NYLOCK M6 ZP",
sku: "SM2-NUT9B506",
supplier: "sup-1",
cost_price: 0.2,
});
});
it("takes the unit from the AI document line when present", async () => {
const row = oi({ po_description: "", ai_check: { doc_line: { unit: "EA" } } });
vi.mocked(getAdminPocketBase).mockResolvedValue(makeAdmin({ orderItems: [row] }) as never);
await addRowsToPos(["oi-1"]);
expect(createStockItem).toHaveBeenCalledWith(
expect.objectContaining({ unit: "EA", description: "M6 nylock nut" }),
);
});
it("returns already_in_pos when a normalised SKU match exists", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ orderItems: [oi()], posItems: [{ id: "si-9", sku: "sm2 nut9b506" }] }) as never,
);
const results = await addRowsToPos(["oi-1"]);
expect(results).toEqual([{ row_id: "oi-1", status: "already_in_pos", stock_item_id: "si-9" }]);
expect(createStockItem).not.toHaveBeenCalled();
});
it("returns already_in_pos for a stock-purchase row and not_found for an unknown id", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ stockPurchaseItems: [{ id: "spi-1" }] }) as never,
);
const results = await addRowsToPos(["spi-1", "nope"]);
expect(results).toEqual([
{ row_id: "spi-1", status: "already_in_pos", stock_item_id: null },
{ row_id: "nope", status: "not_found", stock_item_id: null },
]);
expect(createStockItem).not.toHaveBeenCalled();
});
it("creates only once when two rows share a supplier code", async () => {
vi.mocked(getAdminPocketBase).mockResolvedValue(
makeAdmin({ orderItems: [oi(), oi({ id: "oi-2" })] }) as never,
);
const results = await addRowsToPos(["oi-1", "oi-2"]);
expect(results.map((r) => r.status)).toEqual(["created", "already_in_pos"]);
expect(createStockItem).toHaveBeenCalledTimes(1);
});
});