""" 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"])