WFDASH-WEBAPP/src/lib/server/collection-confirm-service.test.ts
SJCoetz 2997b8dcba feat(collection-confirm): implement updateOrderItemCost service
Co-authored-by: Orca <help@stably.ai>
2026-08-03 00:23:37 +02:00

430 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { describe, it, expect, beforeEach, vi } from "vitest";
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn(),
}));
vi.mock("@/lib/server/orders-service", () => ({
getOrderItemsCollectionName: vi.fn(() => "order_items_v2"),
}));
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getOrderItemsCollectionName } from "@/lib/server/orders-service";
import {
listCollectedItems,
updateOrderItemCost,
confirmOrderItemsForInvoicing,
} from "./collection-confirm-service";
/**
* Mock admin client factory.
*
* Mirrors the page's two-leg union query: one `getFullList` call against
* `order_items` (with the `collected` filter) and one against
* `stock_purchase_items` (with `status = "collected"`). The mock lets
* each test supply the items each leg should return.
*/
const makeAdmin = (opts: {
orderItems?: Record<string, unknown>[];
stockItems?: Record<string, unknown>[];
}) => {
const orderItemsGetFullList = vi
.fn()
.mockResolvedValue(opts.orderItems ?? []);
const stockItemsGetFullList = vi
.fn()
.mockResolvedValue(opts.stockItems ?? []);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getFullList: orderItemsGetFullList };
}
if (name === "stock_purchase_items") {
return { getFullList: stockItemsGetFullList };
}
throw new Error(`unexpected collection ${name}`);
}),
};
return { admin, orderItemsGetFullList, stockItemsGetFullList };
};
describe("listCollectedItems", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses getOrderItemsCollectionName to resolve the order-items collection", async () => {
const { admin } = makeAdmin({});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await listCollectedItems();
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
expect(admin.collection).toHaveBeenCalledWith("order_items_v2");
});
it("returns order_items rows expanded with purchase_order, supplier, order, company, customer", async () => {
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
quantity: 100,
cost_price: 8,
order_item_attachements: ["invoice.pdf"],
expand: {
purchase_order: {
id: "po-1",
po_number: "PO-1",
expand: { supplier: { id: "s-1", suppliername: "Acme" } },
},
order: {
id: "o-1",
order_number: "ORD-1",
expand: {
company: { id: "co-1", company: "Acme Co" },
customer: { id: "cu-1", customer: "J. Smith" },
},
},
},
};
const { admin, orderItemsGetFullList, stockItemsGetFullList } =
makeAdmin({ orderItems: [orderItem] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("oi-1");
expect(row.source).toBe("customer");
expect(row.description).toBe("M20 × 40 hex nuts");
expect(row.qty_collected).toBe(100);
expect(row.order_qty).toBe(100);
expect(row.item_status).toBe("collected");
expect(row.expand?.purchase_order?.po_number).toBe("PO-1");
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("Acme");
expect(row.expand?.order?.order_number).toBe("ORD-1");
expect(row.expand?.order?.expand?.customer?.customer).toBe("J. Smith");
expect(row.expand?.order?.expand?.company?.company).toBe("Acme Co");
// Order-items leg must use the `collected` filter and request the
// expansion the page relies on (PO supplier + order customer/company).
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
const orderOpts = orderItemsGetFullList.mock.calls[0]?.[0] as
| { filter?: string; expand?: string }
| undefined;
expect(orderOpts?.filter).toBe('item_status = "collected"');
expect(orderOpts?.expand).toBe(
"purchase_order.supplier,order.company,order.customer",
);
// Stock-leg is also called (the page runs both legs in parallel);
// it just returned an empty list for this test, so its items
// contribute nothing to the merged output.
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
});
it("returns stock_purchase_items rows normalised to ConfirmRow with source=stock", async () => {
const stockItem = {
id: "spi-1",
description: "Stock cable ties",
stock_item: "st-1",
quantity: 50,
quantity_received: 50,
cost_price: 1.5,
status: "collected",
assigned_at: "2026-08-01T10:00:00.000Z",
expand: {
purchase: {
id: "sp-1",
purchase_number: "SP-001",
expand: { supplier: { id: "s-2", suppliername: "CableCo" } },
},
stock_item: {
id: "st-1",
sku: "CT-200",
name: "Cable tie 200mm",
description: "Black UV-stable cable tie",
},
},
};
const { admin, orderItemsGetFullList, stockItemsGetFullList } =
makeAdmin({ stockItems: [stockItem] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("spi-1");
expect(row.source).toBe("stock");
expect(row.description).toBe("Stock cable ties");
expect(row.qty_collected).toBe(50);
expect(row.order_qty).toBe(50);
expect(row.quantity).toBe(50);
expect(row.item_status).toBe("collected");
expect(row.stock_line_status).toBe("collected");
expect(row.assigned_at).toBe("2026-08-01T10:00:00.000Z");
expect(row.stock_purchase_id).toBe("sp-1");
expect(row.poNumber).toBe("SP-001");
expect(row.sku).toBe("CT-200");
expect(row.supplier).toBe("s-2");
// The purchase data is mapped onto the shared `expand.purchase_order`
// shape so the page's supplier link works without a branch.
expect(row.expand?.purchase_order?.id).toBe("sp-1");
expect(row.expand?.purchase_order?.po_number).toBe("SP-001");
expect(row.expand?.purchase_order?.expand?.supplier?.suppliername).toBe("CableCo");
// Stock rows have no customer side.
expect(row.expand?.order).toBeUndefined();
// Stock leg filter must be the status-based variant (NOT item_status).
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
const stockOpts = stockItemsGetFullList.mock.calls[0]?.[0] as
| { filter?: string; expand?: string }
| undefined;
expect(stockOpts?.filter).toBe('status = "collected"');
expect(stockOpts?.expand).toBe("purchase.supplier,purchase,stock_item");
// Order-items leg must NOT be called when we only returned stock rows.
// (In the real code it IS called — but it returned empty.)
expect(orderItemsGetFullList).toHaveBeenCalledTimes(1);
expect(orderItemsGetFullList.mock.calls[0]?.[0]).toMatchObject({
filter: 'item_status = "collected"',
});
});
it("returns an empty array when both legs return zero rows", async () => {
const { admin } = makeAdmin({ orderItems: [], stockItems: [] });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toEqual([]);
});
it("merges order_items and stock_purchase_items into one ConfirmRow[]", async () => {
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
expand: {
purchase_order: { id: "po-1", po_number: "PO-1" },
order: { id: "o-1" },
},
};
const stockItem = {
id: "spi-1",
description: "Cable ties",
quantity: 50,
quantity_received: 50,
status: "collected",
expand: {
purchase: { id: "sp-1", purchase_number: "SP-001" },
},
};
const { admin } = makeAdmin({
orderItems: [orderItem],
stockItems: [stockItem],
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(2);
const sources = rows.map((r) => r.source).sort();
expect(sources).toEqual(["customer", "stock"]);
});
it("returns order_items rows when stock_purchase_items leg throws", async () => {
// Mirrors the page's `load()` fail-soft behaviour at page.tsx:294:
// the stock leg is wrapped in `.catch(() => [])` so a stock-side
// outage doesn't kill the customer-leg results.
const orderItem = {
id: "oi-1",
item_status: "collected",
description: "M20 × 40 hex nuts",
order_qty: 100,
qty_collected: 100,
expand: {
purchase_order: { id: "po-1", po_number: "PO-1" },
order: { id: "o-1" },
},
};
const orderItemsGetFullList = vi.fn().mockResolvedValue([orderItem]);
const stockItemsGetFullList = vi
.fn()
.mockRejectedValue(new Error("stock side down"));
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getFullList: orderItemsGetFullList };
}
if (name === "stock_purchase_items") {
return { getFullList: stockItemsGetFullList };
}
throw new Error(`unexpected collection ${name}`);
}),
};
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const rows = await listCollectedItems();
expect(rows).toHaveLength(1);
const row = rows[0]!;
expect(row.id).toBe("oi-1");
expect(row.source).toBe("customer");
expect(stockItemsGetFullList).toHaveBeenCalledTimes(1);
});
});
describe("updateOrderItemCost", () => {
beforeEach(() => {
vi.clearAllMocks();
});
/**
* Build an admin mock that exposes `getOne` and `update` for the
* order-items collection only. `getOne` returns the row that the
* service reads to capture the previous cost; `update` returns the
* fresh row that the service writes back.
*
* The default `update` resolution is the existing row overlaid with
* the patch the service passed (so `cost_price` and `notes` are
* visible on the returned object even if the test doesn't supply a
* custom `updated` row).
*/
const makeAdminForUpdate = (opts: {
existing: Record<string, unknown>;
updated?: Record<string, unknown>;
}) => {
const getOne = vi.fn().mockResolvedValue(opts.existing);
const update = vi.fn().mockImplementation(
(_id: string, patch: Record<string, unknown>) =>
Promise.resolve({ ...opts.existing, ...(opts.updated ?? {}), ...patch }),
);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items_v2") {
return { getOne, update };
}
throw new Error(`unexpected collection ${name}`);
}),
};
return { admin, getOne, update };
};
it("writes cost_price and appends a category='cost' note when the price changes", async () => {
const existing = { id: "row-1", cost_price: 45, notes: null };
const { admin, getOne, update } = makeAdminForUpdate({ existing });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await updateOrderItemCost("row-1", {
cost_price: 50,
actor: { id: "u-1", name: "sj" },
});
// Read-then-write through the resolved collection name.
expect(getOrderItemsCollectionName).toHaveBeenCalledTimes(1);
expect(getOne).toHaveBeenCalledTimes(1);
expect(getOne).toHaveBeenCalledWith("row-1");
expect(update).toHaveBeenCalledTimes(1);
const [updatedId, patch] = update.mock.calls[0] as [
string,
{ cost_price: number; notes: string },
];
expect(updatedId).toBe("row-1");
expect(patch.cost_price).toBe(50);
expect(typeof patch.notes).toBe("string");
// The new notes feed is a JSON array containing a single cost entry.
const parsed = JSON.parse(patch.notes);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed).toHaveLength(1);
const entry = parsed[0];
expect(entry.category).toBe("cost");
expect(entry.actorId).toBe("u-1");
expect(entry.actorName).toBe("sj");
expect(entry.body).toEqual({ prevCost: 45, newCost: 50 });
expect(typeof entry.ts).toBe("string");
// Returned row carries the new cost price (mock resolution merges
// the patch into the existing row). `ConfirmRow` is the consumer-
// facing shape and omits `cost_price` (the raw PB record has it),
// so cast to read the field the test is asserting on.
expect(result.id).toBe("row-1");
expect((result as { cost_price?: number }).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 });
});
});
describe("stubs for plan task 5", () => {
it("confirmOrderItemsForInvoicing throws 'not implemented'", async () => {
await expect(
confirmOrderItemsForInvoicing(
["oi-1"],
{
invoice: { date: "2026-08-01", number: "INV-1" },
costOverrides: {},
actor: { id: "u1", name: "U" },
},
),
).rejects.toThrow(/not implemented/i);
});
});