""" Tests for validation error utilities. Tests the format_validation_error() and handle_validation_error() functions that transform Pydantic validation errors into user-friendly messages. """ from django.test import TestCase, tag import json from pydantic import BaseModel, ValidationError, field_validator from typing import Literal from ninja.errors import HttpError from epapi.utils.validation_errors import format_validation_error, handle_validation_error @tag("api", "utils") class ValidationErrorUtilityTests(TestCase): """Test validation error utility functions.""" def test_format_missing_field_error(self): """Test formatting of missing required field error.""" # Create a model with required field class TestModel(BaseModel): required_field: str # Trigger validation error try: TestModel() except ValidationError as e: errors = e.errors() self.assertEqual(len(errors), 1) formatted = format_validation_error(errors[0]) self.assertEqual(formatted, "This field is required") def test_format_enum_error(self): """Test formatting of enum validation error.""" class TestModel(BaseModel): status: Literal["active", "inactive"] try: TestModel(status="invalid") except ValidationError as e: errors = e.errors() self.assertEqual(len(errors), 1) formatted = format_validation_error(errors[0]) # Literal errors get formatted as "Please enter ..." with the valid options self.assertIn("Please enter", formatted) self.assertIn("active", formatted) self.assertIn("inactive", formatted) def test_format_type_errors(self): """Test formatting of type validation errors (string, int, float).""" test_cases = [ # (field_type, invalid_value, expected_message) # Note: We don't check exact error_type as Pydantic may use different types # (e.g., int_type vs int_parsing) but we verify the formatted message is correct (str, 123, "Please enter a valid string"), (int, "not_a_number", "Please enter a valid int"), (float, "not_a_float", "Please enter a valid float"), ] for field_type, invalid_value, expected_message in test_cases: with self.subTest(field_type=field_type.__name__): class TestModel(BaseModel): field: field_type try: TestModel(field=invalid_value) except ValidationError as e: errors = e.errors() self.assertEqual(len(errors), 1) formatted = format_validation_error(errors[0]) self.assertEqual(formatted, expected_message) def test_format_value_error(self): """Test formatting of value error from custom validator.""" class TestModel(BaseModel): age: int @field_validator("age") @classmethod def validate_age(cls, v): if v < 0: raise ValueError("Age must be positive") return v try: TestModel(age=-5) except ValidationError as e: errors = e.errors() self.assertEqual(len(errors), 1) formatted = format_validation_error(errors[0]) self.assertEqual(formatted, "Age must be positive") def test_format_unknown_error_type_fallback(self): """Test that unknown error types fall back to default formatting.""" # Mock an error with an unknown type mock_error = { "type": "unknown_custom_type", "msg": "Input should be a valid email address", "ctx": {}, } formatted = format_validation_error(mock_error) # Should use the else branch which does replacements on the message self.assertEqual(formatted, "Please enter a valid email address") def test_handle_validation_error_structure(self): """Test that handle_validation_error raises HttpError with correct structure.""" class TestModel(BaseModel): name: str count: int try: TestModel(name=123, count="invalid") except ValidationError as e: # handle_validation_error should raise HttpError with self.assertRaises(HttpError) as context: handle_validation_error(e) http_error = context.exception self.assertEqual(http_error.status_code, 400) # Parse the JSON from the error message error_data = json.loads(http_error.message) # Check structure self.assertEqual(error_data["type"], "validation_error") self.assertIn("field_errors", error_data) self.assertIn("message", error_data) self.assertEqual(error_data["message"], "Please correct the errors below") # Check that both fields have errors self.assertIn("name", error_data["field_errors"]) self.assertIn("count", error_data["field_errors"]) def test_handle_validation_error_no_pydantic_internals(self): """Test that handle_validation_error doesn't expose Pydantic internals.""" class TestModel(BaseModel): email: str try: TestModel(email=123) except ValidationError as e: with self.assertRaises(HttpError) as context: handle_validation_error(e) http_error = context.exception error_data = json.loads(http_error.message) error_str = json.dumps(error_data) # Ensure no Pydantic internals are exposed self.assertNotIn("pydantic", error_str.lower()) self.assertNotIn("https://errors.pydantic.dev", error_str) self.assertNotIn("loc", error_str) def test_handle_validation_error_user_friendly_messages(self): """Test that all error messages are user-friendly.""" class TestModel(BaseModel): name: str age: int status: Literal["active", "inactive"] try: TestModel(name=123, status="invalid") # Multiple errors except ValidationError as e: with self.assertRaises(HttpError) as context: handle_validation_error(e) http_error = context.exception error_data = json.loads(http_error.message) # All messages should be user-friendly (contain "Please" or "This field") for field, messages in error_data["field_errors"].items(): for message in messages: # User-friendly messages start with "Please" or "This field" self.assertTrue( message.startswith("Please") or message.startswith("This field"), f"Message '{message}' is not user-friendly", ) def test_handle_validation_error_multiple_errors_same_field(self): """Test handling multiple validation errors for the same field.""" class TestModel(BaseModel): value: int @field_validator("value") @classmethod def validate_range(cls, v): if v < 0: raise ValueError("Must be non-negative") if v > 100: raise ValueError("Must be at most 100") return v # Test with string (type error) - this will fail before the validator runs try: TestModel(value="invalid") except ValidationError as e: with self.assertRaises(HttpError) as context: handle_validation_error(e) http_error = context.exception error_data = json.loads(http_error.message) # Should have error for 'value' field self.assertIn("value", error_data["field_errors"]) self.assertIsInstance(error_data["field_errors"]["value"], list) self.assertGreater(len(error_data["field_errors"]["value"]), 0)