Skip to content

Commit d47cfa1

Browse files
committed
Update validation test to follow documentation.
Expect soft-fail when supported following https://ucp.dev/specification/checkout/#error-handling Use correct error message format for hard fails https://ucp.dev/specification/checkout-rest/#error-responses
1 parent fdced3a commit d47cfa1

3 files changed

Lines changed: 165 additions & 89 deletions

File tree

.cspell/custom-words.txt

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,87 @@
11
# cspell-specific custom words related to UCP
2+
absl
3+
absltest
4+
adyen
25
Adyen
6+
agentic
37
Alam
48
Amex
59
Ant
610
Anytown
11+
atok
12+
backorder
713
Backordered
814
Braintree
915
Carrefour
1016
Centricity
17+
checkout
1118
Chewy
1219
Commerce
13-
Credentialless
14-
Depot
15-
EWALLET
16-
Etsy
17-
Flipkart
18-
Gap
19-
GitHub
20-
Google
21-
Gpay
22-
Kroger
23-
Lowe's
24-
Macy's
25-
Mastercard
26-
Paymentech
27-
Paypal
28-
Preorders
29-
Queensway
30-
Sephora
31-
Shopify
32-
Shopee
33-
Stripe
34-
Target
35-
UCP
36-
Ulta
37-
Visa
38-
Wayfair
39-
Worldpay
40-
Zalando
41-
absl
42-
absltest
43-
adyen
44-
agentic
45-
atok
46-
backorder
47-
checkout
48-
credentialless
4920
credentialization
21+
credentialless
22+
Credentialless≈
23+
cust
5024
datamodel
25+
Depot
5126
dpan
27+
Etsy
5228
ewallet
29+
EWALLET
30+
Flipkart
5331
fontawesome
5432
fpan
5533
fulfillable
34+
Gap
35+
GitHub
36+
Google
5637
gpay
38+
Gpay
5739
healthz
5840
ingestions
5941
inlinehilite
42+
Kroger
6043
linenums
6144
llmstxt
45+
Lowe's
46+
Macy's
47+
Malform
6248
mastercard
49+
Mastercard
6350
mkdocs
6451
mtok
6552
openapi
6653
openrpc
54+
Paymentech
6755
paypal
56+
Paypal
6857
permissionless
6958
preorders
59+
Preorders
7060
proto
7161
protobuf
7262
pymdownx
63+
Queensway
7364
renderable
7465
repudiable
7566
schemas
7667
sdjwt
68+
Sephora
69+
Shopee
7770
shopify
71+
Shopify
72+
Smallville
73+
Stripe
7874
superfences
75+
Target
76+
UCP
77+
Ulta
78+
Villagetown
79+
Visa
7980
vulnz
81+
Wayfair
82+
Worldpay
83+
wumpus
84+
Wumpus
8085
yaml
8186
yml
87+
Zalando

integration_test_utils.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,55 @@ def assert_response_status(
558558
),
559559
)
560560

