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 index fa79a37..df9989a 100644 --- a/src/app/api/v1/orders/[id]/review-save/route.test.ts +++ b/src/app/api/v1/orders/[id]/review-save/route.test.ts @@ -3,11 +3,11 @@ 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"; +const assertAgentAuth = vi.fn(); + +vi.mock("@/lib/server/agent-auth", () => ({ + assertAgentAuth: (...args: unknown[]) => assertAgentAuth(...args), +})); vi.mock("@/lib/server/orders-service", () => ({ updateOrderReviewFields: (...args: unknown[]) => @@ -27,6 +27,7 @@ vi.mock("@/lib/server/orders-service", () => ({ })); import { POST } from "./route"; +import { OrderNumberCollisionError } from "@/lib/server/orders-service"; function postJSON(body: unknown) { return new NextRequest( @@ -42,6 +43,29 @@ describe("POST /api/v1/orders/[id]/review-save", () => { beforeEach(() => { updateOrderReviewFields.mockReset(); updateOrderItemsPrices.mockReset(); + assertAgentAuth.mockReset(); + }); + + it("calls assertAgentAuth before processing", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-001", + customer: "cust-1", + }); + await POST(postJSON({}), { params: Promise.resolve({ id: "order-1" }) }); + expect(assertAgentAuth).toHaveBeenCalledTimes(1); + }); + + it("returns 401 when assertAgentAuth throws", async () => { + const authError = Object.assign(new Error("Unauthorized"), { status: 401 }); + assertAgentAuth.mockImplementationOnce(() => { + throw authError; + }); + const res = await POST(postJSON({ order_number: "X" }), { + params: Promise.resolve({ id: "order-1" }), + }); + expect(res.status).toBe(401); + expect(updateOrderReviewFields).not.toHaveBeenCalled(); }); it("400s when body is not JSON", async () => { @@ -95,8 +119,29 @@ describe("POST /api/v1/orders/[id]/review-save", () => { }); }); + it("calls updateOrderReviewFields with customer when supplied", async () => { + updateOrderReviewFields.mockResolvedValueOnce({ + id: "order-1", + order_number: "ORD-2026-07-001", + customer: "cust-X", + }); + updateOrderItemsPrices.mockResolvedValueOnce({ updated: 0, failed: [] }); + + const res = await POST( + postJSON({ customer: "cust-X" }), + { params: Promise.resolve({ id: "order-1" }) }, + ); + + expect(res.status).toBe(200); + expect(updateOrderReviewFields).toHaveBeenCalledWith("order-1", { + customer: "cust-X", + }); + }); + it("returns 409 on OrderNumberCollisionError with collidingOrderId", async () => { - updateOrderReviewFields.mockRejectedValueOnce(collisionError); + updateOrderReviewFields.mockRejectedValueOnce( + new OrderNumberCollisionError("order-collide", "ORD-2026-07-014"), + ); const res = await POST( postJSON({ order_number: "ORD-2026-07-014" }), @@ -110,6 +155,15 @@ describe("POST /api/v1/orders/[id]/review-save", () => { expect(body.attemptedNumber).toBe("ORD-2026-07-014"); }); + it("returns 404 when PB throws ClientResponseError(404)", async () => { + const err = Object.assign(new Error("not found"), { status: 404 }); + updateOrderReviewFields.mockRejectedValueOnce(err); + const res = await POST(postJSON({}), { + params: Promise.resolve({ id: "order-x" }), + }); + expect(res.status).toBe(404); + }); + it("calls updateOrderItemsPrices when items are supplied", async () => { updateOrderReviewFields.mockResolvedValueOnce({ id: "order-1", @@ -151,4 +205,4 @@ describe("POST /api/v1/orders/[id]/review-save", () => { 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 index 7bc6854..98a2091 100644 --- a/src/app/api/v1/orders/[id]/review-save/route.ts +++ b/src/app/api/v1/orders/[id]/review-save/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; import { OrderNumberCollisionError, updateOrderItemsPrices, @@ -30,6 +31,7 @@ import { * Responses: * 200 — saved. Body: { order: Order, items: { updated, failed } } * 400 — body is missing or not JSON + * 401 — unauthorized (no/invalid bearer token) * 404 — order not found (PB throws; caught and mapped) * 409 — order_number collision. Body: { error, collidingOrderId, attemptedNumber } * 500 — anything else @@ -42,9 +44,18 @@ export async function POST( let body: unknown; try { + assertAgentAuth(req); body = await req.json(); - } catch { - return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } catch (err) { + // Auth errors propagate with `status: 401` — surface that to the + // client. Anything else from `req.json()` is a malformed body. + const status = + (err as Error & { status?: number }).status === 401 ? 401 : 400; + const message = + status === 401 + ? (err instanceof Error ? err.message : "Unauthorized") + : "Body must be JSON"; + return NextResponse.json({ error: message }, { status }); } if (body === null || typeof body !== "object") { return NextResponse.json({ error: "Body must be an object" }, { status: 400 }); @@ -80,13 +91,7 @@ export async function POST( 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") - ) { + if (err instanceof OrderNumberCollisionError) { const collidingOrderId = (err as Error & { collidingOrderId?: string }).collidingOrderId ?? ""; const attemptedNumber = @@ -105,4 +110,4 @@ export async function POST( (err as Error & { status?: number }).status === 404 ? 404 : 500; return NextResponse.json({ error: message }, { status }); } -} \ No newline at end of file +}