Initial commit: Pixel AI comic/video creation platform
- FastAPI backend with SQLModel, Alembic migrations, AgentScope agents - Next.js 15 frontend with React 19, Tailwind, Zustand, React Flow - Multi-provider AI system (DashScope, Kling, MiniMax, Volcengine, OpenAI, etc.) - All HTTP clients migrated from sync requests to async httpx - Admin-managed API keys via environment variables - SSRF vulnerability fixed in ensure_url()
This commit is contained in:
789
backend/tests/test_error_handling_properties.py
Normal file
789
backend/tests/test_error_handling_properties.py
Normal file
@@ -0,0 +1,789 @@
|
||||
"""
|
||||
Property-Based Tests for Error Handling System
|
||||
|
||||
This module contains property-based tests that verify correctness properties
|
||||
of the error handling system across all possible inputs.
|
||||
|
||||
Properties tested:
|
||||
- Property 4: Exception type correctness
|
||||
- Property 5: Error response standardization
|
||||
- Property 6: Error log completeness
|
||||
- Property 7: Error response structure consistency
|
||||
"""
|
||||
import pytest
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from hypothesis import given, strategies as st, assume, settings
|
||||
from hypothesis.strategies import composite
|
||||
from fastapi import Request, FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from src.utils.errors import (
|
||||
AppException,
|
||||
BusinessException,
|
||||
SystemException,
|
||||
ErrorCode,
|
||||
InvalidParameterException,
|
||||
ResourceNotFoundException,
|
||||
ProjectNotFoundException,
|
||||
TaskNotFoundException,
|
||||
TaskTimeoutException,
|
||||
TaskQueueFullException,
|
||||
ModelNotFoundException,
|
||||
GenerationFailedException,
|
||||
StorageException,
|
||||
RateLimitExceededException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
AssetNotFoundException,
|
||||
CanvasNotFoundException,
|
||||
EpisodeNotFoundException,
|
||||
StoryboardNotFoundException,
|
||||
ProjectCreateFailedException,
|
||||
ProjectUpdateFailedException,
|
||||
ProjectDeleteFailedException,
|
||||
TaskExecutionFailedException,
|
||||
TaskCancelledException,
|
||||
ModelNotAvailableException,
|
||||
QuotaExceededException,
|
||||
InvalidPromptException,
|
||||
ProviderErrorException,
|
||||
UploadFailedException,
|
||||
DownloadFailedException,
|
||||
FileTooLargeException,
|
||||
InvalidFileTypeException,
|
||||
)
|
||||
from src.middlewares.error_handler import ErrorResponse, error_handler_middleware
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Hypothesis Strategies for Generating Test Data
|
||||
# ============================================================================
|
||||
|
||||
@composite
|
||||
def error_codes(draw):
|
||||
"""Generate valid error codes"""
|
||||
return draw(st.sampled_from(list(ErrorCode)))
|
||||
|
||||
|
||||
@composite
|
||||
def error_messages(draw):
|
||||
"""Generate error messages"""
|
||||
return draw(st.text(min_size=1, max_size=200))
|
||||
|
||||
|
||||
@composite
|
||||
def error_details(draw):
|
||||
"""Generate error details dictionaries"""
|
||||
# Generate simple dictionaries with string keys and various value types
|
||||
keys = draw(st.lists(st.text(min_size=1, max_size=20), min_size=0, max_size=5, unique=True))
|
||||
values = []
|
||||
for _ in keys:
|
||||
value = draw(st.one_of(
|
||||
st.text(max_size=100),
|
||||
st.integers(),
|
||||
st.floats(allow_nan=False, allow_infinity=False),
|
||||
st.booleans(),
|
||||
st.none()
|
||||
))
|
||||
values.append(value)
|
||||
return dict(zip(keys, values))
|
||||
|
||||
|
||||
@composite
|
||||
def business_exception_types(draw):
|
||||
"""Generate business exception classes"""
|
||||
exception_classes = [
|
||||
InvalidParameterException,
|
||||
ResourceNotFoundException,
|
||||
ProjectNotFoundException,
|
||||
TaskNotFoundException,
|
||||
ModelNotFoundException,
|
||||
RateLimitExceededException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
AssetNotFoundException,
|
||||
CanvasNotFoundException,
|
||||
EpisodeNotFoundException,
|
||||
StoryboardNotFoundException,
|
||||
TaskCancelledException,
|
||||
ModelNotAvailableException,
|
||||
QuotaExceededException,
|
||||
InvalidPromptException,
|
||||
]
|
||||
return draw(st.sampled_from(exception_classes))
|
||||
|
||||
|
||||
@composite
|
||||
def system_exception_types(draw):
|
||||
"""Generate system exception classes"""
|
||||
exception_classes = [
|
||||
TaskTimeoutException,
|
||||
TaskQueueFullException,
|
||||
GenerationFailedException,
|
||||
StorageException,
|
||||
ProjectCreateFailedException,
|
||||
ProjectUpdateFailedException,
|
||||
ProjectDeleteFailedException,
|
||||
TaskExecutionFailedException,
|
||||
ProviderErrorException,
|
||||
UploadFailedException,
|
||||
DownloadFailedException,
|
||||
]
|
||||
return draw(st.sampled_from(exception_classes))
|
||||
|
||||
|
||||
@composite
|
||||
def create_business_exception(draw, exception_class):
|
||||
"""Create a business exception instance with appropriate parameters"""
|
||||
# Generate parameters based on exception type
|
||||
if exception_class == InvalidParameterException:
|
||||
field = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(field=field, reason=reason)
|
||||
|
||||
elif exception_class == ResourceNotFoundException:
|
||||
resource_type = draw(st.text(min_size=1, max_size=50))
|
||||
resource_id = draw(st.text(min_size=1, max_size=50))
|
||||
return exception_class(resource_type=resource_type, resource_id=resource_id)
|
||||
|
||||
elif exception_class in [ProjectNotFoundException, TaskNotFoundException, ModelNotFoundException,
|
||||
AssetNotFoundException, CanvasNotFoundException, EpisodeNotFoundException,
|
||||
StoryboardNotFoundException, TaskCancelledException]:
|
||||
id_value = draw(st.text(min_size=1, max_size=50))
|
||||
# Get the parameter name from the exception class
|
||||
if exception_class == ProjectNotFoundException:
|
||||
return exception_class(project_id=id_value)
|
||||
elif exception_class == TaskNotFoundException:
|
||||
return exception_class(task_id=id_value)
|
||||
elif exception_class == ModelNotFoundException:
|
||||
return exception_class(model_id=id_value)
|
||||
elif exception_class == AssetNotFoundException:
|
||||
return exception_class(asset_id=id_value)
|
||||
elif exception_class == CanvasNotFoundException:
|
||||
return exception_class(canvas_id=id_value)
|
||||
elif exception_class == EpisodeNotFoundException:
|
||||
return exception_class(episode_id=id_value)
|
||||
elif exception_class == StoryboardNotFoundException:
|
||||
return exception_class(storyboard_id=id_value)
|
||||
elif exception_class == TaskCancelledException:
|
||||
return exception_class(task_id=id_value)
|
||||
|
||||
elif exception_class == RateLimitExceededException:
|
||||
limit = draw(st.integers(min_value=1, max_value=10000))
|
||||
window = draw(st.integers(min_value=1, max_value=3600))
|
||||
return exception_class(limit=limit, window=window)
|
||||
|
||||
elif exception_class == UnauthorizedException:
|
||||
reason = draw(st.one_of(st.none(), st.text(min_size=1, max_size=100)))
|
||||
return exception_class(reason=reason)
|
||||
|
||||
elif exception_class == ForbiddenException:
|
||||
reason = draw(st.one_of(st.none(), st.text(min_size=1, max_size=100)))
|
||||
return exception_class(reason=reason)
|
||||
|
||||
elif exception_class == ConflictException:
|
||||
resource_type = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(resource_type=resource_type, reason=reason)
|
||||
|
||||
elif exception_class == ModelNotAvailableException:
|
||||
model_id = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.one_of(st.none(), st.text(min_size=1, max_size=100)))
|
||||
return exception_class(model_id=model_id, reason=reason)
|
||||
|
||||
elif exception_class == QuotaExceededException:
|
||||
resource = draw(st.text(min_size=1, max_size=50))
|
||||
limit = draw(st.integers(min_value=1, max_value=1000000))
|
||||
return exception_class(resource=resource, limit=limit)
|
||||
|
||||
elif exception_class == InvalidPromptException:
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(reason=reason)
|
||||
|
||||
# Default fallback
|
||||
return exception_class(project_id="test_id")
|
||||
|
||||
|
||||
@composite
|
||||
def create_system_exception(draw, exception_class):
|
||||
"""Create a system exception instance with appropriate parameters"""
|
||||
if exception_class == TaskTimeoutException:
|
||||
task_id = draw(st.text(min_size=1, max_size=50))
|
||||
timeout = draw(st.integers(min_value=1, max_value=3600))
|
||||
return exception_class(task_id=task_id, timeout=timeout)
|
||||
|
||||
elif exception_class == TaskQueueFullException:
|
||||
queue_size = draw(st.integers(min_value=1, max_value=10000))
|
||||
return exception_class(queue_size=queue_size)
|
||||
|
||||
elif exception_class == GenerationFailedException:
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
provider = draw(st.one_of(st.none(), st.text(min_size=1, max_size=50)))
|
||||
return exception_class(reason=reason, provider=provider)
|
||||
|
||||
elif exception_class == StorageException:
|
||||
operation = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(operation=operation, reason=reason)
|
||||
|
||||
elif exception_class == ProjectCreateFailedException:
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(reason=reason)
|
||||
|
||||
elif exception_class in [ProjectUpdateFailedException, ProjectDeleteFailedException]:
|
||||
project_id = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(project_id=project_id, reason=reason)
|
||||
|
||||
elif exception_class == TaskExecutionFailedException:
|
||||
task_id = draw(st.text(min_size=1, max_size=50))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(task_id=task_id, reason=reason)
|
||||
|
||||
elif exception_class == ProviderErrorException:
|
||||
provider = draw(st.text(min_size=1, max_size=50))
|
||||
error_message = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(provider=provider, error_message=error_message)
|
||||
|
||||
elif exception_class == UploadFailedException:
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(reason=reason)
|
||||
|
||||
elif exception_class == DownloadFailedException:
|
||||
url = draw(st.text(min_size=1, max_size=100))
|
||||
reason = draw(st.text(min_size=1, max_size=100))
|
||||
return exception_class(url=url, reason=reason)
|
||||
|
||||
# Default fallback
|
||||
return exception_class(reason="test reason")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Property 4: Exception Type Correctness
|
||||
# ============================================================================
|
||||
|
||||
class TestProperty4ExceptionTypeCorrectness:
|
||||
"""
|
||||
Property 4: 异常类型正确性
|
||||
|
||||
验证错误条件抛出正确的异常类型
|
||||
Validates: Requirements 3.2
|
||||
"""
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_business_exception(InvalidParameterException),
|
||||
create_business_exception(ResourceNotFoundException),
|
||||
create_business_exception(ProjectNotFoundException),
|
||||
create_business_exception(TaskNotFoundException),
|
||||
create_business_exception(ModelNotFoundException),
|
||||
create_business_exception(RateLimitExceededException),
|
||||
create_business_exception(UnauthorizedException),
|
||||
create_business_exception(ForbiddenException),
|
||||
create_business_exception(ConflictException),
|
||||
create_business_exception(AssetNotFoundException),
|
||||
create_business_exception(CanvasNotFoundException),
|
||||
create_business_exception(EpisodeNotFoundException),
|
||||
create_business_exception(StoryboardNotFoundException),
|
||||
create_business_exception(TaskCancelledException),
|
||||
create_business_exception(ModelNotAvailableException),
|
||||
create_business_exception(QuotaExceededException),
|
||||
create_business_exception(InvalidPromptException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
def test_business_exceptions_are_business_exception_type(self, exc):
|
||||
"""
|
||||
Property: All business error conditions should throw BusinessException subclasses
|
||||
|
||||
For any business exception class, instances should be BusinessException type
|
||||
"""
|
||||
# Verify it's a BusinessException
|
||||
assert isinstance(exc, BusinessException), \
|
||||
f"{type(exc).__name__} should be a BusinessException"
|
||||
|
||||
# Verify it's also an AppException
|
||||
assert isinstance(exc, AppException), \
|
||||
f"{type(exc).__name__} should be an AppException"
|
||||
|
||||
# Verify status code is 4xx (except for rate limit which is 429)
|
||||
if isinstance(exc, RateLimitExceededException):
|
||||
assert exc.status_code == 429
|
||||
elif isinstance(exc, UnauthorizedException):
|
||||
assert exc.status_code == 401
|
||||
elif isinstance(exc, ForbiddenException):
|
||||
assert exc.status_code == 403
|
||||
elif isinstance(exc, ConflictException):
|
||||
assert exc.status_code == 409
|
||||
else:
|
||||
assert 400 <= exc.status_code < 500, \
|
||||
f"{type(exc).__name__} should have 4xx status code"
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_system_exception(TaskTimeoutException),
|
||||
create_system_exception(TaskQueueFullException),
|
||||
create_system_exception(GenerationFailedException),
|
||||
create_system_exception(StorageException),
|
||||
create_system_exception(ProjectCreateFailedException),
|
||||
create_system_exception(ProjectUpdateFailedException),
|
||||
create_system_exception(ProjectDeleteFailedException),
|
||||
create_system_exception(TaskExecutionFailedException),
|
||||
create_system_exception(ProviderErrorException),
|
||||
create_system_exception(UploadFailedException),
|
||||
create_system_exception(DownloadFailedException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
def test_system_exceptions_are_system_exception_type(self, exc):
|
||||
"""
|
||||
Property: All system error conditions should throw SystemException subclasses
|
||||
|
||||
For any system exception class, instances should be SystemException type
|
||||
"""
|
||||
# Verify it's a SystemException
|
||||
assert isinstance(exc, SystemException), \
|
||||
f"{type(exc).__name__} should be a SystemException"
|
||||
|
||||
# Verify it's also an AppException
|
||||
assert isinstance(exc, AppException), \
|
||||
f"{type(exc).__name__} should be an AppException"
|
||||
|
||||
# Verify status code is 500
|
||||
assert exc.status_code == 500, \
|
||||
f"{type(exc).__name__} should have 500 status code"
|
||||
|
||||
@given(
|
||||
code=error_codes(),
|
||||
message=error_messages(),
|
||||
details=error_details()
|
||||
)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
def test_app_exception_preserves_error_information(self, code, message, details):
|
||||
"""
|
||||
Property: AppException should preserve all error information
|
||||
|
||||
For any error code, message, and details, the exception should store them correctly
|
||||
"""
|
||||
exc = AppException(code=code, message=message, details=details)
|
||||
|
||||
# Verify all information is preserved
|
||||
assert exc.code == code
|
||||
assert exc.message == message
|
||||
assert exc.details == details
|
||||
|
||||
# Verify to_dict includes all information
|
||||
exc_dict = exc.to_dict()
|
||||
assert exc_dict["code"] == code.value
|
||||
assert exc_dict["message"] == message
|
||||
assert exc_dict["details"] == details
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Property 5: Error Response Standardization
|
||||
# ============================================================================
|
||||
|
||||
class TestProperty5ErrorResponseStandardization:
|
||||
"""
|
||||
Property 5: 错误响应标准化
|
||||
|
||||
验证所有错误响应格式一致
|
||||
Validates: Requirements 3.3
|
||||
"""
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_business_exception(InvalidParameterException),
|
||||
create_business_exception(ResourceNotFoundException),
|
||||
create_business_exception(ProjectNotFoundException),
|
||||
create_business_exception(TaskNotFoundException),
|
||||
create_business_exception(ModelNotFoundException),
|
||||
create_business_exception(RateLimitExceededException),
|
||||
create_business_exception(UnauthorizedException),
|
||||
create_business_exception(ForbiddenException),
|
||||
create_business_exception(ConflictException),
|
||||
create_business_exception(AssetNotFoundException),
|
||||
create_business_exception(CanvasNotFoundException),
|
||||
create_business_exception(EpisodeNotFoundException),
|
||||
create_business_exception(StoryboardNotFoundException),
|
||||
create_business_exception(TaskCancelledException),
|
||||
create_business_exception(ModelNotAvailableException),
|
||||
create_business_exception(QuotaExceededException),
|
||||
create_business_exception(InvalidPromptException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
async def test_business_exceptions_produce_standard_response(self, exc):
|
||||
"""
|
||||
Property: All business exceptions should produce standardized JSON responses
|
||||
|
||||
For any business exception, the error handler should convert it to standard format
|
||||
"""
|
||||
# Create mock request
|
||||
app = FastAPI()
|
||||
request = Request(scope={
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/test",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
})
|
||||
request.state.request_id = "test_request_id"
|
||||
request.state.timestamp = "2024-01-01T00:00:00Z"
|
||||
|
||||
# Create mock call_next that raises the exception
|
||||
async def mock_call_next(req):
|
||||
raise exc
|
||||
|
||||
# Call error handler middleware
|
||||
response = await error_handler_middleware(request, mock_call_next)
|
||||
|
||||
# Verify response is JSONResponse
|
||||
assert isinstance(response, JSONResponse)
|
||||
|
||||
# Parse response content
|
||||
content = json.loads(response.body.decode())
|
||||
|
||||
# Verify standard format
|
||||
assert "code" in content
|
||||
assert "message" in content
|
||||
assert "details" in content
|
||||
assert "request_id" in content
|
||||
assert "timestamp" in content
|
||||
|
||||
# Verify values match exception
|
||||
assert content["code"] == (exc.code.value if isinstance(exc.code, ErrorCode) else exc.code)
|
||||
assert content["message"] == exc.message
|
||||
assert content["details"] == exc.details
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_system_exception(TaskTimeoutException),
|
||||
create_system_exception(TaskQueueFullException),
|
||||
create_system_exception(GenerationFailedException),
|
||||
create_system_exception(StorageException),
|
||||
create_system_exception(ProjectCreateFailedException),
|
||||
create_system_exception(ProjectUpdateFailedException),
|
||||
create_system_exception(ProjectDeleteFailedException),
|
||||
create_system_exception(TaskExecutionFailedException),
|
||||
create_system_exception(ProviderErrorException),
|
||||
create_system_exception(UploadFailedException),
|
||||
create_system_exception(DownloadFailedException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
async def test_system_exceptions_produce_standard_response(self, exc):
|
||||
"""
|
||||
Property: All system exceptions should produce standardized JSON responses
|
||||
|
||||
For any system exception, the error handler should convert it to standard format
|
||||
"""
|
||||
# Create mock request
|
||||
app = FastAPI()
|
||||
request = Request(scope={
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/test",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
})
|
||||
request.state.request_id = "test_request_id"
|
||||
request.state.timestamp = "2024-01-01T00:00:00Z"
|
||||
|
||||
# Create mock call_next that raises the exception
|
||||
async def mock_call_next(req):
|
||||
raise exc
|
||||
|
||||
# Call error handler middleware
|
||||
response = await error_handler_middleware(request, mock_call_next)
|
||||
|
||||
# Verify response is JSONResponse
|
||||
assert isinstance(response, JSONResponse)
|
||||
|
||||
# Parse response content
|
||||
content = json.loads(response.body.decode())
|
||||
|
||||
# Verify standard format
|
||||
assert "code" in content
|
||||
assert "message" in content
|
||||
assert "details" in content
|
||||
assert "request_id" in content
|
||||
assert "timestamp" in content
|
||||
|
||||
# Verify values match exception
|
||||
assert content["code"] == (exc.code.value if isinstance(exc.code, ErrorCode) else exc.code)
|
||||
assert content["message"] == exc.message
|
||||
assert content["details"] == exc.details
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Property 6: Error Log Completeness
|
||||
# ============================================================================
|
||||
|
||||
class TestProperty6ErrorLogCompleteness:
|
||||
"""
|
||||
Property 6: 错误日志完整性
|
||||
|
||||
验证错误日志包含必要信息
|
||||
Validates: Requirements 3.4
|
||||
"""
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_business_exception(InvalidParameterException),
|
||||
create_business_exception(ResourceNotFoundException),
|
||||
create_business_exception(ProjectNotFoundException),
|
||||
create_business_exception(TaskNotFoundException),
|
||||
create_business_exception(ModelNotFoundException),
|
||||
create_business_exception(RateLimitExceededException),
|
||||
create_business_exception(UnauthorizedException),
|
||||
create_business_exception(ForbiddenException),
|
||||
create_business_exception(ConflictException),
|
||||
create_business_exception(AssetNotFoundException),
|
||||
create_business_exception(CanvasNotFoundException),
|
||||
create_business_exception(EpisodeNotFoundException),
|
||||
create_business_exception(StoryboardNotFoundException),
|
||||
create_business_exception(TaskCancelledException),
|
||||
create_business_exception(ModelNotAvailableException),
|
||||
create_business_exception(QuotaExceededException),
|
||||
create_business_exception(InvalidPromptException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
async def test_business_exceptions_log_with_warning_level(self, exc):
|
||||
"""
|
||||
Property: Business exceptions should be logged with WARNING level
|
||||
|
||||
For any business exception, the log should use WARNING severity
|
||||
"""
|
||||
# Create mock request
|
||||
request = Request(scope={
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/test",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
})
|
||||
request.state.request_id = "test_request_id"
|
||||
request.state.timestamp = "2024-01-01T00:00:00Z"
|
||||
|
||||
# Create mock call_next that raises the exception
|
||||
async def mock_call_next(req):
|
||||
raise exc
|
||||
|
||||
# Mock logger to capture log calls
|
||||
with patch('src.middlewares.error_handler.logger') as mock_logger:
|
||||
# Call error handler middleware
|
||||
response = await error_handler_middleware(request, mock_call_next)
|
||||
|
||||
# Verify logger.log was called with WARNING level
|
||||
mock_logger.log.assert_called_once()
|
||||
call_args = mock_logger.log.call_args
|
||||
|
||||
# First argument should be WARNING level
|
||||
assert call_args[0][0] == logging.WARNING, \
|
||||
f"Business exception should log with WARNING level, got {call_args[0][0]}"
|
||||
|
||||
# Verify log includes necessary context
|
||||
extra = call_args[1].get('extra', {})
|
||||
assert 'request_id' in extra
|
||||
assert 'timestamp' in extra
|
||||
assert 'path' in extra
|
||||
assert 'method' in extra
|
||||
assert 'error_code' in extra
|
||||
assert 'details' in extra
|
||||
assert 'exception_type' in extra
|
||||
|
||||
@given(exc=st.one_of([
|
||||
create_system_exception(TaskTimeoutException),
|
||||
create_system_exception(TaskQueueFullException),
|
||||
create_system_exception(GenerationFailedException),
|
||||
create_system_exception(StorageException),
|
||||
create_system_exception(ProjectCreateFailedException),
|
||||
create_system_exception(ProjectUpdateFailedException),
|
||||
create_system_exception(ProjectDeleteFailedException),
|
||||
create_system_exception(TaskExecutionFailedException),
|
||||
create_system_exception(ProviderErrorException),
|
||||
create_system_exception(UploadFailedException),
|
||||
create_system_exception(DownloadFailedException),
|
||||
]))
|
||||
@settings(max_examples=50, deadline=None)
|
||||
async def test_system_exceptions_log_with_error_level(self, exc):
|
||||
"""
|
||||
Property: System exceptions should be logged with ERROR level
|
||||
|
||||
For any system exception, the log should use ERROR severity
|
||||
"""
|
||||
# Create mock request
|
||||
request = Request(scope={
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/test",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
})
|
||||
request.state.request_id = "test_request_id"
|
||||
request.state.timestamp = "2024-01-01T00:00:00Z"
|
||||
|
||||
# Create mock call_next that raises the exception
|
||||
async def mock_call_next(req):
|
||||
raise exc
|
||||
|
||||
# Mock logger to capture log calls
|
||||
with patch('src.middlewares.error_handler.logger') as mock_logger:
|
||||
# Call error handler middleware
|
||||
response = await error_handler_middleware(request, mock_call_next)
|
||||
|
||||
# Verify logger.log was called with ERROR level
|
||||
mock_logger.log.assert_called_once()
|
||||
call_args = mock_logger.log.call_args
|
||||
|
||||
# First argument should be ERROR level
|
||||
assert call_args[0][0] == logging.ERROR, \
|
||||
f"System exception should log with ERROR level, got {call_args[0][0]}"
|
||||
|
||||
# Verify log includes necessary context
|
||||
extra = call_args[1].get('extra', {})
|
||||
assert 'request_id' in extra
|
||||
assert 'timestamp' in extra
|
||||
assert 'path' in extra
|
||||
assert 'method' in extra
|
||||
assert 'error_code' in extra
|
||||
assert 'details' in extra
|
||||
assert 'exception_type' in extra
|
||||
|
||||
# Verify exc_info is True for system exceptions (includes stack trace)
|
||||
assert call_args[1].get('exc_info') == True, \
|
||||
"System exceptions should include stack trace (exc_info=True)"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Property 7: Error Response Structure Consistency
|
||||
# ============================================================================
|
||||
|
||||
class TestProperty7ErrorResponseStructureConsistency:
|
||||
"""
|
||||
Property 7: 错误响应结构一致性
|
||||
|
||||
验证错误响应JSON结构
|
||||
Validates: Requirements 3.5
|
||||
"""
|
||||
|
||||
@given(
|
||||
code=error_codes(),
|
||||
message=error_messages(),
|
||||
details=error_details()
|
||||
)
|
||||
@settings(max_examples=100, deadline=None)
|
||||
def test_error_response_has_consistent_structure(self, code, message, details):
|
||||
"""
|
||||
Property: All error responses should have consistent JSON structure
|
||||
|
||||
For any error code, message, and details, the response structure should be identical
|
||||
"""
|
||||
# Create ErrorResponse
|
||||
error_response = ErrorResponse(
|
||||
code=code.value,
|
||||
message=message,
|
||||
details=details,
|
||||
request_id="test_request_id",
|
||||
timestamp="2024-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
# Convert to dict
|
||||
response_dict = error_response.to_dict()
|
||||
|
||||
# Verify structure has exactly these keys
|
||||
expected_keys = {"code", "message", "details", "request_id", "timestamp"}
|
||||
assert set(response_dict.keys()) == expected_keys, \
|
||||
f"Response should have exactly {expected_keys}, got {set(response_dict.keys())}"
|
||||
|
||||
# Verify types
|
||||
assert isinstance(response_dict["code"], str)
|
||||
assert isinstance(response_dict["message"], str)
|
||||
assert isinstance(response_dict["details"], dict)
|
||||
assert isinstance(response_dict["request_id"], str)
|
||||
assert isinstance(response_dict["timestamp"], str)
|
||||
|
||||
# Verify values match input
|
||||
assert response_dict["code"] == code.value
|
||||
assert response_dict["message"] == message
|
||||
assert response_dict["details"] == details
|
||||
|
||||
@given(exc=st.one_of([
|
||||
# Business exceptions
|
||||
create_business_exception(InvalidParameterException),
|
||||
create_business_exception(ResourceNotFoundException),
|
||||
create_business_exception(ProjectNotFoundException),
|
||||
create_business_exception(TaskNotFoundException),
|
||||
create_business_exception(ModelNotFoundException),
|
||||
create_business_exception(RateLimitExceededException),
|
||||
create_business_exception(UnauthorizedException),
|
||||
create_business_exception(ForbiddenException),
|
||||
create_business_exception(ConflictException),
|
||||
create_business_exception(AssetNotFoundException),
|
||||
create_business_exception(CanvasNotFoundException),
|
||||
create_business_exception(EpisodeNotFoundException),
|
||||
create_business_exception(StoryboardNotFoundException),
|
||||
create_business_exception(TaskCancelledException),
|
||||
create_business_exception(ModelNotAvailableException),
|
||||
create_business_exception(QuotaExceededException),
|
||||
create_business_exception(InvalidPromptException),
|
||||
# System exceptions
|
||||
create_system_exception(TaskTimeoutException),
|
||||
create_system_exception(TaskQueueFullException),
|
||||
create_system_exception(GenerationFailedException),
|
||||
create_system_exception(StorageException),
|
||||
create_system_exception(ProjectCreateFailedException),
|
||||
create_system_exception(ProjectUpdateFailedException),
|
||||
create_system_exception(ProjectDeleteFailedException),
|
||||
create_system_exception(TaskExecutionFailedException),
|
||||
create_system_exception(ProviderErrorException),
|
||||
create_system_exception(UploadFailedException),
|
||||
create_system_exception(DownloadFailedException),
|
||||
]))
|
||||
@settings(max_examples=100, deadline=None)
|
||||
async def test_all_exceptions_produce_same_response_structure(self, exc):
|
||||
"""
|
||||
Property: All exception types should produce responses with identical structure
|
||||
|
||||
For any exception type, the response structure should be consistent
|
||||
"""
|
||||
# Create mock request
|
||||
request = Request(scope={
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"path": "/test",
|
||||
"query_string": b"",
|
||||
"headers": [],
|
||||
})
|
||||
request.state.request_id = "test_request_id"
|
||||
request.state.timestamp = "2024-01-01T00:00:00Z"
|
||||
|
||||
# Create mock call_next that raises the exception
|
||||
async def mock_call_next(req):
|
||||
raise exc
|
||||
|
||||
# Call error handler middleware
|
||||
response = await error_handler_middleware(request, mock_call_next)
|
||||
|
||||
# Parse response content
|
||||
content = json.loads(response.body.decode())
|
||||
|
||||
# Verify structure is consistent
|
||||
expected_keys = {"code", "message", "details", "request_id", "timestamp"}
|
||||
assert set(content.keys()) == expected_keys, \
|
||||
f"All responses should have structure {expected_keys}, got {set(content.keys())}"
|
||||
|
||||
# Verify types are consistent
|
||||
assert isinstance(content["code"], str)
|
||||
assert isinstance(content["message"], str)
|
||||
assert isinstance(content["details"], dict)
|
||||
assert isinstance(content["request_id"], str)
|
||||
assert isinstance(content["timestamp"], str)
|
||||
|
||||
# Verify timestamp is ISO format
|
||||
try:
|
||||
datetime.fromisoformat(content["timestamp"].replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pytest.fail(f"Timestamp should be ISO format, got {content['timestamp']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short"])
|
||||
Reference in New Issue
Block a user