diff --git a/src/lib/server/orders-service-review.test.ts b/src/lib/server/orders-service-review.test.ts new file mode 100644 index 0000000..71692b3 --- /dev/null +++ b/src/lib/server/orders-service-review.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const { store, pbMock, clearStore } = vi.hoisted(() => { + type StoreMap = Map>; + const store: Record = {}; + 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[] => { + 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) { + 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) { + 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"); + }); +}); diff --git a/src/lib/server/orders-service.ts b/src/lib/server/orders-service.ts index c232358..514fbfa 100644 --- a/src/lib/server/orders-service.ts +++ b/src/lib/server/orders-service.ts @@ -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 = {}; + 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); +}