Skip to content
Merged
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
78 changes: 56 additions & 22 deletions validation_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
)
from ucp_sdk.models.schemas.shopping.types import item_update_request
from ucp_sdk.models.schemas.shopping.types import line_item_update_request
from ucp_sdk.models.schemas.shopping.types.error_response import ErrorResponse
from pydantic import ValidationError


# Rebuild models to resolve forward references
Expand Down Expand Up @@ -79,11 +81,7 @@ def assert_business_error(
``order``).
"""
if 400 <= response.status_code < 500:
self.assertIn(
error_4xx_substring.lower(),
response.text.lower(),
msg=f"Expected '{error_4xx_substring}' in the 4xx error body",
)
self._assert_structured_4xx_error(response, error_4xx_substring)
return

self.assert_response_status(response, [200, 201])
Expand Down Expand Up @@ -151,6 +149,50 @@ def assert_business_error(
"not carry an order",
)

def _assert_structured_4xx_error(self, response, substring: str) -> None:
"""Assert that a 4xx response is structured correctly (UCP or legacy)."""
try:
data = response.json()
if "messages" in data:
try:
error_resp = ErrorResponse(**data)
errors = [m for m in error_resp.messages if m.type == "error"]
self.assertTrue(
errors,
"Compliant 4xx response must have at least one error message",
)
self.assertTrue(
any(substring.lower() in e.content.lower() for e in errors),
f"Expected '{substring}' in error messages",
)
except ValidationError as e:
self.fail(f"Failed to parse ErrorResponse: {e}")
else:
self.assertTrue(
data.get("detail"),
"Error response missing 'detail' or 'messages' field",
)
self.assertIn(
substring.lower(),
str(data["detail"]).lower(),
)
except ValueError:
self.assertIn(
substring.lower(),
response.text.lower(),
msg=f"Expected '{substring}' in the 4xx error body",
)

def assert_4xx_error(
self,
response,
expected_status: int,
substring: str,
) -> None:
"""Assert a 4xx rejection with a specific status and error message."""
self.assert_response_status(response, expected_status)
self._assert_structured_4xx_error(response, substring)

def test_out_of_stock(self) -> None:
"""Test validation for out-of-stock items.

Expand Down Expand Up @@ -316,7 +358,11 @@ def test_payment_failure(self) -> None:
headers=integration_test_utils.get_headers(),
)

self.assert_response_status(response, 402)
self.assert_4xx_error(
response,
expected_status=402,
substring="Payment Failed",
)

def test_complete_without_fulfillment(self) -> None:
"""Test completion rejection when fulfillment is missing.
Expand All @@ -336,11 +382,10 @@ def test_complete_without_fulfillment(self) -> None:
headers=integration_test_utils.get_headers(),
)

self.assert_response_status(response, 400)
self.assertIn(
"Fulfillment address and option must be selected",
response.text,
msg="Expected error message for missing fulfillment",
self.assert_4xx_error(
response,
expected_status=400,
substring="Fulfillment address and option must be selected",
)

def test_structured_error_messages(self) -> None:
Expand Down Expand Up @@ -370,17 +415,6 @@ def test_structured_error_messages(self) -> None:
headers=integration_test_utils.get_headers(),
)

if 400 <= response.status_code < 500:
# 4xx posture: the body must be structured, not free text.
data = response.json()
self.assertTrue(
data.get("detail"), "Error response missing 'detail' field"
)
self.assertIn("stock", str(data["detail"]).lower())
return

# In-band posture: the message envelope IS the structured error; the
# shared assertion validates every required envelope field.
self.assert_business_error(
response,
accepted_codes={"out_of_stock", "item_unavailable"},
Expand Down
Loading