WFDASH-WEBAPP/src/lib/server/po-number.test.ts
Dave 999948861e feat: add server-side quote search & PO number format update
Implement new server-side ranked quote search endpoint at `/api/v1/quotes/search`, replacing the legacy 500-row client-side substring scan that missed older quotes and didn't rank results by relevance. The new search scores matches across quote identifiers, customer/company headers, and line item fields.
Update PO number generation and validation to use a dashless format (POYYYYMMNNNXXX) with per-company scoped sequence numbers, and ignore legacy PO formats when calculating new sequences.
Remove legacy `searchQuotesWithItems` function and its associated test file, then update the quotes page to use the new server-side search API.
Add comprehensive unit and integration tests for the new quote search service and updated PO number logic.
2026-08-14 11:15:38 +02:00

263 lines
No EOL
9.8 KiB
TypeScript

import { describe, it, expect, beforeEach, vi } from "vitest";
// Mock the admin pb module before importing the SUT so the mock is
// in place when the module evaluates.
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 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];
const actualStr = String(actual ?? "");
switch (op) {
case "=": return actualStr === expected;
case "!=": return actualStr !== expected;
case "=~": return actualStr.includes(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;
return items.filter((item) => {
const orParts = filter.split("||").map((s) => s.trim());
return orParts.some((part) => {
const andParts = part.split("&&").map((s) => s.trim());
return andParts.every((p) => matchesClause(item, p));
});
});
};
const pbMock = {
autoCancellation: () => undefined,
collection: (name: string) => ({
async getList(
page = 1,
perPage = 25,
opts: { filter?: string; sort?: 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;
});
}
const totalItems = all.length;
const totalPages = Math.max(1, Math.ceil(totalItems / perPage));
const start = (page - 1) * perPage;
const items = all.slice(start, start + perPage);
return { items, totalItems, totalPages, page, perPage };
},
async getFullList(opts: { filter?: string; fields?: string; expand?: string } = {}) {
const items = where(name, opts.filter || "");
if (!opts.expand) return items;
const relations = opts.expand.split(",").map((s) => s.trim()).filter(Boolean);
return items.map((it) => {
const expand: Record<string, unknown> = {};
for (const rel of relations) {
const relId = it[rel];
const IRREGULAR: Record<string, string> = { company: "companies" };
const coll = IRREGULAR[rel] ?? (rel.endsWith("s") ? rel : `${rel}s`);
if (typeof relId === "string" && getOrCreateMap(coll).has(relId)) {
expand[rel] = getOrCreateMap(coll).get(relId);
}
}
return { ...it, expand };
});
},
async getOne(id: string, opts: { expand?: string } = {}) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
if (!opts.expand) return item;
const relations = opts.expand.split(",").map((s) => s.trim()).filter(Boolean);
const expand: Record<string, unknown> = {};
for (const rel of relations) {
const relId = item[rel];
const IRREGULAR: Record<string, string> = { company: "companies" };
const coll = IRREGULAR[rel] ?? (rel.endsWith("s") ? rel : `${rel}s`);
if (typeof relId === "string" && getOrCreateMap(coll).has(relId)) {
expand[rel] = getOrCreateMap(coll).get(relId);
}
}
return { ...item, expand };
},
async create(data: Record<string, unknown>) {
const id = typeof data.id === "string" ? data.id : nextId();
const item = {
id,
created: new Date().toISOString(),
updated: new Date().toISOString(),
...data,
};
getOrCreateMap(name).set(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;
}
const updated = { ...existing, ...data, updated: new Date().toISOString() };
map.set(id, updated);
return updated;
},
}),
};
const clearStore = () => {
for (const k of Object.keys(store)) store[k].clear();
};
return { store, pbMock, clearStore };
});
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
import { companyPrefix, generatePONumber } from "./po-number";
beforeEach(() => {
clearStore();
});
describe("companyPrefix", () => {
it("takes the first 3 letters of the first word, uppercase", () => {
expect(companyPrefix("Master Drilling")).toBe("MAS");
expect(companyPrefix("Cash Sales")).toBe("CAS");
expect(companyPrefix("NSS Fastners")).toBe("NSS");
expect(companyPrefix("Harmony - Gold Mining Company Limited")).toBe("HAR");
expect(companyPrefix("ESAB")).toBe("ESA");
});
it("strips non-alphanumeric characters before slicing", () => {
expect(companyPrefix("A.B.C Trading")).toBe("ABC");
// Unicode letters (e.g. accented) fall through to the ASCII
// prefix — the operator can rename the company in PB admin if
// they want a different prefix. The important contract is that
// we always produce a 3-char uppercase result.
expect(companyPrefix("abc-def")).toBe("ABC");
});
it("pads with X when the first word is shorter than 3 chars", () => {
expect(companyPrefix("AB Holdings")).toBe("ABX");
});
it("falls back to GEN when the company name is empty or missing", () => {
expect(companyPrefix("")).toBe("GEN");
expect(companyPrefix(null)).toBe("GEN");
expect(companyPrefix(undefined)).toBe("GEN");
});
});
describe("generatePONumber", () => {
async function seedOrder(orderId: string, companyName: string, companyId?: string) {
const coId = companyId ?? `co-${orderId}`;
await pbMock.collection("companies").create({ id: coId, company: companyName });
await pbMock.collection("orders").create({
id: orderId,
order_number: "ORD-2026-08-001",
company: coId,
});
}
// Persist a PO so the next call's sequence lookup can find it.
async function seedPO(poNumber: string) {
await pbMock.collection("purchase_orders").create({
po_number: poNumber,
});
}
it("builds the no-dash format with prefix", async () => {
await seedOrder("o-mas", "Master Drilling");
const po = await generatePONumber("o-mas");
// Format: PO + YYYYMM + NNN + 3-letter prefix (no dashes).
// 6 digits for YYYYMM (no day), 3 digits for the sequence, 3
// letters for the prefix, total 14 chars.
expect(po).toMatch(/^PO\d{6}001MAS$/);
});
it("uses per-company scoped sequence", async () => {
await seedOrder("o-mas", "Master Drilling");
await seedOrder("o-cas", "Cash Sales");
const mas1 = await generatePONumber("o-mas");
const cas1 = await generatePONumber("o-cas");
// Different companies must each get their own sequence 001 —
// not a global counter that would give them 001 and 002.
expect(mas1).toMatch(/001MAS$/);
expect(cas1).toMatch(/001CAS$/);
});
it("increments within the same company/month", async () => {
await seedOrder("o-mas", "Master Drilling");
const a = await generatePONumber("o-mas");
await seedPO(a);
const b = await generatePONumber("o-mas");
await seedPO(b);
const c = await generatePONumber("o-mas");
expect(a).toMatch(/001MAS$/);
expect(b).toMatch(/002MAS$/);
expect(c).toMatch(/003MAS$/);
});
it("uses the GEN prefix when the order has no company", async () => {
await pbMock.collection("orders").create({
id: "o-no-comp",
order_number: "ORD-2026-08-001",
});
const po = await generatePONumber("o-no-comp");
expect(po).toMatch(/001GEN$/);
});
it("falls back to GEN when the order lookup fails", async () => {
const po = await generatePONumber("o-does-not-exist");
expect(po).toMatch(/001GEN$/);
});
it("ignores legacy dashed POs when computing the sequence", async () => {
// Legacy dashed POs and the earlier dashed-prefix format
// (PO-2026-08-MAS-001) should be excluded so the new no-dash
// format starts its own sequence at 001.
await seedOrder("o-mas", "Master Drilling");
await pbMock.collection("purchase_orders").create({
po_number: "PO-2026-08-127",
});
await pbMock.collection("purchase_orders").create({
po_number: "PO-2026-08-MAS-001",
});
const po = await generatePONumber("o-mas");
expect(po).toMatch(/001MAS$/);
});
it("does not count POs from other companies in the same month", async () => {
// Even though CAS has 005, MAS still starts at 001.
await seedOrder("o-mas", "Master Drilling");
await seedOrder("o-cas", "Cash Sales");
await seedPO("PO202608001CAS");
await seedPO("PO202608005CAS");
const mas1 = await generatePONumber("o-mas");
expect(mas1).toMatch(/001MAS$/);
});
});