214 lines
No EOL
7.8 KiB
TypeScript
214 lines
No EOL
7.8 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
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, op, raw] = m;
|
|
const value = raw.replace(/^"|"$/g, "");
|
|
const actual = String(item[field] ?? "");
|
|
if (op === "=") return actual === value;
|
|
if (op === "!=") return actual !== value;
|
|
return true;
|
|
}),
|
|
);
|
|
};
|
|
|
|
const pbMock = {
|
|
autoCancellation: vi.fn(),
|
|
collection: (name: string) => ({
|
|
autoCancellation: vi.fn(),
|
|
async getList(
|
|
_page = 1,
|
|
_perPage = 25,
|
|
opts: { filter?: string } = {},
|
|
) {
|
|
const items = where(name, opts.filter || "");
|
|
return { items, totalItems: items.length, page: 1, perPage: _perPage };
|
|
},
|
|
async getFullList(opts: { filter?: string; sort?: string } = {}) {
|
|
const items = where(name, opts.filter || "");
|
|
// Honor `sort` so tests can pin behaviour that depends on row
|
|
// order. The real PB SDK sorts by the requested field
|
|
// ascending; descending requires a `-` prefix which we do not
|
|
// currently model. Sufficient for the duplicate-matcher tests
|
|
// that request `sort: "created"`.
|
|
if (opts.sort) {
|
|
const dir = opts.sort.startsWith("-") ? -1 : 1;
|
|
const field = opts.sort.replace(/^-/, "");
|
|
items.sort((a, b) => {
|
|
const av = String(a[field] ?? "");
|
|
const bv = String(b[field] ?? "");
|
|
return dir * (av < bv ? -1 : av > bv ? 1 : 0);
|
|
});
|
|
}
|
|
return items;
|
|
},
|
|
async getOne(
|
|
id: string,
|
|
opts: { expand?: string } = {},
|
|
): Promise<Record<string, unknown> & { expand?: Record<string, unknown> }> {
|
|
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 };
|
|
// Honor the `expand` option: attach each comma-separated
|
|
// relation as a nested `expand.<name>` on the returned
|
|
// record. Matches the real PocketBase SDK shape (consumer
|
|
// code reads `record.expand?.quote`), not a flat
|
|
// `expand_<name>` sentinel.
|
|
//
|
|
// The mock's collection map is keyed on the actual PB
|
|
// collection name (e.g. `quotes`, plural), while relation
|
|
// names in `expand` are singular (e.g. `quote`). Try the
|
|
// relation name as-is first, then the pluralised form, so
|
|
// tests can populate the related collection with the name
|
|
// they already use.
|
|
const expandObj: Record<string, unknown> = {};
|
|
for (const rel of opts.expand.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
const relatedId = (item as Record<string, unknown>)[rel];
|
|
if (typeof relatedId !== "string" || !relatedId) continue;
|
|
const related =
|
|
getOrCreateMap(rel).get(relatedId) ??
|
|
getOrCreateMap(`${rel}s`).get(relatedId) ??
|
|
getOrCreateMap(rel.replace(/s$/, "")).get(relatedId);
|
|
if (related) expandObj[rel] = { ...related };
|
|
}
|
|
return { ...item, expand: expandObj };
|
|
},
|
|
async create(data: Record<string, unknown>) {
|
|
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)) {
|
|
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", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
|
|
vi.mock("./pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
}));
|
|
|
|
import { suggestOrderNumberDuplicate } from "./order-number-duplicate-link-service";
|
|
|
|
beforeEach(() => clearStore());
|
|
|
|
describe("suggestOrderNumberDuplicate", () => {
|
|
it("flags two orders that share the same order_number, regardless of status", async () => {
|
|
await pbMock.collection("orders").create({
|
|
id: "order-a",
|
|
order_number: "NO-REF",
|
|
status: "delivered",
|
|
});
|
|
const orderB = await pbMock.collection("orders").create({
|
|
id: "order-b",
|
|
order_number: "NO-REF",
|
|
status: "new",
|
|
});
|
|
|
|
const result = await suggestOrderNumberDuplicate(orderB.id);
|
|
|
|
expect(result.duplicateOrderId).toBe("order-a");
|
|
const updated = await pbMock.collection("orders").getOne("order-b");
|
|
expect(updated.order_number_duplicate).toBe("order-a");
|
|
expect(updated.order_number_duplicate_checked).toBe(true);
|
|
});
|
|
|
|
it("does not flag when order_number is unique", async () => {
|
|
const order = await pbMock.collection("orders").create({
|
|
id: "order-solo",
|
|
order_number: "UNIQUE-123",
|
|
});
|
|
|
|
const result = await suggestOrderNumberDuplicate(order.id);
|
|
|
|
expect(result.duplicateOrderId).toBeNull();
|
|
const updated = await pbMock.collection("orders").getOne("order-solo");
|
|
expect(updated.order_number_duplicate).toBeFalsy();
|
|
expect(updated.order_number_duplicate_checked).toBe(true);
|
|
});
|
|
|
|
it("prefers the oldest other order when more than two orders share a number", async () => {
|
|
// Insertion order is deliberately NOT chronological: `order-middle`
|
|
// goes in FIRST so the mock's Map-iteration-order (which the real PB
|
|
// SDK would replace via `sort: "created"`) returns the wrong row at
|
|
// index 0 if the service forgets to pass the sort option.
|
|
await pbMock.collection("orders").create({
|
|
id: "order-middle",
|
|
order_number: "SHARED",
|
|
created: "2026-02-01 00:00:00.000Z",
|
|
});
|
|
await pbMock.collection("orders").create({
|
|
id: "order-oldest",
|
|
order_number: "SHARED",
|
|
created: "2026-01-01 00:00:00.000Z",
|
|
});
|
|
const orderNewest = await pbMock.collection("orders").create({
|
|
id: "order-newest",
|
|
order_number: "SHARED",
|
|
created: "2026-03-01 00:00:00.000Z",
|
|
});
|
|
|
|
const result = await suggestOrderNumberDuplicate(orderNewest.id);
|
|
|
|
expect(result.duplicateOrderId).toBe("order-oldest");
|
|
});
|
|
|
|
it("does not stamp _checked when order has no order_number (early return)", async () => {
|
|
const order = await pbMock.collection("orders").create({
|
|
id: "order-no-num",
|
|
// order_number deliberately omitted
|
|
});
|
|
|
|
const result = await suggestOrderNumberDuplicate(order.id);
|
|
|
|
expect(result.duplicateOrderId).toBeNull();
|
|
const updated = await pbMock.collection("orders").getOne("order-no-num");
|
|
expect(updated.order_number_duplicate_checked).toBeFalsy();
|
|
});
|
|
}); |