561+
def assert_checkout_status(
562+
self,
563+
checkout_data: dict[str, Any],
564+
expected_status: str = "incomplete",
565+
valid_path_matchers: list[str] | None = None,
566+
model_class: Any = None,
567+
) -> Any:
568+
"""Assert checkout status and optionally verify error paths using Pydantic."""
569+
if model_class is None:
570+
# Use a more permissive model that only requires status and messages for errors
571+
from pydantic import BaseModel
572+
from ucp_sdk.models.schemas.shopping.types import message
573+
574+
class BasicErrorResponse(BaseModel):
575+
status: str
576+
messages: list[message.Message] | None = None
577+
578+
model_class = BasicErrorResponse
579+
580+
checkout_obj = model_class(**checkout_data)
581+
582+
self.assertEqual(
583+
checkout_obj.status,
584+
expected_status,
585+
msg=f"Expected checkout status '{expected_status}', got '{checkout_obj.status}'.",
586+
)
587+
588+
if valid_path_matchers:
589+
# Extract error messages from the Pydantic model
590+
error_messages = [
591+
msg.root
592+
for msg in (checkout_obj.messages or [])
593+
if getattr(msg.root, "type", None) == "error"
594+
]
595+
596+
error_paths = [getattr(m, "path", "") for m in error_messages]
597+
598+
matches = any(
599+
any(path == matcher for matcher in valid_path_matchers)
600+
for path in error_paths
601+
)
602+
603+
self.assertTrue(
604+
matches,
605+
f"Expected error path matching one of {valid_path_matchers}. Paths found: {error_paths}",
606+
)
607+
608+
return checkout_obj
609+
561610
def create_checkout_session(
562611
self,
563612
quantity: int = 1,

validation_test.py

Lines changed: 74 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ class ValidationTest(integration_test_utils.IntegrationTestBase):
4242
def test_out_of_stock(self) -> None:
4343
"""Test validation for out-of-stock items.
4444
45+
Reference: https://ucp.dev/specification/checkout/#error-handling
46+
4547
Given a product with 0 inventory,
4648
When a checkout creation request is made for this item,
47-
Then the server should return a 400 Bad Request error indicating
48-
insufficient stock.
49+
Then the server should return a successful response (201) with status
50+
'incomplete' and an error message indicating insufficient stock.
4951
"""
5052
# Get out of stock item from config
5153
out_of_stock_item = self.conformance_config.get(
@@ -66,20 +68,30 @@ def test_out_of_stock(self) -> None:
6668
headers=integration_test_utils.get_headers(),
6769
)
6870

69-
self.assert_response_status(response, 400)
70-
self.assertIn(
71-
"Insufficient stock",
72-
response.text,
73-
msg="Expected 'Insufficient stock' message",
71+
# Verify HTTP status and then using centralized utility with
72+
# exact path matching
73+
self.assert_response_status(response, [200, 201])
74+
checkout_data = response.json()
75+
li_id = checkout_data["line_items"][0]["id"]
76+
self.assert_checkout_status(
77+
checkout_data,
78+
expected_status="incomplete",
79+
valid_path_matchers=[
80+
"$.line_items[0]",
81+
f"$.line_items[?(@.id=='{li_id}')]",
82+
],
83+
model_class=checkout.Checkout,
7484
)
7585

7686
def test_update_inventory_validation(self) -> None:
7787
"""Test that inventory validation is enforced on update.
7888
89+
Reference: https://ucp.dev/specification/checkout/#error-handling
90+
7991
Given an existing checkout session with a valid quantity,
8092
When the line item quantity is updated to exceed available stock,
81-
Then the server should return a 400 Bad Request error indicating
82-
insufficient stock.
93+
Then the server should return a successful response (200/201) with
94+
status 'incomplete' and an error message indicating insufficient stock.
8395
"""
8496
response_json = self.create_checkout_session()
8597
checkout_obj = checkout.Checkout(**response_json)
@@ -119,9 +131,19 @@ def test_update_inventory_validation(self) -> None:
119131
headers=integration_test_utils.get_headers(),
120132
)
121133

122-
self.assert_response_status(response, 400)
123-
self.assertIn(
124-
"stock", response.text.lower(), msg="Expected 'stock' message"
134+
# Verify HTTP status and then using centralized utility with
135+
# exact path matching
136+
self.assert_response_status(response, [200, 201])
137+
checkout_data = response.json()
138+
li_id = checkout_data["line_items"][0]["id"]
139+
self.assert_checkout_status(
140+
checkout_data,
141+
expected_status="incomplete",
142+
valid_path_matchers=[
143+
"$.line_items[0]",
144+
f"$.line_items[?(@.id=='{li_id}')]",
145+
],
146+
model_class=checkout.Checkout,
125147
)
126148

127149
def test_product_not_found(self) -> None:
@@ -180,63 +202,62 @@ def test_payment_failure(self) -> None:
180202

181203
self.assert_response_status(response, 402)
182204

183-
def test_complete_without_fulfillment(self) -> None:
184-
"""Test completion rejection when fulfillment is missing.
205+
def test_update_without_fulfillment(self) -> None:
206+
"""Test Soft-Fail when fulfillment info is missing during update.
207+
208+
Reference: https://ucp.dev/specification/checkout/#error-handling
185209
186-
Given a newly created checkout session without fulfillment details,
187-
When a completion request is submitted,
188-
Then the server should return a 400 Bad Request error.
210+
Given an existing checkout session,
211+
When an update request is sent with an empty fulfillment method,
212+
Then the server should return a 200 OK status with 'incomplete' status
213+
and an error message indicating missing fulfillment info.
189214
"""
190215
response_json = self.create_checkout_session(select_fulfillment=False)
191-
checkout_id = response_json["id"]
192-
193-
payment_payload = integration_test_utils.get_valid_payment_payload()
216+
checkout_obj = checkout.Checkout(**response_json)
194217

195-
response = self.client.post(
196-
self.get_shopping_url(f"/checkout-sessions/{checkout_id}/complete"),
197-
json=payment_payload,
198-
headers=integration_test_utils.get_headers(),
218+
# Update with empty fulfillment using the helper to ensure valid structure
219+
response_json = self.update_checkout_session(
220+
checkout_obj, fulfillment={"methods": [{"type": "shipping"}]}
199221
)
200222

201-
self.assert_response_status(response, 400)
202-
self.assertIn(
203-
"Fulfillment address and option must be selected",
204-
response.text,
205-
msg="Expected error message for missing fulfillment",
223+
# Get the method ID from the response for precise path matching
224+
method_id = response_json["fulfillment"]["methods"][0]["id"]
225+
226+
self.assert_checkout_status(
227+
response_json,
228+
expected_status="incomplete",
229+
valid_path_matchers=[
230+
"$.fulfillment",
231+
"$.fulfillment.methods",
232+
f"$.fulfillment.methods[?(@.id=='{method_id}')].destinations",
233+
],
234+
model_class=checkout.Checkout,
206235
)
207236

208237
def test_structured_error_messages(self) -> None:
209-
"""Test that error responses conform to the Message schema.
238+
"""Test that error responses conform to the UCP ErrorResponse schema.
210239
211-
Given a request that triggers an error (e.g., out of stock),
212-
When the server responds with an error code (400),
213-
Then the response body should contain a structured 'detail' field describing
214-
the error.
215-
"""
216-
# Get out of stock item from config
217-
out_of_stock_item = self.conformance_config.get(
218-
"out_of_stock_item",
219-
{"id": "out_of_stock_item_1", "title": "Out of Stock Item"},
220-
)
240+
Reference: https://ucp.dev/specification/checkout-rest/#error-responses
221241
222-
create_payload = self.create_checkout_payload(
223-
item_id=out_of_stock_item["id"],
224-
)
242+
Given a request for a non-existent checkout ID,
243+
When the server responds with a 404 Not Found error,
244+
Then the response body should contain a structured UCP response with
245+
status 'requires_escalation' and a descriptive error message.
246+
"""
247+
non_existent_id = "non-existent-session-id"
225248

226-
response = self.client.post(
227-
self.get_shopping_url("/checkout-sessions"),
228-
json=create_payload.model_dump(
229-
mode="json", by_alias=True, exclude_none=True
230-
),
249+
response = self.client.get(
250+
self.get_shopping_url(f"/checkout-sessions/{non_existent_id}"),
231251
headers=integration_test_utils.get_headers(),
232252
)
233253

234-
self.assert_response_status(response, 400)
254+
# Verify HTTP status 404
255+
self.assert_response_status(response, 404)
235256

236-
# Check for structured error
237-
data = response.json()
238-
self.assertTrue(data.get("detail"), "Error response missing 'detail' field")
239-
self.assertIn("Insufficient stock", data["detail"])
257+
# Verify using centralized utility with 'requires_escalation' status
258+
self.assert_checkout_status(
259+
response.json(), expected_status="requires_escalation"
260+
)
240261

241262

242263
if __name__ == "__main__":

0 commit comments

Comments
 (0)