refactor(orders): use ORDER_COLLECTION constant + rejects.toMatchObject in review tests
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
b77983d0f9
commit
aa7db0d801
2 changed files with 259 additions and 0 deletions
190
src/lib/server/orders-service-review.test.ts
Normal file
190
src/lib/server/orders-service-review.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue