diff --git a/src/app/api/v1/orders/[id]/review-save/route.test.ts b/src/app/api/v1/orders/[id]/review-save/route.test.ts new file mode 100644 index 0000000..fa79a37 --- /dev/null +++ b/src/app/api/v1/orders/[id]/review-save/route.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const updateOrderReviewFields = vi.fn(); +const updateOrderItemsPrices = vi.fn(); +const collisionError: Error & { collidingOrderId?: string; attemptedNumber?: string } = + new Error("collision"); +collisionError.name = "OrderNumberCollisionError"; +collisionError.collidingOrderId = "order-collide"; +collisionError.attemptedNumber = "ORD-2026-07-014"; + +vi.mock("@/lib/server/orders-service", () => ({ + updateOrderReviewFields: (...args: unknown[]) => + updateOrderReviewFields(...args), + updateOrderItemsPrices: (...args: unknown[]) => + updateOrderItemsPrices(...args), + OrderNumberCollisionError: class extends Error { + collidingOrderId: string; + attemptedNumber: string; + constructor(id: string, num: string) { + super(`Order number "${num}" is already in use by another order.`); + this.name = "OrderNumberCollisionError"; + this.collidingOrderId = id; + this.attemptedNumber = num; + } + }, +})); + +import { POST } from "./route"; + +function postJSON(body: unknown) { + return new NextRequest( + "http://localhost/api/v1/orders/order-1/review-save", + { + method: "POST", + body: JSON.stringify(body), + }, + ); +} + +describe("POST /api/v1/orders/[id]/review-save", () => { + beforeEach(() => { + updateOrderReviewFields.mockReset(); + updateOrderItemsPrices.mockReset(); + }); + + it("400s when body is not JSON", async () => { + const req = new NextRequest( + "http://localhost/api/v1/orders/order-1/review-save", + { method: "POST", body: "not json" }, + ); + const res = await POST(req, { params: Promise.resolve({ id: "order-1" }) }); + expect(res.status).toBe(400); + }); + + it("400s when body is null", async () => { + const res = await POST(postJSON(null), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(400); + }); + + it("200s with empty body (no-op)", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + const res = await POST(postJSON({}), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(200); + expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", {}); + const body = await res.json(); + expect(body.order.id).toBe("order-1"); + expect(body.items.updated).toBe(0); + }); + + it("calls updateOrderReviewFields with order_number when supplied", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-014", + customer: "cust-1", + }); + updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] }); + + const res = await POST( + postJSON({ order_number: "ORD-2026-07-014" }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(200); + expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", { + order_number: "ORD-2026-07-014", + }); + }); + + it("returns 409 on OrderNumberCollisionError with collidingOrderId", async () => { + updateOrderReviewFields.mockRejectedValueOnce(collisionError); + + const res = await POST( + postJSON({ order_number: "ORD-2026-07-014" }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toBe("order_number_collision"); + expect(body.collidingOrderId).toBe("order-collide"); + expect(body.attemptedNumber).toBe("ORD-2026-07-014"); + }); + + it("calls updateOrderItemsPrices when items are supplied", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-014", + customer: "cust-1", + }); + updateOrderItemsPrices.mockResolvedValueOnce({ + updated: 2, + failed: [], + }); + + const res = await POST( + postJSON({ + order_number: "ORD-2026-07-014", + items: [ + { id: "item-1", quantity: 5 }, + { id: "item-2", selling_price: 99 }, + ], + }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(200); + expect(updateOrderItemsPrices).toHaveBeenCalledWith("order-1", [ + { id: "item-1", quantity: 5 }, + { id: "item-2", selling_price: 99 }, + ]); + const body = await res.json(); + expect(body.items.updated).toBe(2); + expect(body.items.failed).toEqual([]); + }); + + it("returns 500 on unexpected service error", async () => { + updateOrderReviewFields.mockRejectedValueOnce(new Error("boom")); + const res = await POST(postJSON({ order_number: "X" }), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/boom/); + }); +}); \ No newline at end of file diff --git a/src/app/api/v1/orders/[id]/review-save/route.ts b/src/app/api/v1/orders/[id]/review-save/route.ts new file mode 100644 index 0000000..7bc6854 --- /dev/null +++ b/src/app/api/v1/orders/[id]/review-save/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + OrderNumberCollisionError, + updateOrderItemsPrices, + updateOrderReviewFields, +} from "@/lib/server/orders-service"; + +/** + * POST /api/v1/orders/[id]/review-save + * + * Atomically commits the operator's review-time edits to the order + * header (order_number, customer) and any line-item price/qty + * patches. The "atomic" guarantee is partial: order_number collision + * is checked up-front so a 409 aborts before any field is touched, + * but item updates are best-effort per-row (per the + * `updateOrderItemsPrices` contract). + * + * Request body: + * { + * order_number?: string; + * customer?: string; + * items?: Array<{ + * id: string; + * quantity?: number | null; + * selling_price?: number | null; + * cost_price?: number | null; + * }>; + * } + * + * Responses: + * 200 — saved. Body: { order: Order, items: { updated, failed } } + * 400 — body is missing or not JSON + * 404 — order not found (PB throws; caught and mapped) + * 409 — order_number collision. Body: { error, collidingOrderId, attemptedNumber } + * 500 — anything else + */ +export async function POST( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) { + const { id: orderId } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + if (body === null || typeof body !== "object") { + return NextResponse.json({ error: "Body must be an object" }, { status: 400 }); + } + + const { order_number, customer, items } = body as { + order_number?: string; + customer?: string; + items?: Array<{ + id: string; + quantity?: number | null; + selling_price?: number | null; + cost_price?: number | null; + }>; + }; + + try { + // Header patch — order_number + customer are bundled because the + // collision check needs to fire BEFORE any items[] work runs. + // We always go through `updateOrderReviewFields` — even when the + // patch is empty — so the no-op path returns the current row and + // the route doesn't need a second PB import branch. + const headerPatch: { order_number?: string; customer?: string } = {}; + if (typeof order_number === "string") headerPatch.order_number = order_number; + if (typeof customer === "string") headerPatch.customer = customer; + + const order = await updateOrderReviewFields(orderId, headerPatch); + + const itemResult = + Array.isArray(items) && items.length > 0 + ? await updateOrderItemsPrices(orderId, items) + : { updated: 0, failed: [] }; + + return NextResponse.json({ order, items: itemResult }); + } catch (err) { + // Detect collision by instanceof OR by name — the name check + // keeps the route working with mocks that throw a plain Error + // stamped with `.name = "OrderNumberCollisionError"`. + if ( + err instanceof OrderNumberCollisionError || + (err instanceof Error && err.name === "OrderNumberCollisionError") + ) { + const collidingOrderId = + (err as Error & { collidingOrderId?: string }).collidingOrderId ?? ""; + const attemptedNumber = + (err as Error & { attemptedNumber?: string }).attemptedNumber ?? ""; + return NextResponse.json( + { + error: "order_number_collision", + collidingOrderId, + attemptedNumber, + }, + { status: 409 }, + ); + } + const message = err instanceof Error ? err.message : "Save failed"; + const status = + (err as Error & { status?: number }).status === 404 ? 404 : 500; + return NextResponse.json({ error: message }, { status }); + } +} \ No newline at end of file