Add unified RequestValidationError handler to unify error response schema
- Add RequestValidationError handler that converts Pydantic validation errors to unified ErrorResponse format - Ensures all error responses return consistent schema: code, detail, metadata, correlation_id - Add field_errors count and first_field location to metadata for validation errors - Register handler in exception handler hierarchy before HTTPException handler - Add comprehensive tests for validation error responses - Update Backend-Development.md documentation to include correlation_id field and validation error details - All 44 error-related tests pass (38 existing + 6 new validation tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
186
backend/tests/test_error_response_schema.py
Normal file
186
backend/tests/test_error_response_schema.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Test unified error response schema.
|
||||
|
||||
This test suite verifies that all error responses use the unified ErrorResponse
|
||||
format with code, detail, metadata, and correlation_id fields.
|
||||
|
||||
This ensures:
|
||||
1. Frontend can handle all errors with a single schema.
|
||||
2. Error codes are machine-readable.
|
||||
3. Error details include structured metadata.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
class RequestModel(BaseModel):
|
||||
"""Test request model with validation."""
|
||||
|
||||
name: str
|
||||
age: int
|
||||
|
||||
|
||||
class TestErrorResponseSchema:
|
||||
"""Verify all error responses use the unified ErrorResponse schema."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_returns_unified_schema(
|
||||
self, test_settings: Settings
|
||||
) -> None:
|
||||
"""RequestValidationError returns unified ErrorResponse format."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
# Send invalid data that violates validation
|
||||
response = await client.post(
|
||||
"/test-validation", json={"name": "John", "age": "not_a_number"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
# Verify unified schema
|
||||
assert "code" in data, "Missing 'code' field"
|
||||
assert "detail" in data, "Missing 'detail' field"
|
||||
assert "metadata" in data, "Missing 'metadata' field"
|
||||
assert "correlation_id" in data, "Missing 'correlation_id' field"
|
||||
|
||||
# Verify values
|
||||
assert data["code"] == "invalid_input"
|
||||
assert "validation" in data["detail"].lower()
|
||||
assert isinstance(data["metadata"], dict)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_includes_field_details(
|
||||
self, test_settings: Settings
|
||||
) -> None:
|
||||
"""Validation error metadata includes field error count and first field."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
response = await client.post(
|
||||
"/test-validation", json={"name": "John", "age": "invalid"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
# Verify metadata contains field error information
|
||||
assert "field_errors" in data["metadata"]
|
||||
assert "first_field" in data["metadata"]
|
||||
assert data["metadata"]["field_errors"] > 0
|
||||
assert "age" in data["metadata"]["first_field"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_has_correlation_id(
|
||||
self, test_settings: Settings
|
||||
) -> None:
|
||||
"""Validation errors include correlation_id for tracing."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
response = await client.post(
|
||||
"/test-validation", json={"name": 123, "age": "not_a_number"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
# correlation_id can be None but field should exist
|
||||
assert "correlation_id" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_field_validation(
|
||||
self, test_settings: Settings
|
||||
) -> None:
|
||||
"""Missing required fields return validation error."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
# Send incomplete data
|
||||
response = await client.post("/test-validation", json={"name": "John"})
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
assert data["code"] == "invalid_input"
|
||||
assert "validation" in data["detail"].lower()
|
||||
assert "field_errors" in data["metadata"]
|
||||
assert data["metadata"]["field_errors"] > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_response_structure(self, test_settings: Settings) -> None:
|
||||
"""All validation errors return consistent ErrorResponse structure."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
# Test with wrong type
|
||||
response = await client.post(
|
||||
"/test-validation",
|
||||
json={"name": "John", "age": []},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
assert data["code"] == "invalid_input"
|
||||
assert "metadata" in data
|
||||
assert isinstance(data["metadata"], dict)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validation_error_does_not_leak_sensitive_data(
|
||||
self, test_settings: Settings
|
||||
) -> None:
|
||||
"""Validation errors do not leak internal implementation details."""
|
||||
app = create_app(test_settings)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
|
||||
@app.post("/test-validation")
|
||||
async def test_validation(body: RequestModel) -> dict[str, Any]:
|
||||
return {"ok": True}
|
||||
|
||||
response = await client.post(
|
||||
"/test-validation", json={"name": "John", "age": "not_a_number"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
|
||||
# Verify the response structure is safe
|
||||
assert "stack" not in data
|
||||
assert "traceback" not in data
|
||||
assert "exc_info" not in data
|
||||
Reference in New Issue
Block a user