Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion rest/python/server/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,23 @@
"""Integration tests for the UCP SDK Server."""

import asyncio
from collections.abc import AsyncGenerator
import json
from pathlib import Path
import shutil
import tempfile
from collections.abc import AsyncGenerator
import uuid

from absl import flags
from absl.testing import absltest
import db
import dependencies
from fastapi.testclient import TestClient
import respx
from models import UnifiedCheckout
from server.server import app
from services.checkout_service import CheckoutService
from services.fulfillment_service import FulfillmentService
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker
Expand Down Expand Up @@ -55,6 +60,7 @@
from ucp_sdk.models.schemas.shopping.fulfillment import (
Checkout as FulfillmentCheckout,
)
from ucp_sdk.models.schemas.shopping.order import Order
from ucp_sdk.models.schemas.shopping.order import PlatformSchema
from ucp_sdk.models.schemas.shopping.types import (
fulfillment_group_create_request as fulfillment_group_create_req,
Expand Down Expand Up @@ -591,6 +597,129 @@ def test_cancel_checkout(self) -> None:
self.assertEqual(response.status_code, 409)
self.assertIn("Cannot cancel checkout", response.json()["detail"])

def _notify_and_capture(
self, checkout: UnifiedCheckout, event_type: str
) -> list[dict]:
"""Fire _notify_webhook with httpx stubbed and return captured POSTs."""
captured: list[dict] = []

async def run() -> None:
async with (
self.products_session_factory() as products_session,
self.transactions_session_factory() as transactions_session,
):
service = CheckoutService(
FulfillmentService(),
products_session,
transactions_session,
"http://testserver",
)
with respx.mock:
route = respx.post().respond(200)
await service._notify_webhook(checkout, event_type)
if route.called:
for call in route.calls:
request = call.request
body = json.loads(request.content) if request.content else None
captured.append(
{
"url": str(request.url),
"json": body,
"headers": request.headers,
}
)

asyncio.run(run())
return captured

def test_webhook_delivers_the_bare_order_as_body(self) -> None:
"""The order-event webhook body is the order object, per rest.openapi.json.

webhooks.orderEvent.post.requestBody references #/components/schemas/order,
so the delivered JSON must be the order itself (every required top-level
field present) with the event type carried in the X-Event-Type header --
never a custom {event_type, checkout_id, order} envelope.
"""
with self.client:
# Drive a real create + complete so the server persists a real order.
payload = self._create_checkout_payload(
"wh_order_placed", [("rose", "Red Rose", 1000, 1)]
)
response = self.client.post(
"/checkout-sessions",
headers=self._get_headers(idempotency_key="wh1", request_id="wh1"),
json=payload.model_dump(mode="json", exclude_none=True),
)
self.assertEqual(response.status_code, 201, response.text)

payment_payload = self._create_payment_payload()
response = self.client.post(
"/checkout-sessions/wh_order_placed/complete",
headers=self._get_headers(idempotency_key="wh2", request_id="wh2"),
json=payment_payload,
)
self.assertEqual(response.status_code, 200, response.text)

checkout = UnifiedCheckout.model_validate(response.json())
self.assertIsNotNone(
checkout.order, "completed checkout must carry an order"
)
checkout.platform = PlatformSchema(
webhook_url="https://platform.example/ucp-webhook"
)

captured = self._notify_and_capture(checkout, "order_placed")

self.assertEqual(len(captured), 1, "exactly one webhook must be delivered")
delivered = captured[0]
self.assertEqual(delivered["url"], "https://platform.example/ucp-webhook")
# The event type travels in the header, not the body.
self.assertEqual(delivered["headers"].get("X-Event-Type"), "order_placed")

body = delivered["json"]
# The body IS an order: it validates and carries every required field.
Order.model_validate(body)
for field in (
"ucp",
"id",
"checkout_id",
"permalink_url",
"line_items",
"fulfillment",
"currency",
"totals",
):
self.assertIn(field, body, f"order body missing required '{field}'")
# And it is NOT the old {event_type, checkout_id, order} envelope.
self.assertNotIn("event_type", body)
self.assertNotIn("order", body)

def test_webhook_is_skipped_when_there_is_no_order(self) -> None:
"""No webhook is delivered when the checkout has no order to send.

The body must always be a valid order, so an absent order must never be
posted (the old envelope posted a body of {"order": null}).
"""
with self.client:
payload = self._create_checkout_payload(
"wh_no_order", [("rose", "Red Rose", 1000, 1)]
)
response = self.client.post(
"/checkout-sessions",
headers=self._get_headers(idempotency_key="wh3", request_id="wh3"),
json=payload.model_dump(mode="json", exclude_none=True),
)
self.assertEqual(response.status_code, 201, response.text)
# A created-but-not-completed checkout has no order yet.
checkout = UnifiedCheckout.model_validate(response.json())
self.assertIsNone(checkout.order)
checkout.platform = PlatformSchema(
webhook_url="https://platform.example/ucp-webhook"
)

captured = self._notify_and_capture(checkout, "order_placed")
self.assertEqual(captured, [], "no webhook may be sent without an order")

def test_version_invalid_format(self) -> None:
"""Tests that UCP-Agent with invalid version format is rejected."""
with self.client:
Expand Down
1 change: 1 addition & 0 deletions rest/python/server/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dev = [
"pytest>=8.0.0",
"httpx>=0.26.0",
"ruff>=0.14.11",
"respx>=0.21.0",
]

[build-system]
Expand Down
31 changes: 23 additions & 8 deletions rest/python/server/services/checkout_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -807,26 +807,41 @@ async def complete_checkout(
return checkout

async def _notify_webhook(self, checkout: Checkout, event_type: str) -> None:
"""Notifies the configured webhook of an event."""
"""Notifies the configured 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.
"""
if not checkout.platform or not checkout.platform.webhook_url:
return

webhook_url = str(checkout.platform.webhook_url)
order_data = None
if checkout.order and checkout.order.id:
order_data = await db.get_order(
self.transactions_session, checkout.order.id
)

payload = {
"event_type": event_type,
"checkout_id": checkout.id,
"order": order_data,
}
if not order_data:
logger.warning(
"Skipping %s webhook for checkout %s: no order to deliver",
event_type,
checkout.id,
)
return

webhook_url = str(checkout.platform.webhook_url)

try:
async with httpx.AsyncClient() as client:
await client.post(webhook_url, json=payload, timeout=5.0)
await client.post(
webhook_url,
json=order_data,
headers={"X-Event-Type": event_type},
timeout=5.0,
)
except Exception as e: # pylint: disable=broad-exception-caught
logger.error("Failed to notify webhook at %s: %s", webhook_url, e)

Expand Down
Loading