WFDASH-WEBAPP/src/lib/server/orders-service.test.ts

3233 lines
123 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";
// --- PocketBase mock ---------------------------------------------------------
// The mock state has to be hoisted to the top of the file because Vitest
// hoists `vi.mock` factories above all imports. The state lives in a
// `vi.hoisted` block so the factory can close over it.
const { store, pbMock, clearStore } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
// Relation name → collection name. PB stores every relation as the
// singular id and the related records in the pluralised collection
// (e.g. `order.customer` → `customers` collection). The mock's
// getFullList uses this to resolve expand joins. Most words are
// `+s`, but a few are irregular — `company` → `companies`, etc.
const IRREGULAR_PLURALS: Record<string, string> = {
company: "companies",
};
const pluralize = (rel: string): string => {
if (IRREGULAR_PLURALS[rel]) return IRREGULAR_PLURALS[rel];
return rel.endsWith("s") ? rel : `${rel}s`;
};
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
// The mock `where` is intentionally minimal — supports the patterns the
// production service actually issues: `field = "value"` (with or without
// quotes), `field != "value"`, `&&` (AND), `||` (OR), and `id` lookups by
// PB record id. Anything unrecognised passes through (returns all items
// for the AND group), so unknown filters don't false-negative tests.
const matchesClause = (item: Record<string, unknown>, clause: string): boolean => {
const m = clause.match(/^(\w+)\s*(=~|!~|=|!=|>=|<=|<|>)\s*"?([^"]+?)"?$/);
if (!m) return true;
const [, field, op, raw] = m;
const expected = raw.replace(/^"|"$/g, "");
const actual = item[field];
if (field === "year" || field === "month") {
const num = Number(actual);
const want = Number(expected);
switch (op) {
case "=": return num === want;
case "!=": return num !== want;
default: return true;
}
}
const actualStr = String(actual ?? "");
// Date / numeric comparisons go through `actual` (not the stringified
// version) so PB-style `>=`/`<=`/`>`/`<` against ISO timestamps or
// numbers behaves the way the service layer expects. The mock only
// implements the subset the production code actually emits; if a
// caller adds a new operator the match falls through to `true` so
// tests don't false-negative.
if (op === ">=" || op === "<=" || op === ">" || op === "<") {
const a = actual as string | number | undefined | null;
if (a === undefined || a === null) return false;
switch (op) {
case ">=": return a > expected || a === expected;
case "<=": return a < expected || a === expected;
case ">": return a > expected;
case "<": return a < expected;
default: return true;
}
}
switch (op) {
case "=": return actualStr === expected;
case "!=": return actualStr !== expected;
case "=~": return actualStr.includes(expected);
case "!~": return !actualStr.includes(expected);
default: return true;
}
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
// Strip outer parens around the whole expression so the splitter
// below doesn't see `(` / `)` as clause-leading characters. PB
// filters are parenthesised when they mix `&&` / `||` — see
// `completedTabFilter` in orders-service.ts for the canonical
// example.
const stripped = filter.replace(/^\s*\(/, "").replace(/\)\s*$/, "");
// Split on `||` first (outer OR), then each alternative is a group of
// `&&`-joined AND clauses. An item passes if it satisfies ANY
// alternative group. We also strip wrapping parens around each
// alternative so individual clauses don't start with `(`.
const alternatives = stripped
.split("||")
.map((a) => a.trim().replace(/^\(/, "").replace(/\)$/, ""))
.filter(Boolean);
return items.filter((item) =>
alternatives.some((alt) => {
const clauses = alt
.split("&&")
.map((c) => c.trim().replace(/^\(/, "").replace(/\)$/, "").trim())
.filter(Boolean);
return clauses.every((clause) => matchesClause(item, clause));
}),
);
};
const applyExpand = (
rows: Record<string, unknown>[],
expand: string | undefined,
): Record<string, unknown>[] => {
if (!expand) return rows;
const relations = expand.split(",").map((s) => s.trim()).filter(Boolean);
return rows.map((it) => {
const exp: Record<string, unknown> = {};
for (const rel of relations) {
const relId = it[rel];
if (typeof relId === "string" && getOrCreateMap(pluralize(rel)).has(relId)) {
exp[rel] = getOrCreateMap(pluralize(rel)).get(relId);
}
}
return { ...it, expand: exp };
});
};
const pbMock = {
autoCancellation: () => undefined,
collection: (name: string) => ({
async getList(
page = 1,
perPage = 25,
opts: { filter?: string; sort?: string; expand?: string } = {},
) {
const all = where(name, opts.filter || "");
if (opts.sort) {
const sign = opts.sort.startsWith("-") ? -1 : 1;
const field = opts.sort.replace(/^-/, "");
all.sort((a, b) => {
const av = a[field] as string | number | undefined;
const bv = b[field] as string | number | undefined;
if (av === bv) return 0;
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sign;
});
}
// Real pagination — mirror PB's getList semantics so the
// service code (which relies on totalPages) exercises the same
// branches under test as in production.
const totalItems = all.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const items = applyExpand(all.slice(start, start + perPage), opts.expand);
return { items, totalItems, totalPages, page, perPage };
},
async getFullList(opts: { filter?: string; fields?: string; expand?: string } = {}) {
// Mirror production: respect the filter. The fields projection
// is a wire-payload optimisation in PB; for tests we just
// return the matched records as-is. Expand ("customer,company"
// etc.) is honoured by attaching the related records from the
// store under `expand.<relation>` so tests asserting on names
// pulled through the expand join pass.
const items = where(name, opts.filter || "");
return applyExpand(items, opts.expand);
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
if (name === "order_counters") {
const map = getOrCreateMap(name);
for (const item of map.values()) {
if (item.year === data.year && item.month === data.month) {
const err = new Error("unique constraint") as Error & { status: number };
err.status = 409;
throw err;
}
}
}
// Respect an explicit `id` when the test provides one — PB does
// the same, and several tests (expand joins, split continuations)
// rely on the predictable id to wire related records together.
const item = {
id: typeof data.id === "string" ? data.id : nextId(),
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(item.id, item);
return item;
},
async update(id: string, data: Record<string, unknown>) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
for (const [key, value] of Object.entries(data)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = new Date().toISOString();
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
};
return { store, pbMock, clearStore };
});
vi.mock("pocketbase", () => {
return {
default: vi.fn().mockImplementation(() => pbMock),
};
});
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
vi.mock("./order-quote-link-service", () => ({
pairOrderLines: vi.fn().mockResolvedValue({
confident: [], possible: [], unmatchedOrderItemIds: [],
}),
suggestQuoteForOrder: vi.fn().mockResolvedValue({ best: null }),
}));
vi.mock("./order-delivery-note-link-service", () => ({
suggestDeliveryNoteForOrder: vi.fn().mockResolvedValue({
best: null,
method: "no_match",
}),
}));
// `after()` normally defers its callback until the response has been
// sent — outside a real Next.js request, nothing ever flushes that
// queue. Stub it to run the callback inline so `createOrder` tests can
// assert on the scheduled quote-match synchronously.
vi.mock("next/server", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/server")>();
return {
...actual,
after: vi.fn((cb: () => unknown) => { void cb(); }),
};
});
import {
createOrder,
createOrderFromQuote,
clearOrderItemStatusTabCountsCache,
getOrderItemStatusTabCounts,
getOrderIdsForTabGroup,
getOrderItemsCollectionName,
getOrdersArchiveData,
getOrdersDashboardData,
listOrdersWithItems,
splitOrderForContinuation,
cancelOrder,
cancelOrdersBulk,
findDuplicateOrders,
// Hermes unblock (PR 1, 2026-08-21):
setOrderQuote,
findOrderByOrderNumber,
findOrderByEmailMessageId,
} from "./orders-service";
beforeEach(() => {
clearStore();
});
// --- Tests -------------------------------------------------------------------
describe("getOrderItemsCollectionName", () => {
it("defaults to 'order_items' when env var is unset", () => {
const original = process.env.ORDER_ITEMS_COLLECTION;
delete process.env.ORDER_ITEMS_COLLECTION;
try {
expect(getOrderItemsCollectionName()).toBe("order_items");
} finally {
if (original !== undefined) process.env.ORDER_ITEMS_COLLECTION = original;
}
});
it("returns the env var when set (e.g. order_items_v2)", () => {
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
try {
expect(getOrderItemsCollectionName()).toBe("order_items_v2");
} finally {
delete process.env.ORDER_ITEMS_COLLECTION;
}
});
});
describe("createOrderFromQuote", () => {
it("returns the existing order when one already references the quote", async () => {
// Pre-seed an existing order in the store; dedupe check should find it
// and short-circuit before the rest of the pipeline runs.
await pbMock.collection("orders").create({
id: "order-1",
quote: "quote-abc",
order_number: "ORD-2025-01-001",
});
const result = await createOrderFromQuote("quote-abc");
expect(result.already_exists).toBe(true);
expect(result.order.id).toBe("order-1");
expect(result.created_items).toBe(0);
});
it("throws when the quote has no items", async () => {
await pbMock.collection("quotes").create({
id: "quote-empty",
company: "comp-1",
customer: "cust-1",
notes: "",
});
// No quote_items rows → service must throw.
await expect(createOrderFromQuote("quote-empty")).rejects.toThrow(
/no items to convert/,
);
});
it("creates an order, copies every quote_item, and sets the right defaults", async () => {
// Seed the dependencies in the mock store.
await pbMock.collection("quotes").create({
id: "quote-99",
company: "comp-7",
customer: "cust-3",
notes: "Rush",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "quote-99",
description: "Widget",
quantity: 3,
selling_price: 100,
supplier: "sup-1",
cost_price: 50,
markup: 10,
notes: "front",
});
await pbMock.collection("quote_items").create({
id: "qi-2",
quote: "quote-99",
description: "Sprocket",
quantity: 2,
selling_price: 75,
supplier: "sup-2",
cost_price: 30,
markup: 0,
notes: "",
});
const result = await createOrderFromQuote("quote-99");
expect(result.already_exists).toBe(false);
expect(result.created_items).toBe(2);
// Pull the created order directly out of the store.
const orders = await pbMock.collection("orders").getFullList();
const created = orders.find((o) => o.quote === "quote-99") as
| Record<string, unknown>
| undefined;
expect(created).toBeDefined();
expect(created?.quote).toBe("quote-99");
expect(created?.company).toBe("comp-7");
expect(created?.customer).toBe("cust-3");
expect(created?.quote_matching_attempted).toBe(true);
expect(created?.is_repair_order).toBe(false);
expect(created?.invoiced).toBe(false);
// Starts at ORD-{now-year}-{now-month}-001 because the orders table
// is empty for this test (no prior order to extend from).
const now = new Date();
const expectedPrefix = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-`;
expect(String(created?.order_number)).toBe(`${expectedPrefix}001`);
// Order-item payload assertions: the two quote_items should each
// have produced one order_item row.
const orderItems = await pbMock.collection("order_items").getFullList();
const ourItems = orderItems.filter((o) => o.order === created?.id);
expect(ourItems.length).toBe(2);
const widget = ourItems.find((o) => o.description === "Widget");
expect(widget).toMatchObject({
po_description: "Widget",
quantity: 3,
order_qty: 3,
qty_ordered: "3",
selling_price: 100,
supplier: "sup-1",
cost_price: 50,
markup: 10,
markup_percent: 10,
item_status: "pending",
status: "active",
drv_status: "unassigned",
notes: "front",
quote_item: "qi-1",
});
const sprocket = ourItems.find((o) => o.description === "Sprocket");
expect(sprocket?.quote_item).toBe("qi-2");
expect(sprocket?.supplier).toBe("sup-2");
expect(sprocket?.drv_status).toBe("unassigned");
});
it("extends the latest order's sequence (no month-boundary reset)", async () => {
// The most recent order is in June; we're now in July. The contract
// is that the next order continues June's sequence (NOT a fresh
// July-001). The old code's bug was using `now` month, which would
// have generated `ORD-2026-07-001` here.
await pbMock.collection("orders").create({
order_number: "ORD-2026-06-007",
quote: "some-other-quote",
});
await pbMock.collection("quotes").create({
id: "quote-seq",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "quote-seq",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("quote-seq");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "quote-seq");
expect(newOrder?.order_number).toBe("ORD-2026-06-008");
});
it("starts a fresh month at 001 when the orders table is empty", async () => {
// The orders collection is empty → no most-recent order to extend
// from. Falls back to wall-clock year/month, sequence at 001.
await pbMock.collection("quotes").create({
id: "q-fresh",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-fresh",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("q-fresh");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "q-fresh");
const now = new Date();
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
expect(newOrder?.order_number).toBe(expected);
});
it("falls back to wall-clock year/month when the latest order_number is unparseable", async () => {
// A junk order_number (legacy import, manual edit, or old data with a
// different prefix) should NOT break the generator. The service
// detects unparseable prefixes and uses now year/month.
await pbMock.collection("orders").create({
order_number: "GARBAGE-2026-06-007",
quote: "old",
});
await pbMock.collection("quotes").create({
id: "q-junk",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-junk",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
await createOrderFromQuote("q-junk");
const orders = await pbMock.collection("orders").getFullList();
const newOrder = orders.find((o) => o.quote === "q-junk");
const now = new Date();
const expected = `ORD-${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-001`;
expect(newOrder?.order_number).toBe(expected);
});
it("writes to the collection named by ORDER_ITEMS_COLLECTION env var", async () => {
process.env.ORDER_ITEMS_COLLECTION = "order_items_v2";
await pbMock.collection("quotes").create({
id: "q",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
const result = await createOrderFromQuote("q");
expect(result.order_items_collection).toBe("order_items_v2");
const v2Rows = await pbMock.collection("order_items_v2").getFullList();
expect(v2Rows.length).toBe(1);
expect(v2Rows[0].order).toBeDefined();
delete process.env.ORDER_ITEMS_COLLECTION;
});
it("concurrent calls produce unique order numbers (no race)", async () => {
// Set up the dependencies for a single order.
await pbMock.collection("quotes").create({
id: "q-race",
company: "c",
customer: null,
notes: "",
});
await pbMock.collection("quote_items").create({
id: "qi-1",
quote: "q-race",
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
// Run 5 createOrderFromQuote calls in parallel against distinct
// quotes. The counter must give each a unique number even under
// simulated concurrency. (Note: the test mock is not actually
// concurrent — the `await`s in the implementation interleave the
// operations — so the assertion verifies the counter pattern, not
// true concurrency. Real concurrency safety comes from PB's
// `last_seq+` server-side increment.)
const quotes = ["q-race-a", "q-race-b", "q-race-c", "q-race-d", "q-race-e"];
for (const id of quotes) {
await pbMock.collection("quotes").create({ id, company: "c", customer: null, notes: "" });
await pbMock.collection("quote_items").create({
id: `qi-${id}`,
quote: id,
description: "x",
quantity: 1,
selling_price: 1,
cost_price: 0,
markup: 0,
notes: "",
});
}
const results = await Promise.all(quotes.map((id) => createOrderFromQuote(id)));
const numbers = results.map((r) => String((r.order as unknown as { order_number: string }).order_number));
expect(new Set(numbers).size).toBe(5);
});
});
// ----------------------------------------------------------------------------
// Standalone order creation — createOrder. Use for counter sales / walk-ins /
// internal transfers where there's no originating quote.
// ----------------------------------------------------------------------------
describe("createOrder", () => {
it("creates an order with no quote and the supplied items", async () => {
// Seed the relation targets.
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const result = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
notes: "Counter sale — cash",
contact_name: "Mr Dreyer",
rep: "Giellie",
items: [
{ description: "M16 Bolt", quantity: 50, cost_price: 5, markup: 50, selling_price: 7.5 },
{ description: "M16 Nut", quantity: 50, cost_price: 3, markup: 50, selling_price: 4.5 },
],
});
expect(result.order.id).toBeDefined();
expect(result.order.customer).toBe("cust-x");
expect(result.order.company).toBe("comp-x");
expect(result.order.notes).toBe("Counter sale — cash");
expect(result.order.contact_name).toBe("Mr Dreyer");
expect(result.order.rep).toBe("Giellie");
// Quote-related fields stay blank — no originating quote.
expect(result.order.quote).toBeNull();
expect(result.order.potential_quote).toBeNull();
expect(result.order.quote_matching_attempted).toBe(false);
expect(result.order.quote_match_confidence).toBe(0);
expect(result.order.potential_quote_confidence).toBe(0);
expect(result.order.is_repair_order).toBe(false);
expect(result.order.invoiced).toBe(false);
// Two items created, with the per-row defaults the rest of the
// pipeline relies on.
expect(result.items_created.length).toBe(2);
expect(result.items_failed.length).toBe(0);
const orderItems = await pbMock.collection("order_items").getFullList();
const ours = orderItems.filter((o) => o.order === result.order.id);
expect(ours.length).toBe(2);
for (const it of ours) {
expect(it.item_status).toBe("pending");
expect(it.drv_status).toBe("unassigned");
expect(it.status).toBe("active");
}
});
it("patches secondary fields (sku/unit/supplier_id/notes) onto each item", async () => {
await pbMock.collection("customers").create({ id: "cust-y" });
await pbMock.collection("companies").create({ id: "comp-y" });
await pbMock.collection("suppliers").create({ id: "sup-y" });
const result = await createOrder({
customer_id: "cust-y",
company_id: "comp-y",
items: [
{
description: "Bearing 6205",
quantity: 4,
cost_price: 80,
markup: 25,
selling_price: 100,
sku: "BRG-6205",
unit: "EA",
supplier_id: "sup-y",
notes: "From shelf",
},
],
});
expect(result.items_created.length).toBe(1);
expect(result.items_failed.length).toBe(0);
const orderItems = await pbMock.collection("order_items").getFullList();
const ours = orderItems.filter((o) => o.order === result.order.id);
expect(ours[0]).toMatchObject({
sku: "BRG-6205",
unit: "EA",
supplier: "sup-y",
notes: "From shelf",
selling_price: 100,
cost_price: 80,
});
});
it("throws when customer_id does not resolve", async () => {
// No customers seeded — getOne will 404.
await pbMock.collection("companies").create({ id: "comp-z" });
await expect(
createOrder({
customer_id: "ghost",
company_id: "comp-z",
items: [{ description: "x" }],
}),
).rejects.toThrow(/Customer not found/);
});
it("throws when company_id does not resolve", async () => {
await pbMock.collection("customers").create({ id: "cust-z" });
await expect(
createOrder({
customer_id: "cust-z",
company_id: "ghost",
items: [{ description: "x" }],
}),
).rejects.toThrow(/Company not found/);
});
it("throws when items array is empty", async () => {
await pbMock.collection("customers").create({ id: "cust-1" });
await pbMock.collection("companies").create({ id: "comp-1" });
await expect(
createOrder({ customer_id: "cust-1", company_id: "comp-1", items: [] }),
).rejects.toThrow(/items must be a non-empty array/);
});
it("throws when customer_id is missing", async () => {
await pbMock.collection("companies").create({ id: "comp-1" });
await expect(
createOrder({
customer_id: "",
company_id: "comp-1",
items: [{ description: "x" }],
}),
).rejects.toThrow(/customer_id and company_id are required/);
});
});
// ----------------------------------------------------------------------------
// createOrder email_message_id dedup pre-check.
//
// The n8n email-intake pipeline can pick up the same IMAP message multiple
// times if the seen-flag races the workflow execution. Without this
// dedup check, every poll cycle creates a brand new `orders` row.
// `createOrder` (the service layer) is the line of defense for the
// agent-API / counter / quote pipeline; the n8n workflow has its own
// parallel check at the workflow layer.
//
// See docs/audit/order-pipeline-investigation-2026-08-20.md §2.
// ----------------------------------------------------------------------------
describe("createOrder — email_message_id dedup", () => {
it("returns the existing order when an email_message_id already exists, and marks duplicate=true", async () => {
await pbMock.collection("customers").create({ id: "cust-d1" });
await pbMock.collection("companies").create({ id: "comp-d1" });
// Seed an existing order with the same email_message_id — this is
// what would happen if the n8n pipeline already created one and
// the next poll cycle is asking for the same write.
await pbMock.collection("orders").create({
id: "order-existing",
order_number: "ZA10PO26002142",
company: "comp-d1",
customer: "cust-d1",
email_message_id: "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>",
contact_name: "Neville Benadé",
});
const result = await createOrder({
customer_id: "cust-d1",
company_id: "comp-d1",
contact_name: "Nbenade", // a different LLM-extracted name — irrelevant, we should NOT overwrite
email_message_id: "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
// Same record came back, no new order created.
expect(result.duplicate).toBe(true);
expect(result.order.id).toBe("order-existing");
expect(result.order.order_number).toBe("ZA10PO26002142");
expect(result.order.contact_name).toBe("Neville Benadé");
expect(result.items_created).toEqual([]);
expect(result.items_failed).toEqual([]);
// The store should still have exactly one record keyed by this id.
const orders = await pbMock.collection("orders").getFullList();
expect(orders.filter((o) => o.email_message_id === "<DUPLICATE-MSG-ID@eurprd05.prod.outlook.com>")).toHaveLength(1);
});
it("creates a brand-new order when email_message_id is unique", async () => {
await pbMock.collection("customers").create({ id: "cust-d2" });
await pbMock.collection("companies").create({ id: "comp-d2" });
const result = await createOrder({
customer_id: "cust-d2",
company_id: "comp-d2",
contact_name: "Mr Fresh",
email_message_id: "<FRESH-MSG-ID@eurprd05.prod.outlook.com>",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(result.duplicate).toBeUndefined();
expect(result.order.id).toBeDefined();
expect(result.order.email_message_id).toBe("<FRESH-MSG-ID@eurprd05.prod.outlook.com>");
expect(result.items_created.length).toBe(1);
});
it("skips the dedup check entirely when email_message_id is empty string", async () => {
await pbMock.collection("customers").create({ id: "cust-d3" });
await pbMock.collection("companies").create({ id: "comp-d3" });
const first = await createOrder({
customer_id: "cust-d3",
company_id: "comp-d3",
email_message_id: "",
items: [{ description: "Counter sale item" }],
});
// Counter-form orders don't carry an email_message_id — we never
// dedup them, so two should be creatable.
const second = await createOrder({
customer_id: "cust-d3",
company_id: "comp-d3",
email_message_id: "",
items: [{ description: "Another counter sale" }],
});
expect(first.duplicate).toBeUndefined();
expect(second.duplicate).toBeUndefined();
expect(first.order.id).not.toBe(second.order.id);
});
it("skips the dedup check when email_message_id is undefined", async () => {
await pbMock.collection("customers").create({ id: "cust-d4" });
await pbMock.collection("companies").create({ id: "comp-d4" });
const result = await createOrder({
customer_id: "cust-d4",
company_id: "comp-d4",
// email_message_id omitted entirely
items: [{ description: "Walk-in" }],
});
expect(result.duplicate).toBeUndefined();
expect(result.order.id).toBeDefined();
});
it("trims whitespace before the dedup lookup", async () => {
await pbMock.collection("customers").create({ id: "cust-d5" });
await pbMock.collection("companies").create({ id: "comp-d5" });
await pbMock.collection("orders").create({
id: "order-padded",
order_number: "ZA10PO26002142",
company: "comp-d5",
customer: "cust-d5",
email_message_id: "<PADDED@x.com>",
});
// Caller passes the same id with surrounding whitespace — should
// still hit the dedup path.
const result = await createOrder({
customer_id: "cust-d5",
company_id: "comp-d5",
email_message_id: " <PADDED@x.com>\n",
items: [{ description: "x" }],
});
expect(result.duplicate).toBe(true);
expect(result.order.id).toBe("order-padded");
});
it("returns the existing record when the DB-level UNIQUE constraint rejects the insert (race)", async () => {
await pbMock.collection("customers").create({ id: "cust-d6" });
await pbMock.collection("companies").create({ id: "comp-d6" });
// Race scenario: the pre-check finds NO existing record (no
// contention yet), but by the time we INSERT, another worker
// has already created the row (parallel n8n execution, or a
// manual /api/v1/orders/create call). The DB rejects our insert
// with a unique-constraint error. The catch block in createOrder
// re-queries by email_message_id, finds the existing record, and
// returns it as duplicate.
//
// The mock's `collection(name)` returns a fresh object every call,
// so we wrap `pbMock.collection` itself to cache the per-collection
// objects — that way monkey-patching `ordersColl.create` actually
// affects the object createOrder will see.
const collectionCache = new Map<string, ReturnType<typeof pbMock.collection>>();
const originalCollection = pbMock.collection;
pbMock.collection = ((name: string) => {
if (!collectionCache.has(name)) {
collectionCache.set(name, originalCollection(name));
}
return collectionCache.get(name)!;
}) as typeof pbMock.collection;
const ordersColl = pbMock.collection("orders") as unknown as { create: (data: Record<string, unknown>) => Promise<Record<string, unknown>> };
const originalCreate = ordersColl.create;
let insertAttempts = 0;
ordersColl.create = async (data: Record<string, unknown>) => {
insertAttempts++;
if (insertAttempts === 1) {
// First attempt: the DB-level UNIQUE constraint rejects us
// because another worker already inserted the same
// email_message_id. Before throwing, insert the "raced"
// record so the catch block's lookup can find it.
const err = new Error(
"Failed to create record. UNIQUE constraint failed: index idx_orders_email_message_id",
) as Error & { status: number };
err.status = 409;
await originalCreate({
id: "order-race",
order_number: "RACE-1",
company: "comp-d6",
customer: "cust-d6",
email_message_id: "<RACE@x.com>",
...data,
});
throw err;
}
return originalCreate(data);
};
try {
const result = await createOrder({
customer_id: "cust-d6",
company_id: "comp-d6",
email_message_id: "<RACE@x.com>",
items: [{ description: "x" }],
});
expect(result.duplicate).toBe(true);
expect(result.order.id).toBe("order-race");
expect(insertAttempts).toBe(1); // we did try to insert; the catch caught the unique violation
} finally {
ordersColl.create = originalCreate;
pbMock.collection = originalCollection;
}
});
});
// ----------------------------------------------------------------------------
// Order split — splitOrderForContinuation. Caller passes the item IDs they
// want moved; unlisted items stay on the parent.
// ----------------------------------------------------------------------------
describe("splitOrderForContinuation", () => {
it("moves pending items to a new continuation order and stamps breadcrumbs", async () => {
// Seed: a parent order with three pending items.
await pbMock.collection("orders").create({
id: "ord-1",
order_number: "ORD-2026-08-001",
company: "comp-1",
customer: "cust-1",
quote: "q-1",
notes: "Original notes",
rep: "Alice",
contact_name: "Mr Buyer",
quote_reference_text: "RFQ-123",
rfq_number: "RFQ-123",
});
for (let i = 0; i < 3; i++) {
await pbMock.collection("order_items").create({
id: `oi-${i + 1}`,
order: "ord-1",
description: `Item ${i + 1}`,
quantity: 1,
order_qty: 1,
item_status: "pending",
drv_status: "unassigned",
status: "active",
selling_price: 10,
cost_price: 5,
markup: 50,
markup_percent: 50,
sku: `SKU-${i + 1}`,
unit: "EA",
notes: "",
quote_item: `qi-${i + 1}`,
});
}
const result = await splitOrderForContinuation("ord-1", ["oi-1", "oi-2"]);
// Continuation order exists, inherited the parent's identity fields.
expect(result.continuation.id).toBeDefined();
expect(result.continuation.id).not.toBe("ord-1");
expect(result.continuation.company).toBe("comp-1");
expect(result.continuation.customer).toBe("cust-1");
expect(result.continuation.quote).toBe("q-1");
expect(result.continuation.rep).toBe("Alice");
expect(result.continuation.contact_name).toBe("Mr Buyer");
expect(result.continuation.quote_reference_text).toBe("RFQ-123");
expect(result.continuation.rfq_number).toBe("RFQ-123");
// Notes get the breadcrumb appended.
expect(result.continuation.notes).toMatch(/Original notes/);
expect(result.continuation.notes).toMatch(/Split from ORD-2026-08-001/);
// Two items moved, one kept.
expect(result.itemsMoved).toBe(2);
expect(result.itemsKept).toBe(1);
expect(result.itemsFailed).toEqual([]);
// Original items still belong to the parent (order relation unchanged).
const allItems = await pbMock.collection("order_items").getFullList();
const onParent = allItems.filter((it) => it.order === "ord-1");
expect(onParent.length).toBe(3);
// Moved items have the breadcrumb in their notes.
const movedO1 = onParent.find((it) => it.id === "oi-1");
const movedO2 = onParent.find((it) => it.id === "oi-2");
expect(movedO1?.notes).toMatch(/Moved to ORD-/);
expect(movedO2?.notes).toMatch(/Moved to ORD-/);
expect(movedO1?.notes).toMatch(/via order split on /);
// Unmoved item has no breadcrumb.
const keptO3 = onParent.find((it) => it.id === "oi-3");
expect(keptO3?.notes ?? "").not.toMatch(/Moved to/);
// New order gets two clones with the original fields preserved
// and workflow flags reset to defaults.
const onContinuation = allItems.filter((it) => it.order === result.continuation.id);
expect(onContinuation.length).toBe(2);
for (const clone of onContinuation) {
expect(clone.item_status).toBe("pending");
expect(clone.drv_status).toBe("unassigned");
expect(clone.status).toBe("active");
expect(clone.quote_item).toMatch(/^qi-/);
}
});
it("refuses to move items in non-pending statuses, reporting them per-item", async () => {
await pbMock.collection("orders").create({
id: "ord-2",
order_number: "ORD-2026-08-002",
company: "comp-1",
customer: "cust-1",
quote: "",
notes: "",
});
await pbMock.collection("order_items").create({
id: "oi-pending",
order: "ord-2",
description: "Pending",
item_status: "pending",
quantity: 1,
});
await pbMock.collection("order_items").create({
id: "oi-po",
order: "ord-2",
description: "PO generated",
item_status: "po_generated",
quantity: 1,
});
await pbMock.collection("order_items").create({
id: "oi-delivered",
order: "ord-2",
description: "Delivered",
item_status: "delivered",
quantity: 1,
});
const result = await splitOrderForContinuation("ord-2", [
"oi-pending",
"oi-po",
"oi-delivered",
]);
// Only the pending one moves. The other two are reported in itemsFailed
// with their original status names in the message.
expect(result.itemsMoved).toBe(1);
expect(result.itemsFailed.length).toBe(2);
expect(result.itemsFailed.find((f) => f.id === "oi-po")).toMatchObject({
id: "oi-po",
});
expect(result.itemsFailed.find((f) => f.id === "oi-delivered")?.error).toMatch(
/delivered/,
);
// The continuation order still exists even with partial moves.
expect(result.continuation.id).toBeDefined();
const allItems = await pbMock.collection("order_items").getFullList();
expect(allItems.filter((it) => it.order === result.continuation.id).length).toBe(1);
// The refused items are untouched on the parent (no notes breadcrumb).
expect(allItems.find((it) => it.id === "oi-po")?.notes ?? "").not.toMatch(/Moved to/);
});
it("reports items that do not belong to the parent order", async () => {
await pbMock.collection("orders").create({
id: "ord-3",
order_number: "ORD-2026-08-003",
company: "comp-1",
customer: "cust-1",
notes: "",
});
await pbMock.collection("order_items").create({
id: "oi-on-ord-3",
order: "ord-3",
description: "Belongs",
item_status: "pending",
quantity: 1,
});
await pbMock.collection("orders").create({
id: "ord-other",
order_number: "ORD-2026-08-099",
company: "comp-1",
customer: "cust-1",
notes: "",
});
await pbMock.collection("order_items").create({
id: "oi-other-order",
order: "ord-other",
description: "Foreign",
item_status: "pending",
quantity: 1,
});
const result = await splitOrderForContinuation("ord-3", [
"oi-on-ord-3",
"oi-other-order",
"oi-doesnt-exist",
]);
expect(result.itemsMoved).toBe(1);
expect(result.itemsFailed.length).toBe(2);
expect(result.itemsFailed.find((f) => f.id === "oi-other-order")?.error).toMatch(
/does not belong to order ord-3/,
);
expect(result.itemsFailed.find((f) => f.id === "oi-doesnt-exist")?.error).toMatch(
/does not belong to order ord-3/,
);
});
it("throws when the parent order does not exist", async () => {
await expect(
splitOrderForContinuation("ghost-order", ["oi-1"]),
).rejects.toThrow(/Order not found/);
});
it("throws when itemIds is empty", async () => {
await pbMock.collection("orders").create({
id: "ord-4",
order_number: "ORD-2026-08-004",
company: "comp-1",
customer: "cust-1",
notes: "",
});
await expect(splitOrderForContinuation("ord-4", [])).rejects.toThrow(
/itemIds must be a non-empty array/,
);
});
});
// ----------------------------------------------------------------------------
// /orders dashboard read model — tab filter, counts cache, rep lookup.
//
// These tests cover the bug surface fixed in 2026-07: the old
// `getOrdersDashboardData` returned the global orders pagination
// (Page 1 of 226, 4520 total orders) regardless of which tab the
// operator selected, so "Processing (4)" badges showed empty pages
// when the four matching orders happened to fall on later pages.
// ----------------------------------------------------------------------------
describe("getOrderIdsForTabGroup", () => {
it("returns the distinct order ids with at least one item in the tab group", async () => {
// Three orders, each with one item. Statuses cover "new",
// "processing", and "delivered" tabs so we can verify partition
// membership.
await pbMock.collection("orders").create({
id: "o-new",
order_number: "ORD-2025-01-001",
quote: "q1",
});
await pbMock.collection("orders").create({
id: "o-processing",
order_number: "ORD-2025-01-002",
quote: "q2",
});
await pbMock.collection("orders").create({
id: "o-delivered",
order_number: "ORD-2025-01-003",
quote: "q3",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-new", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-processing", description: "Sprocket",
item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-delivered", description: "Gear",
item_status: "delivered",
});
const processingIds = await getOrderIdsForTabGroup(pbMock as never, "processing");
expect(processingIds.sort()).toEqual(["o-processing"]);
const newIds = await getOrderIdsForTabGroup(pbMock as never, "new");
expect(newIds.sort()).toEqual(["o-new"]);
const deliveredIds = await getOrderIdsForTabGroup(pbMock as never, "delivered");
expect(deliveredIds.sort()).toEqual(["o-delivered"]);
});
it("dedupes when an order has multiple items in the same tab", async () => {
await pbMock.collection("orders").create({
id: "o-multi",
order_number: "ORD-2025-01-010",
quote: "q",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-multi", description: "A",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-multi", description: "B",
item_status: "pending",
});
const ids = await getOrderIdsForTabGroup(pbMock as never, "new");
expect(ids).toEqual(["o-multi"]);
});
});
describe("getOrderItemStatusTabCounts", () => {
beforeEach(() => clearOrderItemStatusTabCountsCache());
it("returns the count of distinct orders per tab", async () => {
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-a", description: "x", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-a", description: "y", item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-b", description: "z", item_status: "pending",
});
const counts = await getOrderItemStatusTabCounts(pbMock as never);
expect(counts.new).toBe(2);
expect(counts.processing).toBe(1);
});
it("bypasses the cache when bypassCache is true", async () => {
await pbMock.collection("orders").create({
id: "o-cache", order_number: "ORD-2025-01-001", quote: "qc",
});
await pbMock.collection("order_items").create({
id: "oi-cache", order: "o-cache", description: "x", item_status: "pending",
});
const first = await getOrderItemStatusTabCounts(pbMock as never);
expect(first.new).toBe(1);
// Mutate the underlying store directly — bypasses our normal
// service writes, but lets us verify the cache is being served
// if `bypassCache` is not set.
await pbMock.collection("orders").create({
id: "o-cache-2", order_number: "ORD-2025-01-002", quote: "qc2",
});
await pbMock.collection("order_items").create({
id: "oi-cache-2", order: "o-cache-2", description: "x", item_status: "pending",
});
const cached = await getOrderItemStatusTabCounts(pbMock as never);
expect(cached.new).toBe(1); // unchanged — cache hit
const fresh = await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
expect(fresh.new).toBe(2); // refresh sees the new order
});
it("clearOrderItemStatusTabCountsCache forces a fresh read", async () => {
await pbMock.collection("orders").create({
id: "o-clear", order_number: "ORD-2025-01-001", quote: "qclr",
});
await pbMock.collection("order_items").create({
id: "oi-clear", order: "o-clear", description: "x", item_status: "pending",
});
await getOrderItemStatusTabCounts(pbMock as never); // prime the cache
clearOrderItemStatusTabCountsCache();
const fresh = await getOrderItemStatusTabCounts(pbMock as never);
expect(fresh.new).toBe(1);
});
it("fires two combined getFullList calls (combined tab scan + completed time-window scan)", async () => {
// Regression guard: the pre-`completed`-tab implementation issued
// 6 parallel `getFullList` calls (one per tab), each with a
// separate filter scan. With ~18k rows in `order_items` and no
// `item_status` index, those 6 scans dominated the page-load
// budget. The current implementation collapses to:
// (a) ONE combined scan covering every tab status, used to
// bucket the partition's "primary" tab counts, plus
// (b) ONE time-windowed scan for the `completed` tab (recent
// `delivered` + `completed` items only).
// Total = 2 scans — still O(1) on the number of tabs, and each
// scan is fields-only so the wire payload stays tiny. This test
// intercepts the mock's `collection()` to count how many times
// `getFullList` is called on the `order_items` collection.
await pbMock.collection("order_items").create({
id: "oi-1", order: "o1", description: "x", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o2", description: "x", item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o3", description: "x", item_status: "delivered",
});
const originalCollection = pbMock.collection;
let getFullListCalls = 0;
pbMock.collection = ((name: string) => {
const proxy = originalCollection(name);
if (name === "order_items") {
const originalGetFullList = proxy.getFullList;
proxy.getFullList = ((...args: unknown[]) => {
getFullListCalls++;
return (originalGetFullList as (...a: unknown[]) => unknown).call(
proxy,
...args,
);
}) as typeof proxy.getFullList;
}
return proxy;
}) as typeof pbMock.collection;
try {
await getOrderItemStatusTabCounts(pbMock as never, { bypassCache: true });
expect(getFullListCalls).toBe(2);
} finally {
pbMock.collection = originalCollection;
}
});
it("can skip archive tabs for the main orders page", async () => {
await pbMock.collection("order_items").create({
id: "oi-1", order: "o1", description: "x", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o2", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o3", description: "x", item_status: "cancelled",
});
const counts = await getOrderItemStatusTabCounts(pbMock as never, {
bypassCache: true,
includeArchivedTabs: false,
});
expect(counts.new).toBe(1);
expect(counts.delivered).toBe(0);
expect(counts.cancelled).toBe(0);
});
});
describe("listOrdersWithItems — tab filter", () => {
it("returns only orders that have at least one item in the requested tab", async () => {
// Two orders: one with a "pending" item, one with a "processed"
// item. Filtered to "processing" should yield only the second.
await pbMock.collection("orders").create({
id: "o-pending", order_number: "ORD-2025-01-001", quote: "qp",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-processed", order_number: "ORD-2025-01-002", quote: "qpr",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-pending", order: "o-pending", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-processed", order: "o-processed", description: "Sprocket",
item_status: "processed",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
tabGroup: "processing",
});
expect(result.items.map((i) => i.order.id)).toEqual(["o-processed"]);
expect(result.totalItems).toBe(1);
expect(result.totalPages).toBe(1);
});
it("returns an empty list with totalPages=0 when no orders match the tab", async () => {
await pbMock.collection("orders").create({
id: "o-only-new", order_number: "ORD-2025-01-001", quote: "qn",
});
await pbMock.collection("order_items").create({
id: "oi-only-new", order: "o-only-new", description: "x",
item_status: "pending",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
tabGroup: "cancelled",
});
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
});
it("reports totalItems from the matching set, not the page slice (pagination honesty)", async () => {
// Five orders with processed items, perPage=2. The page-1 response
// should have 2 items but totalItems=5 / totalPages=3 so the
// footer says "Page 1 of 3" — matching the operator's mental
// model of how many orders the tab actually contains.
for (let i = 0; i < 5; i++) {
await pbMock.collection("orders").create({
id: `o-${i}`,
order_number: `ORD-2025-01-00${i + 1}`,
quote: `q-${i}`,
created: `2025-01-0${i + 1}T00:00:00Z`,
});
await pbMock.collection("order_items").create({
id: `oi-${i}`,
order: `o-${i}`,
description: "x",
item_status: "processed",
});
}
const result = await listOrdersWithItems({
page: 1,
perPage: 2,
tabGroup: "processing",
});
expect(result.items.length).toBe(2);
expect(result.totalItems).toBe(5);
expect(result.totalPages).toBe(3);
});
it("falls back to all-orders pagination when no tab is supplied", async () => {
// Sanity check — the existing all-orders path is unchanged. PB's
// `getList(page, perPage, { sort })` returns a paginated slice;
// the test mock mirrors that with `totalItems = items.length`,
// so totalPages = 1 here regardless of the underlying set size.
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-2025-01-001", quote: "qa",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-2025-01-002", quote: "qb",
});
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
});
expect(result.items.length).toBe(2);
});
it("does not pass a `fields` parameter on the orders list (PB 0.26 breaks expand when fields is set)", async () => {
// Regression guard: earlier I tried to limit the wire payload
// by passing a `fields` list with per-expand syntax. PB 0.26
// treats `fields` as a hard allowlist of MAIN record columns,
// and the moment you set it the `expand` payload is dropped
// from the response — so customer/company/rep/quote names
// vanish from every order card. The orders list deliberately
// omits `fields`; this test pins that behaviour so a future
// "let's slim the payload" change doesn't reintroduce the bug.
let capturedFields: string | undefined;
let capturedExpand: string | undefined;
const originalCollection = pbMock.collection;
pbMock.collection = ((name: string) => {
const proxy = originalCollection(name);
if (name === "orders") {
const originalGetList = proxy.getList;
proxy.getList = ((page: number, perPage: number, opts: { filter?: string; sort?: string; fields?: string; expand?: string }) => {
capturedFields = opts?.fields;
capturedExpand = opts?.expand;
return (originalGetList as (...a: unknown[]) => unknown).call(
proxy,
page,
perPage,
opts,
);
}) as typeof proxy.getList;
}
return proxy;
}) as typeof pbMock.collection;
try {
await listOrdersWithItems({ page: 1, perPage: 20 });
// `fields` must NOT be sent — PB 0.26 drops the expand payload
// when it sees it.
expect(capturedFields).toBeUndefined();
// Expand IS sent, and includes the four relations the list view
// reads (company, customer, customer.rep, quote). Importantly
// it does NOT include `potential_quote` — the list view only
// checks the field's existence, never the expanded record.
expect(capturedExpand).toContain("company");
expect(capturedExpand).toContain("customer");
expect(capturedExpand).toContain("customer.rep");
expect(capturedExpand).toContain("quote");
expect(capturedExpand).not.toContain("potential_quote");
} finally {
pbMock.collection = originalCollection;
}
});
});
describe("listOrdersWithItems — search scope + per-tab match counts", () => {
/**
* Seed one order per active tab, all sharing the token "widget" in
* their item description, plus a delivered (archived) order that also
* matches. Lets each test assert both cross-tab counting and the
* archive exclusion.
*/
async function seedCrossTabMatches() {
const rows: [string, string, string][] = [
["o-new", "pending", "2025-01-01T00:00:00Z"],
["o-proc", "processed", "2025-01-02T00:00:00Z"],
["o-pool", "collection_pool", "2025-01-03T00:00:00Z"],
["o-coll", "collected", "2025-01-04T00:00:00Z"],
["o-archived", "delivered", "2025-01-05T00:00:00Z"],
];
for (const [id, status, created] of rows) {
await pbMock.collection("orders").create({
id,
order_number: `ORD-${id}`,
quote: `q-${id}`,
created,
});
await pbMock.collection("order_items").create({
id: `oi-${id}`,
order: id,
description: "Widget assembly",
item_status: status,
});
}
}
it("counts matches across every active tab, not just the active one", async () => {
await seedCrossTabMatches();
// Operator is standing on "new" — but the hits in processing,
// collection_pool and collected must still be counted so the tab
// strip can point at them. This is the core of "show me where it is".
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
search: "widget",
tabGroup: "new",
});
expect(result.searchCounts).toMatchObject({
new: 1,
processing: 1,
collection_pool: 1,
collected: 1,
});
// ...while the visible list is still narrowed to the active tab.
expect(result.items.map((i) => i.order.id)).toEqual(["o-new"]);
});
it("excludes archived (delivered/cancelled) orders from search", async () => {
await seedCrossTabMatches();
const result = await listOrdersWithItems({
page: 1,
perPage: 20,
search: "widget",
});
// "o-archived" matches the text but is delivered-only, so it is out
// of scope — it belongs to /orders/archive.
expect(result.items.map((i) => i.order.id)).not.toContain("o-archived");
expect(result.searchCounts?.delivered ?? 0).toBe(0);
});
it("is unaffected by sort direction (regression: the 500-cap coupling)", async () => {
// The old implementation fetched the first 500 orders BY SORT and
// filtered those, so an ascending sort would hide newer matches.
// Search now scopes by active-order id, so both directions agree.
await seedCrossTabMatches();
const ascending = await listOrdersWithItems({
search: "widget",
sort: "created",
});
const descending = await listOrdersWithItems({
search: "widget",
sort: "-created",
});
expect(ascending.items.map((i) => i.order.id).sort()).toEqual(
descending.items.map((i) => i.order.id).sort(),
);
expect(ascending.searchCounts).toEqual(descending.searchCounts);
});
it("counts an order once per tab it has items in", async () => {
// A single order straddling two tabs should appear in both counts —
// the counts are 'distinct orders per tab', not a partition.
await pbMock.collection("orders").create({
id: "o-split", order_number: "ORD-split", quote: "q-split",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-split-a", order: "o-split", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-split-b", order: "o-split", description: "Widget",
item_status: "processed",
});
const result = await listOrdersWithItems({ search: "widget" });
expect(result.searchCounts).toMatchObject({ new: 1, processing: 1 });
});
it("omits searchCounts entirely when no search is active", async () => {
await seedCrossTabMatches();
const result = await listOrdersWithItems({ page: 1, perPage: 20 });
expect(result.searchCounts).toBeUndefined();
});
});
describe("listOrdersWithItems — tab taxonomy completeness", () => {
/**
* Regression guard. The `ItemStatus` union and `TAB_GROUP_ITEM_STATUSES`
* must stay in lockstep — every status MUST be assigned to a tab,
* because:
* 1. Unmapped statuses are invisible to /orders (they fail the
* tab filter).
* 2. They drop out of `TAB_STATUSES` and therefore out of the
* search candidate set built by `getActiveOrderIds`.
* 3. They drop out of the tab counts query entirely.
*
* This caught the bug where 60 active items (ready_invoice,
* invoiced, delivery_note, removed) were silently missing from
* search and mis-bucketed as "new" — including order 4500704208
* (ready_invoice, expected: Collected).
*/
it("returns search hits whose item_status is mapped to a non-new tab", async () => {
// ready_invoice should land on the Collected tab now. Previously
// it was bucketed as "new" and excluded from the search candidate
// set, so the order was unfindable.
await pbMock.collection("orders").create({
id: "o-inv",
order_number: "ORD-invoice-ready",
quote: "q-inv",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-inv",
order: "o-inv",
description: "Widget",
item_status: "ready_invoice",
});
const result = await listOrdersWithItems({ search: "invoice" });
expect(result.items.map((i) => i.order.id)).toEqual(["o-inv"]);
expect(result.searchCounts).toMatchObject({ collected: 1 });
expect(result.searchCounts?.new ?? 0).toBe(0);
});
it("counts every item_status in the live union across the active set", async () => {
// Build one item per ItemStatus union member, confirm each one
// surfaces in `getActiveOrderIds` (the search candidate set).
const allStatuses = [
"pending", "approval_needed", "approval_rejected",
"processed", "po_generated",
"collection_pool", "assigned", "ready_delivery",
"collected", "stock_verified", "picking",
"delivered", "cancelled",
"ready_invoice", "invoiced",
"stock_booked", "with_driver", "checked_in", "completed",
"removed", "delivery_note",
];
for (const s of allStatuses) {
await pbMock.collection("orders").create({
id: `o-${s}`,
order_number: `ORD-${s}`,
quote: `q-${s}`,
});
await pbMock.collection("order_items").create({
id: `oi-${s}-${allStatuses.indexOf(s)}`,
order: `o-${s}`,
description: "x",
item_status: s,
});
}
// If this fails, an ItemStatus union member has been added to
// src/lib/types.ts without a matching TAB_GROUP_ITEM_STATUSES entry
// in src/lib/item-status-ui.ts — and that item is invisible to
// /orders search and counts.
const result = await listOrdersWithItems({
search: "ORD-",
});
const foundIds = new Set(result.items.map(i => i.order.id));
// Every active-tab status should appear; delivered and cancelled
// remain excluded by design (they live on /orders/archive).
const active = allStatuses.filter(s => s !== "delivered" && s !== "cancelled");
for (const s of active) {
expect(foundIds, `item_status "${s}" should be in search results`).toContain(`o-${s}`);
}
});
});
describe("getOrdersDashboardData — search counts replace global counts", () => {
it("promotes per-tab match counts over the global tab counts while searching", async () => {
await pbMock.collection("orders").create({
id: "o-hit", order_number: "ORD-hit", quote: "q-hit",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-hit", order: "o-hit", description: "Unobtainium",
item_status: "processed",
});
// A second order that must NOT contribute to the badges during a
// search for "unobtainium".
await pbMock.collection("orders").create({
id: "o-miss", order_number: "ORD-miss", quote: "q-miss",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-miss", order: "o-miss", description: "Something else",
item_status: "processed",
});
const searched = await getOrdersDashboardData({
search: "unobtainium",
includeArchivedCounts: false,
bypassCountsCache: true,
});
// Badge shows 1 match in processing, not the global 2 orders.
expect(searched.counts.processing).toBe(1);
const browsing = await getOrdersDashboardData({
includeArchivedCounts: false,
bypassCountsCache: true,
});
expect(browsing.counts.processing).toBe(2);
});
});
describe("getOrdersDashboardData — end-to-end", () => {
beforeEach(() => clearOrderItemStatusTabCountsCache());
it("returns tab-scoped counts and items in a single response", async () => {
// Three orders: a pending one (new tab), a processed one
// (processing tab), and a delivered one.
await pbMock.collection("orders").create({
id: "o-p", order_number: "ORD-2025-01-001", quote: "qp",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-pr", order_number: "ORD-2025-01-002", quote: "qpr",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-d", order_number: "ORD-2025-01-003", quote: "qd",
created: "2025-01-03T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-p", order: "o-p", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-pr", order: "o-pr", description: "Sprocket",
item_status: "processed",
});
await pbMock.collection("order_items").create({
id: "oi-d", order: "o-d", description: "Gear",
item_status: "delivered",
// The completed tab requires a recent `delivered_at` (last 30
// days) — see `completedTabFilter`. The fixture here uses
// `now` so the row qualifies regardless of when the test runs.
delivered_at: new Date().toISOString(),
});
const result = await getOrdersDashboardData({
page: 1,
perPage: 20,
tabGroup: "processing",
});
// Counts are global (always were) — the badge still tells the truth.
// `delivered` items now bucket into the `completed` tab (the
// partition lists `completed` before `delivered` in TAB_GROUP_ORDER
// so the time-windowed slice wins as the "primary" tab). The
// archive page reads its own delivered count from
// `getOrdersArchiveData` rather than this map.
expect(result.counts.new).toBe(1);
expect(result.counts.processing).toBe(1);
expect(result.counts.completed).toBe(1);
expect(result.counts.delivered).toBe(0);
// Items are scoped to the active tab.
expect(result.items.map((i) => i.order.id)).toEqual(["o-pr"]);
expect(result.totalPages).toBe(1);
expect(result.totalItems).toBe(1);
});
it("can omit archive counts for the main orders page", async () => {
await pbMock.collection("orders").create({
id: "o-p", order_number: "ORD-2025-01-001", quote: "qp",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("orders").create({
id: "o-d", order_number: "ORD-2025-01-002", quote: "qd",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-p", order: "o-p", description: "Widget",
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-d", order: "o-d", description: "Gear",
item_status: "delivered",
});
const result = await getOrdersDashboardData({
page: 1,
perPage: 20,
tabGroup: "new",
includeArchivedCounts: false,
});
expect(result.counts.new).toBe(1);
expect(result.counts.delivered).toBe(0);
expect(result.counts.cancelled).toBe(0);
});
});
// ----------------------------------------------------------------------------
// Orders archive — read model for /orders/archive.
//
// The archive is the home for orders with at least one item in
// (delivered, cancelled) status. On the live PB that holds ~99% of
// all orders, so it gets its own paginated page rather than living
// as a tab on the main orders view. These tests pin the contract.
// ----------------------------------------------------------------------------
describe("getOrdersArchiveData", () => {
function makeOrdersArchivePb(options: { failOnArchiveItemScan?: boolean } = {}) {
const listRecords = (name: string) =>
Array.from((store[name] || new Map()).values()) as Array<Record<string, unknown>>;
const matchesArchiveFilter = (
order: Record<string, unknown>,
filter: string,
) => {
const orderItems = listRecords("order_items").filter(
(item) => String(item.order ?? "") === String(order.id ?? ""),
);
const hasDelivered = orderItems.some((item) => item.item_status === "delivered");
const hasCancelled = orderItems.some((item) => item.item_status === "cancelled");
const wantsDelivered = filter.includes('order_items_via_order.item_status ?= "delivered"');
const wantsCancelled = filter.includes('order_items_via_order.item_status ?= "cancelled"');
if (wantsDelivered && wantsCancelled) return hasDelivered || hasCancelled;
if (wantsDelivered) return hasDelivered;
if (wantsCancelled) return hasCancelled;
return true;
};
const matchesSearch = (order: Record<string, unknown>, filter: string) => {
const orderNumberMatch = filter.match(/order_number ~ "([^"]+)"/);
if (orderNumberMatch) {
return String(order.order_number ?? "")
.toLowerCase()
.includes(orderNumberMatch[1].toLowerCase());
}
const contactMatch = filter.match(/contact_name ~ "([^"]+)"/);
if (contactMatch) {
return String(order.contact_name ?? "")
.toLowerCase()
.includes(contactMatch[1].toLowerCase());
}
return true;
};
return {
autoCancellation: () => undefined,
collection: (name: string) => {
if (name === "orders") {
return {
async getList(
page = 1,
perPage = 25,
opts: { filter?: string; sort?: string; expand?: string } = {},
) {
let items = listRecords("orders").filter((order) =>
matchesArchiveFilter(order, opts.filter || "") &&
matchesSearch(order, opts.filter || ""),
);
if (opts.sort === "-created") {
items = items.sort((a, b) =>
String(b.created ?? "").localeCompare(String(a.created ?? "")),
);
}
const totalItems = items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
return {
items: items.slice(start, start + perPage),
totalItems,
totalPages,
page,
perPage,
};
},
getFullList: pbMock.collection("orders").getFullList,
};
}
if (name === "order_items" && options.failOnArchiveItemScan) {
return {
async getFullList(opts: { filter?: string } = {}) {
if (!(opts.filter || "").includes('order = "')) {
throw new Error("unexpected full archive item scan");
}
return pbMock.collection("order_items").getFullList(opts);
},
};
}
return pbMock.collection(name);
},
};
}
it("returns orders with at least one delivered or cancelled item, plus header counts", async () => {
// 2 delivered orders, 1 cancelled, 1 active (no delivered/cancelled).
await pbMock.collection("orders").create({
id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("orders").create({
id: "o-d2", order_number: "ORD-2025-01-002", quote: "q2",
});
await pbMock.collection("orders").create({
id: "o-c1", order_number: "ORD-2025-01-003", quote: "q3",
});
await pbMock.collection("orders").create({
id: "o-active", order_number: "ORD-2025-01-004", quote: "q4",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-d1", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-d2", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-c1", description: "x", item_status: "cancelled",
});
await pbMock.collection("order_items").create({
id: "oi-4", order: "o-active", description: "x", item_status: "pending",
});
const result = await getOrdersArchiveData(
{ page: 1, perPage: 20 },
makeOrdersArchivePb() as never,
);
// 3 archived orders (the active one is excluded).
expect(result.items.map((i) => i.order.id).sort()).toEqual([
"o-c1",
"o-d1",
"o-d2",
]);
expect(result.totalItems).toBe(3);
// Header counts: distinct orders per status.
expect(result.counts.delivered).toBe(2);
expect(result.counts.cancelled).toBe(1);
});
it("dedupes orders that have multiple items in the matching statuses", async () => {
// An order with two delivered items should still count as 1 order.
await pbMock.collection("orders").create({
id: "o-multi", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-multi", description: "A",
item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-multi", description: "B",
item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-c", order: "o-multi", description: "C",
item_status: "cancelled",
});
const result = await getOrdersArchiveData(
{ page: 1, perPage: 20 },
makeOrdersArchivePb() as never,
);
expect(result.totalItems).toBe(1);
expect(result.counts.delivered).toBe(1);
expect(result.counts.cancelled).toBe(1);
});
it("filters archive results by search (order_number, company, customer, contact)", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-2025-FINDME", quote: "q1",
contact_name: "Specific Contact",
});
await pbMock.collection("orders").create({
id: "o-2", order_number: "ORD-2025-OTHER", quote: "q2",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "x", item_status: "delivered",
});
// Search by order_number
const byOrderNum = await getOrdersArchiveData(
{ page: 1, perPage: 20, search: "FINDME" },
makeOrdersArchivePb() as never,
);
expect(byOrderNum.items.map((i) => i.order.id)).toEqual(["o-1"]);
// Search by contact_name
const byContact = await getOrdersArchiveData(
{ page: 1, perPage: 20, search: "specific" },
makeOrdersArchivePb() as never,
);
expect(byContact.items.map((i) => i.order.id)).toEqual(["o-1"]);
});
it("returns an empty result when no orders have delivered/cancelled items", async () => {
await pbMock.collection("orders").create({
id: "o-active", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-active", description: "x",
item_status: "pending",
});
const result = await getOrdersArchiveData(
{ page: 1, perPage: 20 },
makeOrdersArchivePb() as never,
);
expect(result.items).toEqual([]);
expect(result.totalItems).toBe(0);
expect(result.counts.delivered).toBe(0);
expect(result.counts.cancelled).toBe(0);
});
it("loads the default archive page without scanning every order_item first", async () => {
await pbMock.collection("orders").create({
id: "o-d1", order_number: "ORD-2025-01-001", quote: "q1",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-d1", description: "x", item_status: "delivered",
});
const result = await getOrdersArchiveData(
{ page: 1, perPage: 20 },
makeOrdersArchivePb({ failOnArchiveItemScan: true }) as never,
);
expect(result.items.map((i) => i.order.id)).toEqual(["o-d1"]);
expect(result.counts.delivered).toBe(1);
});
});
/* ============================================================================
* cancelOrder / cancelOrdersBulk / findDuplicateOrders
* ----------------------------------------------------------------------------
* Smoke tests for the soft-cancel + duplicate-detection flow added on
* 2026-08-12. Real PB verification lives in the live script in the
* scratchpad directory; these tests pin the basic contract.
* ========================================================================== */
describe("cancelOrder", () => {
it("flips every in-flight item_status to 'cancelled'", async () => {
await pbMock.collection("orders").create({
id: "o-cancel", order_number: "ORD-cancel", quote: "qc",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-cancel", description: "a", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-cancel", description: "b", item_status: "po_generated",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-cancel", description: "c", item_status: "collected",
});
const result = await cancelOrder("o-cancel");
expect(result.itemsCancelled).toBe(3);
expect(result.itemsSkipped).toEqual([]);
const after = await pbMock.collection("order_items").getFullList();
expect(after.every((i) => i.item_status === "cancelled")).toBe(true);
});
it("refuses to cancel an order where every item is terminal", async () => {
await pbMock.collection("orders").create({
id: "o-shipped", order_number: "ORD-shipped", quote: "qs",
});
await pbMock.collection("order_items").create({
id: "oi-d1", order: "o-shipped", description: "a", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-d2", order: "o-shipped", description: "b", item_status: "delivered",
});
await expect(cancelOrder("o-shipped")).rejects.toThrow(/terminal state/);
});
it("partially cancels an order with mixed statuses, reporting the skipped ones", async () => {
await pbMock.collection("orders").create({
id: "o-mixed", order_number: "ORD-mixed", quote: "qm",
});
await pbMock.collection("order_items").create({
id: "oi-m1", order: "o-mixed", description: "a", item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-m2", order: "o-mixed", description: "b", item_status: "delivered",
});
const result = await cancelOrder("o-mixed");
expect(result.itemsCancelled).toBe(1);
expect(result.itemsSkipped).toEqual([{ id: "oi-m2", item_status: "delivered" }]);
});
it("allowDelivered flag lets the bulk-cancel path skip the terminal guard", async () => {
await pbMock.collection("orders").create({
id: "o-bulk", order_number: "ORD-bulk", quote: "qb",
});
await pbMock.collection("order_items").create({
id: "oi-b1", order: "o-bulk", description: "a", item_status: "delivered",
});
await pbMock.collection("order_items").create({
id: "oi-b2", order: "o-bulk", description: "b", item_status: "pending",
});
const result = await cancelOrder("o-bulk", { allowDelivered: true });
expect(result.itemsCancelled).toBe(2);
expect(result.itemsSkipped).toEqual([]);
});
});
describe("cancelOrdersBulk", () => {
it("returns per-order success/failure", async () => {
await pbMock.collection("orders").create({
id: "o-ok", order_number: "ORD-ok", quote: "qk",
});
await pbMock.collection("order_items").create({
id: "oi-ok", order: "o-ok", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-missing", order_number: "ORD-missing", quote: "qm",
});
// (no items — empty order, will succeed with 0 cancelled)
const result = await cancelOrdersBulk(["o-ok", "o-missing", "o-doesnt-exist"]);
expect(result.succeeded.map((r) => r.orderId).sort()).toEqual(["o-missing", "o-ok"]);
expect(result.failed).toEqual([{ orderId: "o-doesnt-exist", error: expect.stringContaining("not found") }]);
});
});
describe("findDuplicateOrders", () => {
it("only returns groups with ≥2 active records (the ones visible on the order board)", async () => {
// The 389020 pattern from live data: 4 records share the number,
// but only 2 are "on the board" (have items in active status).
// The other 2 are off the board — the operator never sees them
// — so the duplicates endpoint should hide them. With the new
// `completed` tab, *recent* `delivered` rows count as on the
// board (they show up on the Completed tab), so we use a row
// whose `delivered_at` is older than the 30-day window as the
// "off the board" example.
await pbMock.collection("orders").create({
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated",
});
await pbMock.collection("orders").create({
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "Widget", item_status: "po_generated",
});
// Third copy — off the board. `delivered_at` is set years ago,
// well outside the 30-day Completed window, so it falls through
// to the archive and isn't visible to the operator.
await pbMock.collection("orders").create({
id: "o-3", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.200Z",
});
await pbMock.collection("order_items").create({
id: "oi-3", order: "o-3", description: "Widget", item_status: "delivered",
delivered_at: "2024-01-01T00:00:00Z",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(1);
expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-1", "o-2"]);
});
it("does not surface groups where only one record is active", async () => {
// Two records share the number, but only one has items in active
// status. The off-board copy is hidden, leaving only 1 record,
// so the group doesn't qualify as a duplicate from the operator's
// perspective.
await pbMock.collection("orders").create({
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "Widget", item_status: "po_generated",
});
// Off-board copy: `delivered_at` set years ago, well outside the
// 30-day Completed window — so it lives on /orders/archive and
// the operator can't see it. (Naïvely setting `item_status =
// delivered` without a stale `delivered_at` would now show up on
// the Completed tab and count as on the board.)
await pbMock.collection("orders").create({
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "Widget", item_status: "delivered",
delivered_at: "2024-01-01T00:00:00Z",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("does not surface singletons or orders with no active items", async () => {
await pbMock.collection("orders").create({
id: "o-solo", order_number: "SOLO", quote: "q", created: "2025-01-01T00:00:00Z",
});
// Off-board: a delivered row whose `delivered_at` is well outside
// the 30-day Completed window (so it lives only on
// /orders/archive).
await pbMock.collection("orders").create({
id: "o-delivered", order_number: "ALSO-SOLO", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-d", order: "o-delivered", description: "Widget", item_status: "delivered",
delivered_at: "2024-01-01T00:00:00Z",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("enriches each active record with company, customer, and item statuses", async () => {
await pbMock.collection("customers").create({ id: "c-x", customer: "Cust" });
await pbMock.collection("companies").create({ id: "co-x", company: "Co" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "DUP", quote: "q",
customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "M16x50 bolt", quantity: 400, item_status: "po_generated",
});
await pbMock.collection("orders").create({
id: "o-2", order_number: "DUP", quote: "q",
customer: "c-x", company: "co-x", created: "2025-01-01T00:00:00.100Z",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "M16x50 bolt", quantity: 400, item_status: "invoiced",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(1);
const r1 = groups[0].records.find((r) => r.id === "o-1")!;
const r2 = groups[0].records.find((r) => r.id === "o-2")!;
expect(r1.customerName).toBe("Cust");
expect(r1.companyName).toBe("Co");
expect(r1.itemStatusSummary).toEqual({ po_generated: 1 });
// The invoiced copy shows the operator how far each got — the
// "invoiced" one is the natural keeper.
expect(r2.itemStatusSummary).toEqual({ invoiced: 1 });
});
it("sorts records by created asc so the oldest is first", async () => {
await pbMock.collection("orders").create({
id: "o-newer", order_number: "DUP", quote: "q",
created: "2025-01-01T01:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-n", order: "o-newer", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-older", order_number: "DUP", quote: "q",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-o", order: "o-older", description: "x", item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups[0].records.map((r) => r.id)).toEqual(["o-older", "o-newer"]);
});
it("returns groups sorted by size (largest first)", async () => {
// Use distinct quantities per order_number so the cross-number
// line-aggregate pass doesn't accidentally fire here — this test
// is about the order-number pass's sort order.
for (const [id, num, qty] of [
["o-s1", "SOLO", 1],
["o-s2", "SOLO", 1],
["o-t1", "TRIPLE", 2],
["o-t2", "TRIPLE", 2],
["o-t3", "TRIPLE", 2],
]) {
await pbMock.collection("orders").create({
id, order_number: num, quote: "q",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: `oi-${id}`, order: id, description: "x", quantity: qty, item_status: "pending",
});
}
const groups = await findDuplicateOrders();
expect(groups.map((g) => g.orderNumber)).toEqual(["TRIPLE", "SOLO"]);
});
// ────── line-aggregate ("Same line items") pass ──────
it("flags cross-order_number line-aggregate duplicates even when descriptions differ", async () => {
// Two orders under different order_numbers, same line breakdown
// (1 line, qty 5, R100). Descriptions and created-timestamps
// differ — the kind: "line_aggregate" pass should still flag it.
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-A", quote: "q",
created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-a", description: "M16 hex bolt 50mm grade 8.8",
quantity: 5, selling_price: 100, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-B", quote: "q",
created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-b", description: "M16 x 50 hex bolt", // different text
quantity: 5, selling_price: 100, item_status: "pending",
});
const groups = await findDuplicateOrders();
const aggregateGroups = groups.filter((g) => g.kind === "line_aggregate");
expect(aggregateGroups).toHaveLength(1);
expect(aggregateGroups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]);
expect(aggregateGroups[0].orderNumber).toMatch(/^1 line · 5 · R/);
});
it("does not double-list a same-number group under line-aggregate (cross-number filter)", async () => {
// Two records under the SAME order_number with matching content
// — already an order-number duplicate. The line-aggregate pass
// must NOT surface them again.
await pbMock.collection("orders").create({
id: "o-1", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", quantity: 5, selling_price: 100,
item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-2", order_number: "DUP", quote: "q", created: "2025-01-01T00:00:00.100Z",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-2", description: "x", quantity: 5, selling_price: 100,
item_status: "invoiced",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(1);
expect(groups[0].kind).toBe("order_number");
expect(groups[0].records.map((r) => r.id)).toEqual(["o-1", "o-2"]);
});
it("excludes orders with zero items from line-aggregate matching", async () => {
// Empty orders would all match each other on (0 lines, 0 qty, R0)
// — must not be flagged.
await pbMock.collection("orders").create({
id: "o-empty-1", order_number: "EMPTY-A", quote: "q",
});
await pbMock.collection("orders").create({
id: "o-empty-2", order_number: "EMPTY-B", quote: "q",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("rounds sub-cent price differences so 1499.998 and 1500.002 both match 1500.00", async () => {
await pbMock.collection("orders").create({
id: "o-x", order_number: "X", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-x", order: "o-x", description: "x", quantity: 1,
selling_price: 1499.998, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-y", order_number: "Y", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-y", order: "o-y", description: "x", quantity: 1,
selling_price: 1500.002, item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
});
it("matches when both sides are entirely zero-priced", async () => {
await pbMock.collection("orders").create({
id: "o-z1", order_number: "Z1", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-z1", order: "o-z1", description: "x", quantity: 5,
selling_price: 0, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-z2", order_number: "Z2", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-z2", order: "o-z2", description: "x", quantity: 5,
selling_price: 0, item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups.filter((g) => g.kind === "line_aggregate")).toHaveLength(1);
});
it("does not match when one side is zero-priced and the other is not", async () => {
await pbMock.collection("orders").create({
id: "o-p1", order_number: "P1", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-p1", order: "o-p1", description: "x", quantity: 1,
selling_price: 0, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-p2", order_number: "P2", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-p2", order: "o-p2", description: "x", quantity: 1,
selling_price: 50, item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("does not match when total quantity differs (everything else the same)", async () => {
await pbMock.collection("orders").create({
id: "o-q1", order_number: "Q1", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-q1", order: "o-q1", description: "x", quantity: 5,
selling_price: 100, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-q2", order_number: "Q2", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-q2", order: "o-q2", description: "x", quantity: 6,
selling_price: 100, item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("does not match when line count differs (everything else the same)", async () => {
await pbMock.collection("orders").create({
id: "o-c1", order_number: "C1", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-c1a", order: "o-c1", description: "x", quantity: 5,
selling_price: 100, item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-c1b", order: "o-c1", description: "y", quantity: 5,
selling_price: 100, item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-c2", order_number: "C2", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-c2", order: "o-c2", description: "x", quantity: 10,
selling_price: 100, item_status: "pending",
});
const groups = await findDuplicateOrders();
expect(groups).toHaveLength(0);
});
it("computes totalSellingPrice as the sum of selling_price × quantity per line", async () => {
// 2 lines: qty 2 @ R50 = R100, qty 3 @ R100 = R300 → total R400.
// qty 5, R400. Match against another order with same aggregates.
await pbMock.collection("orders").create({
id: "o-m1", order_number: "M1", quote: "q", created: "2025-01-01T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-m1a", order: "o-m1", description: "a", quantity: 2, selling_price: 50,
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-m1b", order: "o-m1", description: "b", quantity: 3, selling_price: 100,
item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-m2", order_number: "M2", quote: "q", created: "2025-01-02T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-m2a", order: "o-m2", description: "a", quantity: 2, selling_price: 50,
item_status: "pending",
});
await pbMock.collection("order_items").create({
id: "oi-m2b", order: "o-m2", description: "b", quantity: 3, selling_price: 100,
item_status: "pending",
});
const groups = await findDuplicateOrders();
const aggs = groups.filter((g) => g.kind === "line_aggregate");
expect(aggs).toHaveLength(1);
expect(aggs[0].orderNumber).toMatch(/^2 lines · 5 · R/);
expect(aggs[0].records[0].totalQuantity).toBe(5);
expect(aggs[0].records[0].totalSellingPrice).toBeCloseTo(400, 2);
});
});
// ──────────────────────────────────────────────────────────────────────
// setOrderQuote (Hermes unblock)
// ──────────────────────────────────────────────────────────────────────
describe("setOrderQuote", () => {
it("links an existing order to an existing quote, returns {order, previous}", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
expect(result.previous).toEqual({ quote: "" });
expect(result.order.id).toBe("o-1");
expect(result.order.quote).toBe("q-1");
// The store reflects the write.
const stored = (store["orders"] || new Map()).get("o-1");
expect(stored?.quote).toBe("q-1");
});
it("refuses 409 order_locked when any item is past pending", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "po_generated",
});
await pbMock.collection("order_items").create({
id: "oi-2", order: "o-1", description: "y", item_status: "pending",
});
try {
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(409);
const data = (err as Error & { data?: { code?: string; offendingItemIds?: string[] } }).data;
expect(data?.code).toBe("order_locked");
expect(data?.offendingItemIds).toContain("oi-1");
}
// Order quote must not have changed.
const stored = (store["orders"] || new Map()).get("o-1");
expect(stored?.quote).toBe("");
});
it("refuses 409 quote_already_linked when another order has the quote", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-other", order_number: "ORD-OTHER", quote: "q-1",
});
await pbMock.collection("order_items").create({
id: "oi-other", order: "o-other", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
try {
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(409);
const data = (err as Error & { data?: { code?: string; conflictingOrderId?: string } }).data;
expect(data?.code).toBe("quote_already_linked");
expect(data?.conflictingOrderId).toBe("o-other");
}
});
it("404 when the order doesn't exist", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
try {
await setOrderQuote({ orderId: "missing", quoteId: "q-1" });
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(404);
}
});
it("404 when the quote doesn't exist", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
try {
await setOrderQuote({ orderId: "o-1", quoteId: "missing" });
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(404);
}
});
it("is idempotent when the order already points at the quote (no-op + no audit stamp)", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "q-1", notes: "original note",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
const result = await setOrderQuote({
orderId: "o-1",
quoteId: "q-1",
actor: { id: "agent-1", name: "Hermes" },
});
expect(result.previous).toEqual({ quote: "q-1" });
expect(result.order.quote).toBe("q-1");
// Notes must NOT be touched on idempotent no-op.
expect((store["orders"] || new Map()).get("o-1")?.notes).toBe("original note");
});
it("stamps actor into notes when provided", async () => {
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "", notes: "existing note",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
await setOrderQuote({
orderId: "o-1",
quoteId: "q-1",
actor: { id: "agent-1", name: "Hermes" },
});
const stored = (store["orders"] || new Map()).get("o-1");
expect(stored?.quote).toBe("q-1");
expect(String(stored?.notes ?? "")).toMatch(/existing note/);
expect(String(stored?.notes ?? "")).toMatch(/Hermes linked this order to quote q-1/);
});
});
// ──────────────────────────────────────────────────────────────────────
// setOrderQuote line pairing (Task 5)
// ──────────────────────────────────────────────────────────────────────
describe("setOrderQuote line pairing", () => {
it("pairs order lines to the quote's lines after writing the hard link", async () => {
const { pairOrderLines } = await import("./order-quote-link-service");
vi.mocked(pairOrderLines).mockClear();
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
});
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
expect(result.order.quote).toBe("q-1");
});
it("does not fail the link when pairing throws", async () => {
const { pairOrderLines } = await import("./order-quote-link-service");
vi.mocked(pairOrderLines).mockClear();
vi.mocked(pairOrderLines).mockRejectedValueOnce(new Error("PB down"));
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "", created: "2026-08-21T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
});
const result = await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
// The link still landed despite the pairing failure.
expect(result.order.quote).toBe("q-1");
});
it("does not pair lines when the link is refused (order_locked)", async () => {
const { pairOrderLines } = await import("./order-quote-link-service");
vi.mocked(pairOrderLines).mockClear();
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "po_generated",
});
try {
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(409);
}
expect(pairOrderLines).not.toHaveBeenCalled();
});
it("also pairs lines on the idempotent no-op path (order already points at the quote)", async () => {
const { pairOrderLines } = await import("./order-quote-link-service");
vi.mocked(pairOrderLines).mockClear();
await pbMock.collection("quotes").create({ id: "q-1" });
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "q-1",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "hex bolt m20", item_status: "pending",
});
await setOrderQuote({ orderId: "o-1", quoteId: "q-1" });
// Re-running pairing on an already-linked order is safe (it never
// overwrites an existing `quote_item`) and picks up lines added
// or edited since the original link — so pairing runs here too,
// even though the hard link itself is a no-op.
expect(pairOrderLines).toHaveBeenCalledWith("o-1", "q-1");
});
});
// ──────────────────────────────────────────────────────────────────────
// findOrderByOrderNumber (Hermes unblock)
// ──────────────────────────────────────────────────────────────────────
describe("findOrderByOrderNumber", () => {
it("returns null on miss (no matching order)", async () => {
const hit = await findOrderByOrderNumber("DOES-NOT-EXIST");
expect(hit).toBeNull();
});
it("returns the full order record on hit", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1", quote: "q", created: "2026-08-21T00:00:00Z",
});
const hit = await findOrderByOrderNumber("ORD-1");
expect(hit).not.toBeNull();
expect(hit?.id).toBe("o-1");
expect(hit?.order_number).toBe("ORD-1");
});
it("escapes double-quotes in user input", async () => {
// Filter "order_number = \"weird\\\"value\"" — the mock's where
// only handles simple patterns, so we just confirm no crash on
// a quote-containing input.
const hit = await findOrderByOrderNumber('weird"value');
expect(hit).toBeNull();
});
});
// ──────────────────────────────────────────────────────────────────────
// findOrderByEmailMessageId (Hermes unblock)
// ──────────────────────────────────────────────────────────────────────
describe("findOrderByEmailMessageId", () => {
it("returns null on miss", async () => {
const hit = await findOrderByEmailMessageId("<missing@mail.example>");
expect(hit).toBeNull();
});
it("returns the full order record on hit", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1",
email_message_id: "<msg-1@mail.example>",
});
const hit = await findOrderByEmailMessageId("<msg-1@mail.example>");
expect(hit).not.toBeNull();
expect(hit?.id).toBe("o-1");
expect(hit?.email_message_id).toBe("<msg-1@mail.example>");
});
});
// ──────────────────────────────────────────────────────────────────────
// findDuplicateOrders — email_message_id kind (Hermes unblock)
// ──────────────────────────────────────────────────────────────────────
describe("findDuplicateOrders — email_message_id kind", () => {
it("flags a group of ≥2 records sharing the same email_message_id", async () => {
const sharedEmail = "<msg-shared@mail.example>";
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-A", quote: "q",
email_message_id: sharedEmail, created: "2026-08-21T00:00:00Z",
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-a", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-B", quote: "q",
email_message_id: sharedEmail, created: "2026-08-21T00:00:01Z",
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-b", description: "x", item_status: "pending",
});
const groups = await findDuplicateOrders({ kind: "email_message_id" });
expect(groups).toHaveLength(1);
expect(groups[0].kind).toBe("email_message_id");
expect(groups[0].orderNumber).toBe(sharedEmail);
expect(groups[0].records.map((r) => r.id).sort()).toEqual(["o-a", "o-b"]);
});
it("skips records with an empty email_message_id", async () => {
await pbMock.collection("orders").create({
id: "o-empty-1", order_number: "E1", quote: "q", email_message_id: "",
});
await pbMock.collection("order_items").create({
id: "oi-e1", order: "o-empty-1", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-empty-2", order_number: "E2", quote: "q", email_message_id: "",
});
await pbMock.collection("order_items").create({
id: "oi-e2", order: "o-empty-2", description: "x", item_status: "pending",
});
const groups = await findDuplicateOrders({ kind: "email_message_id" });
expect(groups).toHaveLength(0);
});
it("does not include email_message_id groups when kind filter is order_number", async () => {
const sharedEmail = "<msg-shared@mail.example>";
await pbMock.collection("orders").create({
id: "o-a", order_number: "ORD-A", quote: "q",
email_message_id: sharedEmail,
});
await pbMock.collection("order_items").create({
id: "oi-a", order: "o-a", description: "x", item_status: "pending",
});
await pbMock.collection("orders").create({
id: "o-b", order_number: "ORD-B", quote: "q",
email_message_id: sharedEmail,
});
await pbMock.collection("order_items").create({
id: "oi-b", order: "o-b", description: "x", item_status: "pending",
});
const groups = await findDuplicateOrders({ kind: "order_number" });
expect(groups).toHaveLength(0);
});
});
describe("createOrder quote auto-detection", () => {
it("schedules a quote suggestion after the response", async () => {
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
const { after } = await import("next/server");
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(after).mockClear();
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(after).toHaveBeenCalled();
expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id);
});
it("never fails order creation when the matcher throws", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(new Error("PB down"));
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const result = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(result.order.id).toBeDefined();
expect(result.items_created.length).toBe(1);
// Verifies the inner catch swallowed the matcher failure and
// logged it — without this assertion the test passes purely
// because the `after` mock detaches the promise regardless of
// what production code does.
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("[createOrder] quote auto-detection failed for order"),
expect.stringContaining("PB down"),
);
consoleErrorSpy.mockRestore();
});
it("falls back to an inline match when after() is unavailable", async () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const { after } = await import("next/server");
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(after).mockClear();
vi.mocked(after).mockImplementationOnce(() => {
throw new Error("`after` was called outside a request scope.");
});
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({ id: "comp-x", name: "Acme (Pty) Ltd" });
const r = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(r.order.id).toBeDefined();
// Proves the catch fired AND ran the inline fallback — without
// the call assertion the test would pass on the spy alone even
// if `void runPostCreateMatchers()` was deleted.
expect(suggestQuoteForOrder).toHaveBeenCalledWith(r.order.id);
// The fallback logs its engagement so a silent switch to
// fire-and-forget is impossible to miss in production logs.
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(
"[createOrder] after() unavailable, running post-create matchers inline",
),
expect.anything(),
);
consoleErrorSpy.mockRestore();
});
});
describe("createOrder delivery-note auto-detection", () => {
it("also schedules a delivery-note match after the response", async () => {
const { suggestDeliveryNoteForOrder } = await import(
"./order-delivery-note-link-service"
);
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({
id: "comp-x",
name: "Acme (Pty) Ltd",
});
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
});
it("a delivery-note match failure does not prevent the quote suggestion from running", async () => {
// The point of the two independent try/catch blocks — a delivery-
// note match failure (e.g. PB down for delivery_notes collection)
// must still leave the quote suggestion able to land. The two
// matchers are independent: one failing cannot starve the other.
const { suggestDeliveryNoteForOrder } = await import(
"./order-delivery-note-link-service"
);
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(suggestDeliveryNoteForOrder).mockRejectedValueOnce(
new Error("DN lookup failed"),
);
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({
id: "comp-x",
name: "Acme (Pty) Ltd",
});
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestQuoteForOrder).toHaveBeenCalledWith(created.order.id);
});
it("a quote-match failure does not prevent the delivery-note match from running", async () => {
// The reverse direction — the quote matcher throws, the delivery-
// note matcher still runs. Proves the second try/catch is its
// own scope and not a continuation of the first.
const { suggestDeliveryNoteForOrder } = await import(
"./order-delivery-note-link-service"
);
const { suggestQuoteForOrder } = await import("./order-quote-link-service");
vi.mocked(suggestDeliveryNoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockClear();
vi.mocked(suggestQuoteForOrder).mockRejectedValueOnce(
new Error("quote lookup failed"),
);
await pbMock.collection("customers").create({ id: "cust-x", name: "Acme" });
await pbMock.collection("companies").create({
id: "comp-x",
name: "Acme (Pty) Ltd",
});
const created = await createOrder({
customer_id: "cust-x",
company_id: "comp-x",
items: [{ description: "M16 Bolt", quantity: 50 }],
});
expect(suggestDeliveryNoteForOrder).toHaveBeenCalledWith(created.order.id);
});
});
describe("createOrderItemForOrder (mid-flight add)", () => {
// Task 2 of the non-invoiceable-order-extras plan — the pending
// gate that previously 409'd when siblings were po_generated is
// removed because adding a new row is independent of any in-flight
// PO's pricing/supplier reference. Three tests below cover the
// contract: (a) the gate is gone, (b) the new flag is authoritative
// for selling_price, (c) omission leaves the row billable.
it("adds a line to an order whose siblings are po_generated (was previously 409 order_locked)", async () => {
const { createOrderItemForOrder } = await import("./orders-service");
await pbMock.collection("orders").create({
id: "o-1",
order_number: "ORD-1",
// Customer + company required by createOrder's lead row — skip
// for this targeted test by using a minimal stub.
});
await pbMock.collection("order_items").create({
id: "oi-existing",
order: "o-1",
description: "Bolt",
quantity: 50,
item_status: "po_generated",
});
const result = await createOrderItemForOrder("o-1", {
description: "Washer",
quantity: 50,
cost_price: 5,
});
expect(result.failed).toEqual([]);
expect(result.created.length).toBe(1);
expect(result.created[0].description).toBe("Washer");
expect(result.orderLocked).toBe(false);
});
it("exclude_from_invoice forces selling_price to 0 even when caller passes a non-zero price", async () => {
const { createOrderItemForOrder } = await import("./orders-service");
await pbMock.collection("orders").create({
id: "o-1",
order_number: "ORD-1",
});
const result = await createOrderItemForOrder("o-1", {
description: "Nut",
quantity: 50,
cost_price: 10,
markup: 50, // would compute selling_price = 15 without the flag
selling_price: 15, // would persist 15 without the flag
exclude_from_invoice: true,
});
expect(result.created[0].selling_price).toBe(0);
expect(result.created[0].markup).toBe(50); // markup stays as-is
// The flag itself persists on the secondary update — separate
// assertion because the `created[]` return shape doesn't include
// it.
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get(
result.created[0].id,
);
expect(stored?.exclude_from_invoice).toBe(true);
expect(stored?.selling_price).toBe(0);
});
it("omitting exclude_from_invoice preserves the existing markup-based selling_price calculation", async () => {
const { createOrderItemForOrder } = await import("./orders-service");
await pbMock.collection("orders").create({
id: "o-1",
order_number: "ORD-1",
});
const result = await createOrderItemForOrder("o-1", {
description: "Bolt",
quantity: 50,
cost_price: 10,
markup: 25,
});
expect(result.created[0].selling_price).toBe(12.5); // 10 * 1.25
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get(
result.created[0].id,
);
expect(stored?.exclude_from_invoice).toBeFalsy();
});
});
describe("cancelOrderItem", () => {
let cancelOrderItem: typeof import("./orders-service").cancelOrderItem;
beforeEach(async () => {
({ cancelOrderItem } = await import("./orders-service"));
});
it("flips item_status to cancelled for a pending line", async () => {
await pbMock.collection("orders").create({
id: "o-1", order_number: "ORD-1",
});
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "M2 flat washer", item_status: "pending",
});
const result = await cancelOrderItem("o-1", "oi-1");
expect(result.id).toBe("oi-1");
expect(result.item_status).toBe("cancelled");
});
it("refuses 404 when the order doesn't exist", async () => {
await pbMock.collection("order_items").create({
id: "oi-orphan", order: "o-missing", description: "x", item_status: "pending",
});
// The item belongs to o-missing; we're calling with o-1.
// Even though the item exists, the order check fires first.
try {
await cancelOrderItem("o-1", "oi-orphan");
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(404);
}
});
it("refuses 404 when the item belongs to a different order", async () => {
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
await pbMock.collection("orders").create({ id: "o-2", order_number: "ORD-2" });
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
// Item is on o-1; we're calling with o-2.
try {
await cancelOrderItem("o-2", "oi-1");
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(404);
}
});
it("refuses 409 when the line is already in a terminal status", async () => {
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
// 'invoiced' is in CANCEL_BLOCKED_ITEM_STATUSES — cancelling
// would orphan the customer invoice. Same for delivered /
// cancelled. One test per status would be overkill; pick
// 'invoiced' as the representative terminal state.
await pbMock.collection("order_items").create({
id: "oi-invoiced", order: "o-1", description: "x", item_status: "invoiced",
});
try {
await cancelOrderItem("o-1", "oi-invoiced");
expect.fail("should have thrown");
} catch (err) {
expect((err as Error & { status?: number }).status).toBe(409);
}
});
it("stamps the operator identity on notes when an actor is supplied", async () => {
await pbMock.collection("orders").create({ id: "o-1", order_number: "ORD-1" });
await pbMock.collection("order_items").create({
id: "oi-1", order: "o-1", description: "x", item_status: "pending",
});
await cancelOrderItem("o-1", "oi-1", { actor: { id: "u-1", name: "Dave" } });
const stored = (store["order_items"] as Map<string, Record<string, unknown>>).get("oi-1");
expect(stored?.item_status).toBe("cancelled");
// The notes field is appended, not replaced — preserves any
// operator note that was already on the line.
const notes = String(stored?.notes ?? "");
expect(notes).toMatch(/cancelled by Dave/);
});
});