698 lines
25 KiB
TypeScript
698 lines
25 KiB
TypeScript
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 });
|
||
});
|
||
});
|