Skip to content

Commit c026b2c

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 c026b2c

3 files changed

Lines changed: 164 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: 73 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,29 @@ 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 exact path matching
72+
self.assert_response_status(response, [200, 201])
73+
checkout_data = response.json()
74+
li_id = checkout_data["line_items"][0]["id"]
75+
self.assert_checkout_status(
76+
checkout_data,
77+
expected_status="incomplete",
78+
valid_path_matchers=[
79+
"$.line_items[0]",
80+
f"$.line_items[?(@.id=='{li_id}')]",
81+
],
82+
model_class=checkout.Checkout,
7483
)
7584

7685
def test_update_inventory_validation(self) -> None:
7786
"""Test that inventory validation is enforced on update.
7887
88+
Reference: https://ucp.dev/specification/checkout/#error-handling
89+
7990
Given an existing checkout session with a valid quantity,
8091
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.
92+
Then the server should return a successful response (200/201) with
93+
status 'incomplete' and an error message indicating insufficient stock.
8394
"""
8495
response_json = self.create_checkout_session()
8596
checkout_obj = checkout.Checkout(**response_json)
@@ -119,9 +130,18 @@ def test_update_inventory_validation(self) -> None:
119130
headers=integration_test_utils.get_headers(),
120131
)
121132

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

127147
def test_product_not_found(self) -> None:
@@ -180,63 +200,63 @@ def test_payment_failure(self) -> None:
180200

181201
self.assert_response_status(response, 402)
182202

183-
def test_complete_without_fulfillment(self) -> None:
184-
"""Test completion rejection when fulfillment is missing.
203+
def test_update_without_fulfillment(self) -> None:
204+
"""Test Soft-Fail when fulfillment info is missing during update.
185205
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.
206+
Reference: https://ucp.dev/specification/checkout/#error-handling
207+
208+
Given an existing checkout session,
209+
When an update request is sent with an empty fulfillment method,
210+
Then the server should return a 200 OK status with 'incomplete' status
211+
and an error message indicating missing fulfillment info.
189212
"""
190213
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()
214+
checkout_obj = checkout.Checkout(**response_json)
215+
checkout_id = checkout_obj.id
194216

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(),
217+
# Update with empty fulfillment using the helper to ensure valid structure
218+
response_json = self.update_checkout_session(
219+
checkout_obj, fulfillment={"methods": [{"type": "shipping"}]}
199220
)
200221

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",
222+
# Get the method ID from the response for precise path matching
223+
method_id = response_json["fulfillment"]["methods"][0]["id"]
224+
225+
self.assert_checkout_status(
226+
response_json,
227+
expected_status="incomplete",
228+
valid_path_matchers=[
229+
"$.fulfillment",
230+
"$.fulfillment.methods",
231+
f"$.fulfillment.methods[?(@.id=='{method_id}')].destinations",
232+
],
233+
model_class=checkout.Checkout,
206234
)
207235

208236
def test_structured_error_messages(self) -> None:
209-
"""Test that error responses conform to the Message schema.
237+
"""Test that error responses conform to the UCP ErrorResponse schema.
210238
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-
)
239+
Reference: https://ucp.dev/specification/checkout-rest/#error-responses
221240
222-
create_payload = self.create_checkout_payload(
223-
item_id=out_of_stock_item["id"],
224-
)
241+
Given a request for a non-existent checkout ID,
242+
When the server responds with a 404 Not Found error,
243+
Then the response body should contain a structured UCP response with
244+
status 'requires_escalation' and a descriptive error message.
245+
"""
246+
non_existent_id = "non-existent-session-id"
225247

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-
),
248+
response = self.client.get(
249+
self.get_shopping_url(f"/checkout-sessions/{non_existent_id}"),
231250
headers=integration_test_utils.get_headers(),
232251
)
233252

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

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"])
256+
# Verify using centralized utility with 'requires_escalation' status
257+
self.assert_checkout_status(
258+
response.json(), expected_status="requires_escalation"
259+
)
240260

241261

242262
if __name__ == "__main__":

0 commit comments

Comments
 (0)