From cd68e3dc06e6c027bb51ea5f307f9bf66b032120 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Tue, 11 Aug 2026 21:27:28 -0400 Subject: [PATCH] fix(rest/nodejs): answer checkout errors with the UCP error envelope Checkout business and protocol failures returned a flat {detail} JSON body. The 2026-04-08 spec requires the UCP error envelope for business failures (checkout.md: the response contains ucp.status "error" with messages describing the failure) and code plus content in the body for protocol errors (checkout-rest.md), and the Python reference already answers this way through its typed exception taxonomy (rest/python/server/exceptions.py + the ucp_exception_handler). Add the same taxonomy for Node (src/utils/ucp_error.ts) and convert every checkout error site to it, mirroring the Python codes: IDEMPOTENCY_CONFLICT 409, RESOURCE_NOT_FOUND 404, CHECKOUT_NOT_MODIFIABLE 409, OUT_OF_STOCK 400 (409 at complete, matching the Python complete path), INVALID_REQUEST 400, and the payment family INSUFFICIENT_FUNDS 402 / FRAUD_DETECTED 403 / UNKNOWN_TOKEN with severity requires_buyer_input. HTTP statuses are preserved at every site; this is a response shape change only. validateInventory now throws the typed OutOfStockError so the create and update catch blocks can render it distinctly from other validation failures. Left as is, matching the Python reference which answers the same way: the testing simulation endpoints, the signature 401 wrapper, and the internal 500 in completeCheckout. --- rest/nodejs/src/api/checkout.ts | 182 +++++++++++++----- rest/nodejs/src/utils/ucp_error.ts | 109 +++++++++++ rest/nodejs/test/error_envelope.test.ts | 229 +++++++++++++++++++++++ rest/nodejs/test/fulfillment.test.ts | 14 +- rest/nodejs/test/validation_flow.test.ts | 14 +- 5 files changed, 488 insertions(+), 60 deletions(-) create mode 100644 rest/nodejs/src/utils/ucp_error.ts create mode 100644 rest/nodejs/test/error_envelope.test.ts diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index c79950a..ca23e61 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -54,6 +54,16 @@ import { type CheckoutCompleteRequest, type PostalAddress, } from "../models"; +import { + CheckoutNotModifiableError, + IdempotencyConflictError, + InvalidRequestError, + OutOfStockError, + PaymentFailedError, + ResourceNotFoundError, + UcpError, + ucpErrorResponse, +} from "../utils/ucp_error"; import { type IdParamContext } from "../utils/validation"; // zCompleteCheckoutRequest and CompleteCheckoutRequest are now imported from SDK models @@ -490,7 +500,9 @@ export class CheckoutService { for (const line of checkout.line_items) { const qtyAvail = getInventory(line.item.id); if (qtyAvail === undefined || qtyAvail < line.quantity) { - throw new Error(`Insufficient stock for item ${line.item.id}`); + throw new OutOfStockError( + `Insufficient stock for item ${line.item.id}` + ); } } } @@ -506,9 +518,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 201); @@ -531,12 +545,18 @@ export class CheckoutService { const quantity = reqLine.quantity; if (!productId) { - return c.json({ detail: `Line item ${i} missing product ID` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Line item ${i} missing product ID`) + ); } const product = getProduct(productId); if (!product) { - return c.json({ detail: `Product ${productId} not found` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Product ${productId} not found`) + ); } lineItems.push({ @@ -616,9 +636,11 @@ export class CheckoutService { return c.json(checkout, 201); } catch (e: unknown) { - return c.json( - { detail: e instanceof Error ? e.message : String(e) }, - 400 + return ucpErrorResponse( + c, + e instanceof UcpError + ? e + : new InvalidRequestError(e instanceof Error ? e.message : String(e)) ); } }; @@ -631,7 +653,10 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } return c.json(checkout, 200); }; @@ -648,9 +673,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -662,16 +689,21 @@ export class CheckoutService { const existing = getCheckoutSession(id); if (!existing) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } if ( existing.status === CheckoutResponseStatusSchema.enum.completed || existing.status === CheckoutResponseStatusSchema.enum.canceled ) { - return c.json( - { detail: `Cannot update a ${existing.status} checkout session` }, - 409 + return ucpErrorResponse( + c, + new CheckoutNotModifiableError( + `Cannot update a ${existing.status} checkout session` + ) ); } @@ -701,11 +733,17 @@ export class CheckoutService { const quantity = reqLine.quantity; if (!productId) { - return c.json({ detail: `Line item missing product ID` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Line item missing product ID`) + ); } const product = getProduct(productId); if (!product) { - return c.json({ detail: `Product ${productId} not found` }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError(`Product ${productId} not found`) + ); } newLineItems.push({ @@ -749,9 +787,11 @@ export class CheckoutService { return c.json(existing, 200); } catch (e: unknown) { - return c.json( - { detail: e instanceof Error ? e.message : String(e) }, - 400 + return ucpErrorResponse( + c, + e instanceof UcpError + ? e + : new InvalidRequestError(e instanceof Error ? e.message : String(e)) ); } }; @@ -768,9 +808,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -782,7 +824,10 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } // Validate Fulfillment is complete. Require at least one method: an empty @@ -798,9 +843,11 @@ export class CheckoutService { ); if (!hasFulfillment) { - return c.json( - { detail: "Fulfillment address and option must be selected" }, - 400 + return ucpErrorResponse( + c, + new InvalidRequestError( + "Fulfillment address and option must be selected" + ) ); } @@ -809,13 +856,19 @@ export class CheckoutService { checkout.status === CheckoutResponseStatusSchema.enum.canceled ) { // If already completed and not caught by idempotency, it's a conflict - return c.json({ detail: `Checkout already completed or canceled` }, 409); + return ucpErrorResponse( + c, + new CheckoutNotModifiableError(`Checkout already completed or canceled`) + ); } // Process Payment const payment = rawBody.payment; if (!payment || !payment.instruments || payment.instruments.length === 0) { - return c.json({ detail: "Missing payment data" }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError("Missing payment data") + ); } const selectedInstrument = payment.instruments[0]; @@ -823,7 +876,10 @@ export class CheckoutService { const handlerId = selectedInstrument.handler_id; const credential = selectedInstrument.credential; if (!credential) { - return c.json({ detail: "Missing credentials in instrument" }, 400); + return ucpErrorResponse( + c, + new InvalidRequestError("Missing credentials in instrument") + ); } if (selectedInstrument.type === "card" && credential.type === "card") { @@ -839,17 +895,32 @@ export class CheckoutService { if (token === "success_token") { // Success } else if (token === "fail_token") { - return c.json( - { detail: "Payment Failed: Insufficient Funds (Mock)" }, - 402 + return ucpErrorResponse( + c, + new PaymentFailedError( + "Payment Failed: Insufficient Funds (Mock)", + "INSUFFICIENT_FUNDS", + 402 + ) ); } else if (token === "fraud_token") { - return c.json( - { detail: "Payment Failed: Fraud Detected (Mock)" }, - 403 + return ucpErrorResponse( + c, + new PaymentFailedError( + "Payment Failed: Fraud Detected (Mock)", + "FRAUD_DETECTED", + 403 + ) ); } else { - return c.json({ detail: `Unknown mock token: ${token}` }, 400); + return ucpErrorResponse( + c, + new PaymentFailedError( + `Unknown mock token: ${token}`, + "UNKNOWN_TOKEN", + 400 + ) + ); } } else if ( handlerId === "google_pay" || @@ -858,9 +929,9 @@ export class CheckoutService { ) { // Mock success } else { - return c.json( - { detail: `Unsupported payment handler: ${handlerId}` }, - 400 + return ucpErrorResponse( + c, + new InvalidRequestError(`Unsupported payment handler: ${handlerId}`) ); } } @@ -879,9 +950,9 @@ export class CheckoutService { for (const reserved of reservedItems) { releaseStock(reserved.id, reserved.qty); } - return c.json( - { detail: `Item ${line.item.id} is out of stock` }, - 409 + return ucpErrorResponse( + c, + new OutOfStockError(`Item ${line.item.id} is out of stock`, 409) ); } reservedItems.push({ id: line.item.id, qty: line.quantity }); @@ -1025,9 +1096,11 @@ export class CheckoutService { const record = getIdempotencyRecord(idempotencyKey); if (record) { if (record.request_hash !== requestHash) { - return c.json( - { detail: "Idempotency key reused with different parameters" }, - 409 + return ucpErrorResponse( + c, + new IdempotencyConflictError( + "Idempotency key reused with different parameters" + ) ); } return c.json(JSON.parse(record.response_body), 200); @@ -1038,16 +1111,21 @@ export class CheckoutService { const checkout = getCheckoutSession(id); if (!checkout) { - return c.json({ detail: "Checkout session not found" }, 404); + return ucpErrorResponse( + c, + new ResourceNotFoundError("Checkout session not found") + ); } if ( checkout.status === CheckoutResponseStatusSchema.enum.completed || checkout.status === CheckoutResponseStatusSchema.enum.canceled ) { - return c.json( - { detail: `Cannot cancel a ${checkout.status} checkout session` }, - 409 + return ucpErrorResponse( + c, + new CheckoutNotModifiableError( + `Cannot cancel a ${checkout.status} checkout session` + ) ); } diff --git a/rest/nodejs/src/utils/ucp_error.ts b/rest/nodejs/src/utils/ucp_error.ts new file mode 100644 index 0000000..39746a6 --- /dev/null +++ b/rest/nodejs/src/utils/ucp_error.ts @@ -0,0 +1,109 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { type Context } from "hono"; +import { type ContentfulStatusCode } from "hono/utils/http-status"; + +import { UCP_VERSION } from "./config"; + +// UCP error taxonomy, mirroring the Python reference server +// (rest/python/server/exceptions.py). Business and protocol failures answer +// with the UCP error envelope — `ucp.status: "error"` plus a `messages[]` +// entry carrying `code`, `content`, and `severity` — rather than a flat +// `{ detail }` body, per checkout.md's error responses ("the response +// contains `ucp.status: \"error\"` with `messages` describing the failure") +// and checkout-rest.md's protocol errors ("JSON body containing `code` and +// `content`"). + +export type ErrorSeverity = + | "recoverable" + | "requires_buyer_input" + | "requires_buyer_review" + | "unrecoverable"; + +export class UcpError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly statusCode: ContentfulStatusCode, + public readonly severity: ErrorSeverity = "unrecoverable" + ) { + super(message); + this.name = new.target.name; + } +} + +export class ResourceNotFoundError extends UcpError { + constructor(message: string) { + super(message, "RESOURCE_NOT_FOUND", 404); + } +} + +export class IdempotencyConflictError extends UcpError { + constructor(message: string) { + super(message, "IDEMPOTENCY_CONFLICT", 409); + } +} + +export class CheckoutNotModifiableError extends UcpError { + constructor(message: string) { + super(message, "CHECKOUT_NOT_MODIFIABLE", 409); + } +} + +export class OutOfStockError extends UcpError { + constructor(message: string, statusCode: ContentfulStatusCode = 400) { + super(message, "OUT_OF_STOCK", statusCode); + } +} + +export class PaymentFailedError extends UcpError { + constructor( + message: string, + code = "PAYMENT_FAILED", + statusCode: ContentfulStatusCode = 402 + ) { + super(message, code, statusCode, "requires_buyer_input"); + } +} + +export class InvalidRequestError extends UcpError { + constructor(message: string) { + super(message, "INVALID_REQUEST", 400); + } +} + +/** + * Renders a UcpError as the UCP error envelope, byte-shape-identical to the + * Python reference's ucp_exception_handler (rest/python/server/server.py). + */ +export function ucpErrorResponse(c: Context, error: UcpError) { + return c.json( + { + ucp: { + version: UCP_VERSION, + status: "error", + }, + messages: [ + { + type: "error", + code: error.code, + content: error.message, + severity: error.severity, + }, + ], + }, + error.statusCode + ); +} diff --git a/rest/nodejs/test/error_envelope.test.ts b/rest/nodejs/test/error_envelope.test.ts new file mode 100644 index 0000000..b9256a6 --- /dev/null +++ b/rest/nodejs/test/error_envelope.test.ts @@ -0,0 +1,229 @@ +// Copyright 2026 UCP Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import assert from "node:assert/strict"; +import { before, test } from "node:test"; + +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; + +import { CheckoutService } from "../src/api/checkout"; +import { getProductsDb, getTransactionsDb, initDbs } from "../src/data/db"; +import { + CheckoutCompleteRequestSchema, + ExtendedCheckoutCreateRequestSchema, + ExtendedCheckoutUpdateRequestSchema, +} from "../src/models"; +import { UCP_VERSION } from "../src/utils/config"; +import { IdParamSchema, prettyValidation } from "../src/utils/validation"; + +// Checkout business/protocol failures must answer with the UCP error +// envelope — `ucp.status: "error"` plus a typed `messages[]` entry carrying +// `code` and `content` — matching the Python reference server +// (rest/python/server/exceptions.py + server.py ucp_exception_handler), +// rather than the flat `{ detail }` shape. +function buildApp() { + const svc = new CheckoutService(); + const app = new Hono<{ Variables: { logger: typeof console } }>(); + app.use(async (c, next) => { + c.set("logger", console); + await next(); + }); + app.post( + "/checkout-sessions", + zValidator("json", ExtendedCheckoutCreateRequestSchema, prettyValidation), + svc.createCheckout + ); + app.get( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + svc.getCheckout + ); + app.put( + "/checkout-sessions/:id", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", ExtendedCheckoutUpdateRequestSchema, prettyValidation), + svc.updateCheckout + ); + app.post( + "/checkout-sessions/:id/complete", + zValidator("param", IdParamSchema, prettyValidation), + zValidator("json", CheckoutCompleteRequestSchema, prettyValidation), + svc.completeCheckout + ); + app.post( + "/checkout-sessions/:id/cancel", + zValidator("param", IdParamSchema, prettyValidation), + svc.cancelCheckout + ); + return app; +} + +before(() => { + initDbs(":memory:", ":memory:"); + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("bouquet_roses", "Red Rose", 3500, ""); + const inv = getTransactionsDb().prepare( + "INSERT INTO inventory (product_id, quantity) VALUES (?, ?)" + ); + inv.run("bouquet_roses", 100); + // A product whose stock is exhausted, for the OUT_OF_STOCK path. + getProductsDb() + .prepare( + "INSERT INTO products (id, title, price, image_url) VALUES (?, ?, ?, ?)" + ) + .run("gardenias", "Gardenia", 2000, ""); + inv.run("gardenias", 0); +}); + +const JSON_HEADERS = { "Content-Type": "application/json" }; +const CREATE_BODY = { + currency: "USD", + line_items: [{ item: { id: "bouquet_roses" }, quantity: 1 }], + payment: {}, +}; + +interface UcpErrorBody { + ucp?: { version?: string; status?: string }; + messages?: Array<{ + type?: string; + code?: string; + content?: string; + severity?: string; + }>; + detail?: string; +} + +function assertUcpError( + body: UcpErrorBody, + code: string, + severity = "unrecoverable" +) { + assert.equal(body.detail, undefined, "flat detail shape must be gone"); + assert.equal(body.ucp?.status, "error", "ucp.status must be 'error'"); + assert.equal(body.ucp?.version, UCP_VERSION); + assert.ok( + Array.isArray(body.messages) && body.messages.length > 0, + "messages[] must carry the failure" + ); + const msg = body.messages![0]; + assert.equal(msg.type, "error"); + assert.equal(msg.code, code); + assert.ok(msg.content, "content must state the failure"); + assert.equal(msg.severity, severity); +} + +async function create(app: ReturnType, extra?: object) { + return app.request("/checkout-sessions", { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ ...CREATE_BODY, ...extra }), + }); +} + +test("idempotency conflict answers 409 with an IDEMPOTENCY_CONFLICT envelope", async () => { + const app = buildApp(); + const key = `key_${Date.now()}_envelope`; + const first = await app.request("/checkout-sessions", { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify(CREATE_BODY), + }); + assert.equal(first.status, 201); + + const conflicting = await app.request("/checkout-sessions", { + method: "POST", + headers: { ...JSON_HEADERS, "Idempotency-Key": key }, + body: JSON.stringify({ + ...CREATE_BODY, + line_items: [{ item: { id: "bouquet_roses" }, quantity: 2 }], + }), + }); + assert.equal(conflicting.status, 409); + assertUcpError( + (await conflicting.json()) as UcpErrorBody, + "IDEMPOTENCY_CONFLICT" + ); +}); + +test("unknown checkout id answers 404 with a RESOURCE_NOT_FOUND envelope", async () => { + const app = buildApp(); + const res = await app.request("/checkout-sessions/no_such_session"); + assert.equal(res.status, 404); + assertUcpError((await res.json()) as UcpErrorBody, "RESOURCE_NOT_FOUND"); +}); + +test("updating a canceled checkout answers 409 with a CHECKOUT_NOT_MODIFIABLE envelope", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const canceled = await app.request( + `/checkout-sessions/${created.id}/cancel`, + { method: "POST", headers: JSON_HEADERS } + ); + assert.equal(canceled.status, 200); + + const res = await app.request(`/checkout-sessions/${created.id}`, { + method: "PUT", + headers: JSON_HEADERS, + body: JSON.stringify(CREATE_BODY), + }); + assert.equal(res.status, 409); + assertUcpError((await res.json()) as UcpErrorBody, "CHECKOUT_NOT_MODIFIABLE"); +}); + +test("insufficient stock answers with an OUT_OF_STOCK envelope", async () => { + const app = buildApp(); + const res = await create(app, { + line_items: [{ item: { id: "gardenias" }, quantity: 1 }], + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "OUT_OF_STOCK"); +}); + +test("an unknown product answers with an INVALID_REQUEST envelope", async () => { + const app = buildApp(); + const res = await create(app, { + line_items: [{ item: { id: "no_such_product" }, quantity: 1 }], + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "INVALID_REQUEST"); +}); + +test("completion without fulfillment answers with an INVALID_REQUEST envelope", async () => { + const app = buildApp(); + const created = (await (await create(app)).json()) as { id: string }; + const res = await app.request(`/checkout-sessions/${created.id}/complete`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + payment: { + instruments: [ + { + id: "pi_1", + handler_id: "mock_payment_handler", + type: "card", + brand: "visa", + last_digits: "4242", + credential: { type: "network_token", token: "success_token" }, + }, + ], + }, + }), + }); + assert.equal(res.status, 400); + assertUcpError((await res.json()) as UcpErrorBody, "INVALID_REQUEST"); +}); diff --git a/rest/nodejs/test/fulfillment.test.ts b/rest/nodejs/test/fulfillment.test.ts index 3fa0ad1..69da576 100644 --- a/rest/nodejs/test/fulfillment.test.ts +++ b/rest/nodejs/test/fulfillment.test.ts @@ -257,8 +257,11 @@ test("completing a checkout with no fulfillment selected is rejected", async () body: JSON.stringify(SUCCESS_PAYMENT), }); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /fulfillment/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); }); test("a checkout with fulfillment fully selected can be completed", async () => { @@ -310,6 +313,9 @@ test("empty fulfillment methods array blocks completion", async () => { body: JSON.stringify(SUCCESS_PAYMENT), }); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /fulfillment/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /fulfillment/i); }); diff --git a/rest/nodejs/test/validation_flow.test.ts b/rest/nodejs/test/validation_flow.test.ts index f923dc0..f7cb86c 100644 --- a/rest/nodejs/test/validation_flow.test.ts +++ b/rest/nodejs/test/validation_flow.test.ts @@ -69,8 +69,11 @@ test("an unknown product id is rejected", async () => { { item: { id: "no_such_product" }, quantity: 1 }, ]); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /not found/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "INVALID_REQUEST"); + assert.match(body.messages?.[0]?.content ?? "", /not found/i); }); test("ordering more than the available stock is rejected", async () => { @@ -79,8 +82,11 @@ test("ordering more than the available stock is rejected", async () => { { item: { id: "bouquet_roses" }, quantity: 5 }, ]); assert.equal(res.status, 400); - const body = (await res.json()) as { detail: string }; - assert.match(body.detail, /stock/i); + const body = (await res.json()) as { + messages?: Array<{ code?: string; content?: string }>; + }; + assert.equal(body.messages?.[0]?.code, "OUT_OF_STOCK"); + assert.match(body.messages?.[0]?.content ?? "", /stock/i); }); test("ordering within the available stock succeeds", async () => {