WFDASH-WEBAPP/src/lib/server/orders-service.test.ts
SJCoetz f9a0d08dab fix(tests): mock scaffold destructure + collection-pool + pocketbase startup
- orders-service.test.ts: destructure capture-group bug on regex match
  (was assigning m[0] to 'field', so filter clauses never matched).
  Two pre-seed tests were masked by the bug.
- quotes-service.test.ts: createQuoteFromEmail now returns 0
  attachments; the n8n flow uploads them in a follow-up step. Update
  the assertion that still expected the old embedded-base64 count.
- collection-pool/page.test.tsx: tests assume ONE supplier group with
  multiple rows; supply that shape and scope the selectors. Also wait
  for data load in the chevron test and gather list items across all
  <ul> elements in the sort control helper.
- pocketbase.ts: wrap authStore.onChange registration defensively so
  the test mock (no authStore) doesn't throw at startup.
- stock-orders/page.tsx: drop the re-export of
  STOCK_PURCHASE_STATUS_VALUES on the page module — Next.js page
  route files only allow the default export.
- collection-confirm/view-model.test.ts: buildConfirmNote input is
  Pick<InvoiceDetails, 'date' | 'number'>; drop the extra setBy fields.

Co-authored-by: Orca <help@stably.ai>
2026-07-31 00:01:24 +02:00

451 lines
15 KiB
TypeScript

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}`;
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("&&").map((c) => c.trim());
return items.filter((item) =>
clauses.every((clause) => {
const m = clause.match(/^(\w+)\s*=\s*"?([^"]+)"?$/);
if (!m) return true;
const [, field, raw] = m;
const value = raw.replace(/^"|"$/g, "");
if (field === "year" || field === "month") {
return Number(item[field]) === Number(value);
}
return String(item[field] ?? "") === value;
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
_page = 1,
_perPage = 25,
opts: { filter?: string; sort?: string } = {},
) {
const items = where(name, opts.filter || "");
if (opts.sort) {
const sign = opts.sort.startsWith("-") ? -1 : 1;
const field = opts.sort.replace(/^-/, "");
items.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;
});
}
return { items, totalItems: items.length, page: 1, perPage: _perPage };
},
async getFullList() {
return where(name, "");
},
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;
}
}
}
const item = {
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),
}));
import {
createOrderFromQuote,
getOrderItemsCollectionName,
listCollectionPool,
} 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);
});
});