From 015e200f0f67d5a99c46f45cbf930987f96246ac Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Thu, 23 Jul 2026 23:34:51 -0400 Subject: [PATCH] fix(rest/nodejs): deliver the order object as the webhook body Mirrors #140 (the Python fix): the orderEvent webhook body must be an order object (rest.openapi.json $ref: order), but the Node.js server posted a custom {event_type, checkout_id, order} envelope, and posted it even with no order. Post the order object as the body, carry the event type in an X-Event-Type header, and skip delivery when there is no order. Adds a test that captures the delivered webhook request and asserts the body is the bare order with the event type in the header. --- rest/nodejs/src/api/checkout.ts | 30 ++++-- rest/nodejs/test/webhook.test.ts | 157 +++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 rest/nodejs/test/webhook.test.ts diff --git a/rest/nodejs/src/api/checkout.ts b/rest/nodejs/src/api/checkout.ts index b042a184..2e453fcf 100644 --- a/rest/nodejs/src/api/checkout.ts +++ b/rest/nodejs/src/api/checkout.ts @@ -117,6 +117,14 @@ export class CheckoutService { return undefined; } + /** + * Notifies the configured platform webhook of an order event. + * + * Per the UCP REST OpenAPI (`webhooks.orderEvent`), the request body is the + * order object itself (`#/components/schemas/order`); the event type is + * conveyed out of band in the `X-Event-Type` header. The body must always be + * a valid order, so no notification is sent when there is no order to deliver. + */ private async notifyWebhook( checkout: ExtendedCheckoutResponse, eventType: string @@ -124,23 +132,29 @@ export class CheckoutService { if (!checkout.platform?.webhook_url) { return; } - const webhookUrl = checkout.platform.webhook_url; + let orderData: Order | undefined = undefined; if (checkout.order) { orderData = getOrder(checkout.order.id); } - const payload = { - event_type: eventType, - checkout_id: checkout.id, - order: orderData, - }; + if (!orderData) { + console.warn( + `Skipping ${eventType} webhook for checkout ${checkout.id}: no order to deliver` + ); + return; + } + + const webhookUrl = checkout.platform.webhook_url; try { await fetch(webhookUrl, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + headers: { + "Content-Type": "application/json", + "X-Event-Type": eventType, + }, + body: JSON.stringify(orderData), }); } catch (e) { console.error(`Failed to notify webhook at ${webhookUrl}`, e); diff --git a/rest/nodejs/test/webhook.test.ts b/rest/nodejs/test/webhook.test.ts new file mode 100644 index 00000000..3dfad0da --- /dev/null +++ b/rest/nodejs/test/webhook.test.ts @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import { test, before } from "node:test"; + +import { CheckoutService } from "../src/api/checkout"; +import { initDbs, getTransactionsDb } from "../src/data/db"; + +// Per the UCP REST OpenAPI (source/services/shopping/rest.openapi.json), the +// orderEvent webhook's requestBody is `{ "$ref": "#/components/schemas/order" }` +// with `required: true` -- the delivered JSON body must BE the order object. +// The event type is carried out of band in the X-Event-Type header, and no +// notification may be sent when there is no order (the body must always be a +// valid order, never null/undefined or a custom envelope). + +const WEBHOOK_URL = "https://platform.example/ucp-webhook"; +const ORDER_ID = "order_wh_test"; +const CHECKOUT_ID = "chk_wh_test"; + +// The eight top-level fields the order schema marks `required`, mirrored from +// the order the server itself builds when a checkout completes. +const ORDER_REQUIRED_FIELDS = [ + "ucp", + "id", + "checkout_id", + "permalink_url", + "line_items", + "fulfillment", + "currency", + "totals", +] as const; + +function seedOrder() { + getTransactionsDb() + .prepare("INSERT OR REPLACE INTO orders (id, data) VALUES (?, ?)") + .run( + ORDER_ID, + JSON.stringify({ + ucp: { version: "2025-09-24" }, + id: ORDER_ID, + checkout_id: CHECKOUT_ID, + permalink_url: `http://localhost:8080/orders/${ORDER_ID}`, + line_items: [ + { + id: "li_1", + item: { id: "bouquet_roses" }, + quantity: { total: 1, fulfilled: 0 }, + totals: [], + status: "processing", + }, + ], + fulfillment: { expectations: [] }, + currency: "USD", + totals: [{ type: "total", amount: 3500 }], + }) + ); +} + +before(() => { + initDbs(":memory:", ":memory:"); +}); + +type CapturedRequest = { + url: string; + headers: Record; + body: unknown; +}; + +// Fire notifyWebhook with global fetch stubbed and return the captured POSTs, +// mirroring the delivered wire request exactly. +async function notifyAndCapture( + checkout: unknown, + eventType: string +): Promise { + const captured: CapturedRequest[] = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + url: string | URL | Request, + init?: RequestInit + ): Promise => { + const headers = (init?.headers ?? {}) as Record; + const rawBody = init?.body; + captured.push({ + url: String(url), + headers, + body: typeof rawBody === "string" ? JSON.parse(rawBody) : rawBody, + }); + return new Response(null, { status: 200 }); + }) as typeof globalThis.fetch; + + try { + await new CheckoutService()["notifyWebhook"](checkout as never, eventType); + } finally { + globalThis.fetch = originalFetch; + } + return captured; +} + +test("webhook delivers the bare order object as the body", async () => { + seedOrder(); + const checkout = { + id: CHECKOUT_ID, + platform: { webhook_url: WEBHOOK_URL }, + order: { + id: ORDER_ID, + permalink_url: `http://localhost:8080/orders/${ORDER_ID}`, + }, + }; + + const captured = await notifyAndCapture(checkout, "order_placed"); + + assert.equal(captured.length, 1, "exactly one webhook must be delivered"); + const delivered = captured[0]!; + assert.equal(delivered.url, WEBHOOK_URL); + + // The event type travels in the header, not the body. + assert.equal( + delivered.headers["X-Event-Type"], + "order_placed", + "event type must be carried in the X-Event-Type header" + ); + + const body = delivered.body as Record; + // The body IS the order: its own id, and every required field present. + assert.equal( + body.id, + ORDER_ID, + "body must be the order itself (top-level id)" + ); + for (const field of ORDER_REQUIRED_FIELDS) { + assert.ok(field in body, `order body missing required field '${field}'`); + } + + // And it is NOT the old { event_type, checkout_id, order } envelope. + assert.equal( + body.event_type, + undefined, + "body must not carry an event_type key" + ); + assert.equal( + body.order, + undefined, + "body must not nest the order under 'order'" + ); +}); + +test("no webhook is delivered when there is no order", async () => { + // A checkout with no order (e.g. created but not completed) must never post, + // because the body must always be a valid order object. + const checkout = { + id: CHECKOUT_ID, + platform: { webhook_url: WEBHOOK_URL }, + // no `order` field + }; + + const captured = await notifyAndCapture(checkout, "order_placed"); + + assert.deepEqual(captured, [], "no webhook may be sent without an order"); +});