430 lines
15 KiB
TypeScript
430 lines
15 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). `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);
|
||
});
|
||
});
|