WFDASH-WEBAPP/src/lib/server/order-items-service.test.ts
Dave 9e8bb0a1ad feat(order-splitting): enhance order item splitting functionality and error handling
- Implemented a new split mechanism for order items, allowing operators to split quantities based on in-stock and non-in-stock statuses.
- Updated the payload for creating split order items to ensure it adheres to the PocketBase schema, avoiding empty strings for relation fields and removing deprecated fields.
- Enhanced error handling in the split route to log unexpected errors for better debugging.
- Improved the UI to conditionally render split buttons based on item availability, ensuring a clearer user experience.
- Added unit tests to validate the new splitting logic and payload structure, ensuring robustness against schema changes.
2026-08-17 14:14:27 +02:00

551 lines
19 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", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/server/orders-service")>();
return { ...actual, getOrderItemsCollectionName: () => "order_items" };
});
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import {
splitOrderItemRemainder,
confirmOrderItemsCollected,
OrderItemSplitError,
} from "./order-items-service";
const baseItem = (overrides: Record<string, unknown> = {}) => ({
id: "item-1",
order: "order-1",
description: "M20 × 40 hex nuts grade 8.8",
po_description: "M20 × 40 hex nuts grade 8.8",
quantity: 100,
order_qty: 40,
selling_price: 12.5,
cost_price: 8,
markup: 56.25,
markup_percent: 56.25,
supplier: "sup-1",
item_status: "pending",
status: "active",
drv_status: "unassigned",
in_stock: false,
notes: "",
sku: "SKU-42",
pricing_source: "quote",
matched_price_list_id: "price-list-1",
pricing_notes: "",
...overrides,
});
/**
* Build a mock admin client wired for the split flow:
* - order_items.getOne → the item being split (default: 100 / 40)
* - orders.getOne → the parent order exists
* - order_items.create → echoes the payload as the new row
* Returns the mock + the create spy so callers can assert the payload.
*/
const makeAdmin = (itemOverrides: Record<string, unknown> = {}) => {
const item = baseItem(itemOverrides);
const getOne = vi.fn().mockImplementation((id: string) => {
if (id === "order-1") return Promise.resolve({ id: "order-1" });
return Promise.resolve(item);
});
const getFullList = vi.fn().mockResolvedValue([]);
const create = vi.fn().mockImplementation((payload: Record<string, unknown>) =>
Promise.resolve({ id: "item-rem-1", ...payload }),
);
const update = vi.fn().mockImplementation((id: string, payload: Record<string, unknown>) =>
Promise.resolve({ ...item, ...payload }),
);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items") {
return { getOne, getFullList, create, update };
}
return { getOne };
}),
};
return { admin, getOne, getFullList, create, update };
};
describe("splitOrderItemRemainder", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a remainder line for the shortfall, supplier-less and pending", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await splitOrderItemRemainder("item-1");
expect(result.remainderQty).toBe(60);
expect(result.remainder.id).toBe("item-rem-1");
expect(create).toHaveBeenCalledTimes(1);
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload).toMatchObject({
order: "order-1",
description: "M20 × 40 hex nuts grade 8.8",
quantity: 60,
order_qty: 60,
selling_price: 12.5,
cost_price: 8,
supplier: null,
item_status: "pending",
// Audit-trail fields carried over…
sku: "SKU-42",
pricing_source: "quote",
matched_price_list_id: "price-list-1",
});
// …but the quote match stays on the original — `quote_item` is
// deliberately not in the create payload.
expect("quote_item" in (create.mock.calls[0][0] as Record<string, unknown>)).toBe(false);
// The remainder carries a split marker pointing back at its source.
const remainderDetails = JSON.parse(payload.order_item_details as string) as {
split: { sourceItemId: string; remainderQty: number };
};
expect(remainderDetails.split.sourceItemId).toBe("item-1");
expect(remainderDetails.split.remainderQty).toBe(60);
});
it("reduces the original's customer-facing quantity to its order_qty and stamps the source marker", async () => {
const { admin, update } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await splitOrderItemRemainder("item-1");
// 100 / 40 split → original becomes 40 / 40 so 40 + 60 = 100
// (not 100 + 60 = 160), and the split button hides since
// order_qty == quantity.
expect(update).toHaveBeenCalledWith(
"item-1",
expect.objectContaining({ quantity: 40 }),
);
const updatePayload = update.mock.calls[0][1] as Record<string, unknown>;
const details = JSON.parse(updatePayload.order_item_details as string) as {
split: { remainderItemId: string; remainderQty: number };
};
expect(details.split.remainderItemId).toBe("item-rem-1");
expect(details.split.remainderQty).toBe(60);
expect(result.original.quantity).toBe(40);
});
it("rejects a duplicate split when a remainder line already exists", async () => {
const { admin, getFullList, create, update } = makeAdmin();
getFullList.mockResolvedValue([
{
id: "item-rem-legacy",
order: "order-1",
description: "M20 × 40 hex nuts grade 8.8",
quantity: 60,
order_qty: 60,
supplier: null,
sku: "SKU-42",
},
]);
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err).toBeInstanceOf(OrderItemSplitError);
expect(err.status).toBe(409);
expect(create).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("copies po_description + in_stock + notes from the original", async () => {
const { admin, create } = makeAdmin({
po_description: "For supplier only: M20 nuts, grade 8.8",
in_stock: true,
notes: "prefer blue bins",
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1");
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload.po_description).toBe("For supplier only: M20 nuts, grade 8.8");
expect(payload.in_stock).toBe(true);
expect(payload.notes).toBe("prefer blue bins");
});
it("defaults order_qty to quantity when the field is missing (no split)", async () => {
const { admin, create } = makeAdmin({ order_qty: undefined });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await expect(splitOrderItemRemainder("item-1")).rejects.toThrow(
OrderItemSplitError,
);
expect(create).not.toHaveBeenCalled();
});
it("rejects a 0 order_qty — no split when the operator ordered nothing from this supplier", async () => {
const { admin, create } = makeAdmin({ order_qty: 0 });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err).toBeInstanceOf(OrderItemSplitError);
expect(err.status).toBe(422);
expect(create).not.toHaveBeenCalled();
});
it("rejects order_qty ≥ quantity — no shortfall to split", async () => {
const { admin, create } = makeAdmin({ order_qty: 100 });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err.status).toBe(422);
expect(create).not.toHaveBeenCalled();
});
it("rejects items past the editable window", async () => {
const { admin, create } = makeAdmin({ item_status: "po_generated" });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err.status).toBe(409);
expect(create).not.toHaveBeenCalled();
});
it("rejects a missing item with 404", async () => {
const { admin, create } = makeAdmin();
(admin.collection as unknown as ReturnType<typeof vi.fn>).mockImplementation(
(name: string) => {
if (name === "order_items") {
return {
getOne: vi.fn().mockRejectedValue(new Error("404")),
create,
};
}
return { getOne: vi.fn() };
},
);
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err.status).toBe(404);
expect(create).not.toHaveBeenCalled();
});
it("rejects an item with no parent order", async () => {
const { admin, create } = makeAdmin({ order: null });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const err = await splitOrderItemRemainder("item-1").catch((e) => e) as OrderItemSplitError;
expect(err.status).toBe(409);
expect(create).not.toHaveBeenCalled();
});
it("sends a clean create payload (no unknown fields, null PO link, no category unless present)", async () => {
const { admin, create } = makeAdmin();
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1");
const payload = create.mock.calls[0][0] as Record<string, unknown>;
// `status` and `drv_status` do not exist on the live order_items
// schema (v1 leftovers). PB silently strips unknown fields in
// older SDK builds and throws in stricter ones — don't send them.
expect(payload).not.toHaveProperty("status");
expect(payload).not.toHaveProperty("drv_status");
// Relation fields must be `null` for "no relation" — `""` is
// rejected by stricter PB builds as a malformed relation id.
expect(payload.purchase_order).toBeNull();
// `category` is optional on the schema and only inherited when
// the original line has it; the base mock has none.
expect(payload).not.toHaveProperty("category");
});
it("inherits category from the original when present", async () => {
const { admin, create } = makeAdmin({ category: "cat-fasteners" });
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await splitOrderItemRemainder("item-1");
const payload = create.mock.calls[0][0] as Record<string, unknown>;
expect(payload.category).toBe("cat-fasteners");
});
});
describe("confirmOrderItemsCollected", () => {
beforeEach(() => {
vi.clearAllMocks();
});
/**
* Build a mock admin client that hands out distinct order_items rows
* keyed by id, with a per-id status override (defaults to the base
* `collection_pool` eligibility). `update` echoes the merged payload
* so the service's `update` call can be asserted on directly.
*/
const makeConfirmAdmin = (
rowsById: Record<string, Record<string, unknown>>,
) => {
const getOne = vi.fn().mockImplementation((id: string) => {
const row = rowsById[id];
if (!row) return Promise.reject(new Error(`Row ${id} missing`));
return Promise.resolve(row);
});
const update = vi.fn().mockImplementation(
(id: string, payload: Record<string, unknown>) =>
Promise.resolve({ ...rowsById[id], ...payload, id }),
);
const admin = {
collection: vi.fn().mockImplementation((name: string) => {
if (name === "order_items") return { getOne, update };
return { getOne: vi.fn() };
}),
};
return { admin, getOne, update };
};
const ACTOR = { id: "agent-1", name: "Giellie" };
it("returns no-op shape for an empty id list", async () => {
const result = await confirmOrderItemsCollected([], { actor: ACTOR });
expect(result).toEqual({ results: [], confirmed: [], failed: [] });
});
it("confirms a collection_pool row, stamping all five audit fields and a JSON note", async () => {
const { admin, update } = makeConfirmAdmin({
"item-1": {
id: "item-1",
item_status: "collection_pool",
quantity: 10,
qty_collected: 0,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(["item-1"], { actor: ACTOR });
expect(result.confirmed).toEqual(["item-1"]);
expect(result.failed).toEqual([]);
expect(update).toHaveBeenCalledTimes(1);
const [, payload] = update.mock.calls[0];
expect(payload).toMatchObject({
item_status: "collected",
drv_status: "collected",
qty_collected: 10,
});
expect(payload.drv_collected_at).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(payload.collected_date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const parsedNotes = JSON.parse(payload.notes);
expect(parsedNotes).toHaveLength(1);
expect(parsedNotes[0]).toMatchObject({
actorId: "agent-1",
actorName: "Giellie",
category: "collection",
});
expect(typeof parsedNotes[0].body).toBe("string");
expect(parsedNotes[0].body).toMatch(/Confirmed collection without driver assignment/);
});
it("confirms an `assigned` row (driver-bound path also accepted)", async () => {
const { admin, update } = makeConfirmAdmin({
"item-2": {
id: "item-2",
item_status: "assigned",
quantity: 5,
qty_collected: 0,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(["item-2"], { actor: ACTOR });
expect(result.confirmed).toEqual(["item-2"]);
expect(update.mock.calls[0][1].item_status).toBe("collected");
});
it("supports a partial collect via quantity_collected override (additive to existing)", async () => {
const { admin, update } = makeConfirmAdmin({
"item-3": {
id: "item-3",
item_status: "collection_pool",
quantity: 100,
qty_collected: 30,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(
["item-3"],
{ actor: ACTOR, quantity_collected: { "item-3": 50 } },
);
expect(result.confirmed).toEqual(["item-3"]);
expect(update.mock.calls[0][1].qty_collected).toBe(80);
expect(result.results[0].previous).toEqual({ item_status: "collection_pool", qty_collected: 30 });
});
it("rejects a qty override that exceeds remaining with qty_overrun", async () => {
const { admin, update } = makeConfirmAdmin({
"item-4": {
id: "item-4",
item_status: "collection_pool",
quantity: 10,
qty_collected: 8,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(
["item-4"],
{ actor: ACTOR, quantity_collected: { "item-4": 5 } },
);
expect(result.confirmed).toEqual([]);
expect(result.failed).toEqual([{ id: "item-4", reason: "qty_overrun: requested 5 > remaining 2" }]);
expect(update).not.toHaveBeenCalled();
});
it("rejects rows already past the pool (collected / delivered / invoiced)", async () => {
const { admin, update } = makeConfirmAdmin({
"item-collected": {
id: "item-collected",
item_status: "collected",
quantity: 10,
qty_collected: 10,
notes: "",
},
"item-delivered": {
id: "item-delivered",
item_status: "delivered",
quantity: 10,
qty_collected: 10,
notes: "",
},
"item-invoiced": {
id: "item-invoiced",
item_status: "invoiced",
quantity: 10,
qty_collected: 10,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(
["item-collected", "item-delivered", "item-invoiced"],
{ actor: ACTOR },
);
expect(result.confirmed).toEqual([]);
expect(result.failed.map((f) => f.id)).toEqual([
"item-collected",
"item-delivered",
"item-invoiced",
]);
for (const f of result.failed) {
expect(f.reason).toMatch(/^wrong_status:/);
}
expect(update).not.toHaveBeenCalled();
});
it("reports per-row independence: one ok, one wrong_status, one not_found", async () => {
const { admin, update } = makeConfirmAdmin({
"item-ok": {
id: "item-ok",
item_status: "collection_pool",
quantity: 4,
qty_collected: 0,
notes: "",
},
"item-bad": {
id: "item-bad",
item_status: "cancelled",
quantity: 4,
qty_collected: 0,
notes: "",
},
// item-missing intentionally absent — getOne will reject
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
const result = await confirmOrderItemsCollected(
["item-ok", "item-bad", "item-missing"],
{ actor: ACTOR },
);
expect(result.confirmed).toEqual(["item-ok"]);
expect(result.failed.map((f) => f.id)).toEqual(["item-bad", "item-missing"]);
expect(update).toHaveBeenCalledTimes(1);
expect(result.results.find((r) => r.id === "item-ok")?.status).toBe("ok");
expect(result.results.find((r) => r.id === "item-bad")?.status).toBe("failed");
expect(result.results.find((r) => r.id === "item-missing")?.status).toBe("failed");
});
it("appends a new note without clobbering the existing notes feed", async () => {
const existingNote = {
id: "prior-1",
ts: "2026-08-01T10:00:00.000Z",
actorId: "u-1",
actorName: "Operator",
category: "note" as const,
body: "Customer requested delivery on Friday",
};
const { admin, update } = makeConfirmAdmin({
"item-5": {
id: "item-5",
item_status: "assigned",
quantity: 10,
qty_collected: 0,
notes: JSON.stringify([existingNote]),
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await confirmOrderItemsCollected(["item-5"], {
actor: ACTOR,
note: "Driver didn't show — agent confirmed pickup directly",
});
const [, payload] = update.mock.calls[0];
const notes = JSON.parse(payload.notes);
expect(notes).toHaveLength(2);
expect(notes[0]).toEqual(existingNote);
expect(notes[1]).toMatchObject({
actorId: "agent-1",
category: "collection",
});
expect(notes[1].body).toContain("Driver didn't show");
});
it("throws OrderItemConfirmError when actor is missing", async () => {
await expect(
confirmOrderItemsCollected(["item-1"], { actor: { id: "", name: "" } }),
).rejects.toMatchObject({
name: "OrderItemConfirmError",
code: "missing_actor",
status: 400,
});
});
it("accepts a caller-supplied collected_at date for the stamp", async () => {
const { admin, update } = makeConfirmAdmin({
"item-6": {
id: "item-6",
item_status: "collection_pool",
quantity: 2,
qty_collected: 0,
notes: "",
},
});
vi.mocked(getAdminPocketBase).mockResolvedValue(admin as never);
await confirmOrderItemsCollected(["item-6"], {
actor: ACTOR,
collected_at: "2026-08-09T15:30:00.000Z",
});
const [, payload] = update.mock.calls[0];
expect(payload.drv_collected_at).toBe("2026-08-09");
expect(payload.collected_date).toBe("2026-08-09");
});
});