From 291faee343c66fcc28a6d579767b301b8addba08 Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Fri, 11 Sep 2026 20:21:13 -0400 Subject: [PATCH 1/2] fix(rest/python): validate created fulfillment with the create request model `UnifiedCheckoutCreateRequest` declared `fulfillment` with `Fulfillment`, the response container, so creating a checkout required three members that the schema this server advertises marks as not supplied on create: fulfillment.methods[0].id ucp_request create: omit fulfillment.methods[0].line_item_ids ucp_request create: optional fulfillment.methods[0].destinations[0].id ucp_request optional A platform that composes the advertised schemas and sends a create body without them gets HTTP 422. Switching the annotation to `FulfillmentCreateRequest`, which the pinned ucp-sdk already ships, matches the line directly above it that already uses `LineItemCreateRequest`. The service layer was already written for this shape: checkout_service mints a method id when the request omits one, and a comment in the same function says the create path expects the create request model. The Node server in this repo also accepts the create shape and mints the id itself. Testing. Against the reference server, a create body carrying none of the three members goes from 422 to 201, and the supplied postal address now survives into the response instead of being reduced to a bare id. A body that does carry them still returns 201, so no existing client breaks. The update path is unchanged and still returns 200. pytest is 245 passed before and after. ruff check and ruff format are clean. --- rest/python/server/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rest/python/server/models.py b/rest/python/server/models.py index c9f5de7..d992c30 100644 --- a/rest/python/server/models.py +++ b/rest/python/server/models.py @@ -37,6 +37,9 @@ Checkout as FulfillmentCheckout, Fulfillment, ) +from ucp_sdk.models.schemas.shopping.types.fulfillment_create_request import ( + FulfillmentCreateRequest, +) from ucp_sdk.models.schemas.shopping.order import Order from ucp_sdk.models.schemas.shopping.order import PlatformSchema @@ -93,7 +96,7 @@ class UnifiedCheckoutCreateRequest(CheckoutCreateRequest): """Create request model combining base fields and extensions.""" line_items: list[LineItemCreateRequest] | None = None - fulfillment: Fulfillment | None = None + fulfillment: FulfillmentCreateRequest | None = None discounts: DiscountsObject | None = None buyer_consent: Any | None = None cart_id: str | None = None From b0bc71ba3e05bd2a41f7c2dbe761ae562da7b03b Mon Sep 17 00:00:00 2001 From: Vishal Katyal Date: Thu, 17 Sep 2026 23:47:58 -0400 Subject: [PATCH 2/2] test(rest/python): cover the fulfillment create shape on checkout create Adds the regression test requested in review. It posts a create body that omits the three members the schema marks as not supplied on create, `methods[].id`, `methods[].line_item_ids` and `destinations[].id`, asserts 201, and asserts the server assigns the two identifiers and preserves the supplied address. The existing fulfillment tests populate all three, which is what allowed the request model to regress to the response container without any test noticing. This one fails against the previous model with 422 and passes with the create request model, so the coverage is real rather than incidental. Suite goes from 245 to 249 passed, the four being this case running in the inherited test classes as well. ruff check and ruff format are clean. --- rest/python/server/integration_test.py | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/rest/python/server/integration_test.py b/rest/python/server/integration_test.py index 1e55400..7215700 100644 --- a/rest/python/server/integration_test.py +++ b/rest/python/server/integration_test.py @@ -1737,6 +1737,55 @@ def test_create_checkout_with_attribution(self) -> None: stored.get("attribution", {}).get("campaign_id"), "18234567890" ) + def test_create_checkout_with_fulfillment_create_shape(self) -> None: + """Fulfillment on create accepts the shape that omits server assigned ids. + + `fulfillment_method.json` marks `id` as `create: omit`, `line_item_ids` as + `create: optional`, and `shipping_destination.json` marks `id` as optional, + so a platform composing the advertised schemas sends none of them. The + other fulfillment tests populate all three, which is what let the request + model regress to the response container without any test noticing. + """ + with self.client: + response = self.client.post( + "/checkout-sessions", + headers=self._get_headers( + idempotency_key="ful_create_min", request_id="ful_create_min" + ), + json={ + "line_items": [{"item": {"id": "rose"}, "quantity": 1}], + "fulfillment": { + "methods": [ + { + "type": "shipping", + "destinations": [ + { + "street_address": "1 Test Way", + "address_locality": "Springfield", + "address_region": "CA", + "address_country": "US", + "postal_code": "99999", + } + ], + } + ] + }, + }, + ) + self.assertEqual(response.status_code, 201, f"Response: {response.text}") + body = response.json() + + methods = body.get("fulfillment", {}).get("methods", []) + self.assertLen(methods, 1) + self.assertIsNotNone(methods[0].get("id")) + self.assertEqual(methods[0].get("type"), "shipping") + + destinations = methods[0].get("destinations", []) + self.assertLen(destinations, 1) + self.assertIsNotNone(destinations[0].get("id")) + self.assertEqual(destinations[0].get("street_address"), "1 Test Way") + self.assertEqual(destinations[0].get("postal_code"), "99999") + def test_validation_failure_answers_with_ucp_envelope(self) -> None: """A validation failure answers with the UCP envelope, not detail.""" with self.client: