forked from enviPath/enviPy
Add API key authentication to v1 API Also includes: - management command to create keys for users - Improvements to API tests Minor: - more robust way to start docker dev container. Reviewed-on: enviPath/enviPy#327 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
219 lines
8.1 KiB
Python
219 lines
8.1 KiB
Python
"""
|
|
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)
|