diff --git a/validation_test.py b/validation_test.py index 9a292f8..baac374 100644 --- a/validation_test.py +++ b/validation_test.py @@ -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 @@ -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]) @@ -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. @@ -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. @@ -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: @@ -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"},