refactor(orders): use ORDER_COLLECTION constant + rejects.toMatchObject in review tests

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-29 14:06:07 +02:00
parent b77983d0f9
commit aa7db0d801
2 changed files with 259 additions and 0 deletions

View file

@ -0,0 +1,190 @@
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 = {
collection: (name: string) => ({
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() {
return where(name, "");
},
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>) {
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 {
OrderNumberCollisionError,
updateOrderReviewFields,
} from "./orders-service";
beforeEach(() => clearStore());
describe("updateOrderReviewFields", () => {
it("updates order_number when unique", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-014",
});
expect(result.order_number).toBe("ORD-2026-07-014");
});
it("updates customer", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
customer: "cust-new",
});
expect(result.customer).toBe("cust-new");
});
it("updates both in a single call", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-old",
});
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-014",
customer: "cust-new",
});
expect(result.order_number).toBe("ORD-2026-07-014");
expect(result.customer).toBe("cust-new");
});
it("throws OrderNumberCollisionError when another order owns the number", async () => {
await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-002",
customer: "cust-2",
});
await expect(
updateOrderReviewFields(order.id, { order_number: "ORD-2026-07-001" }),
).rejects.toBeInstanceOf(OrderNumberCollisionError);
await expect(
updateOrderReviewFields(order.id, { order_number: "ORD-2026-07-001" }),
).rejects.toMatchObject({
collidingOrderId: expect.any(String),
attemptedNumber: "ORD-2026-07-001",
});
});
it("does not throw when the new number equals the order's own current number", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
// Same number on the same order should be a no-op, not a self-collision.
const result = await updateOrderReviewFields(order.id, {
order_number: "ORD-2026-07-001",
});
expect(result.order_number).toBe("ORD-2026-07-001");
});
it("no-ops when patch is empty", async () => {
const order = await pbMock.collection("orders").create({
order_number: "ORD-2026-07-001",
customer: "cust-1",
});
const result = await updateOrderReviewFields(order.id, {});
expect(result.order_number).toBe("ORD-2026-07-001");
expect(result.customer).toBe("cust-1");
});
});

View file

@ -265,3 +265,72 @@ async function getMaxOrderSequenceInMonth(
function formatOrderNumber(year: number, month: number, seq: number): string {
return `ORD-${year}-${String(month).padStart(2, "0")}-${String(seq).padStart(3, "0")}`;
}
/**
* Thrown by `updateOrderReviewFields` when the requested `order_number`
* is already in use by a different order. Carries the colliding
* order's id so the route handler can include it in the 409 response
* (the modal renders it as a link to the conflicting order).
*/
export class OrderNumberCollisionError extends Error {
readonly collidingOrderId: string;
readonly attemptedNumber: string;
constructor(collidingOrderId: string, attemptedNumber: string) {
super(
`Order number "${attemptedNumber}" is already in use by another order.`,
);
this.name = "OrderNumberCollisionError";
this.collidingOrderId = collidingOrderId;
this.attemptedNumber = attemptedNumber;
}
}
/**
* Patches the order header fields editable from the review modal.
* Currently supports `order_number` and `customer`. Validates
* `order_number` uniqueness against the `orders` collection
* (excluding self) before write; throws
* `OrderNumberCollisionError` on collision.
*/
export async function updateOrderReviewFields(
orderId: string,
patch: { order_number?: string; customer?: string },
) {
const pb = await getAdminPocketBase();
// Validate order_number uniqueness before write — we do this as a
// separate query rather than letting PB throw on its unique index
// so we can return the colliding row's id to the UI (PB's own
// 400 doesn't carry it).
if (patch.order_number !== undefined) {
const existing = await pb
.collection(ORDER_COLLECTION)
.getOne(orderId);
if (existing.order_number !== patch.order_number) {
const conflict = await pb
.collection(ORDER_COLLECTION)
.getList<{ id: string; order_number: string }>(1, 1, {
filter: `order_number = "${patch.order_number}" && id != "${orderId}"`,
});
if (conflict.items.length > 0) {
throw new OrderNumberCollisionError(
conflict.items[0].id,
patch.order_number,
);
}
}
}
// Build a clean patch object with only the defined keys — passing
// `undefined` to PB is harmless but we keep the wire payload tight.
const cleanPatch: Record<string, unknown> = {};
if (patch.order_number !== undefined) cleanPatch.order_number = patch.order_number;
if (patch.customer !== undefined) cleanPatch.customer = patch.customer;
if (Object.keys(cleanPatch).length === 0) {
// No-op — just re-fetch and return.
return pb.collection(ORDER_COLLECTION).getOne(orderId);
}
return pb.collection(ORDER_COLLECTION).update(orderId, cleanPatch);
}