- 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()
271 lines
9.2 KiB
Python
271 lines
9.2 KiB
Python
"""
|
|
Tests for unified error handling system
|
|
|
|
Tests the exception hierarchy, error handler middleware, and error response format.
|
|
"""
|
|
import pytest
|
|
from datetime import datetime
|
|
from src.utils.errors import (
|
|
AppException,
|
|
BusinessException,
|
|
SystemException,
|
|
ErrorCode,
|
|
InvalidParameterException,
|
|
ResourceNotFoundException,
|
|
ProjectNotFoundException,
|
|
TaskNotFoundException,
|
|
TaskTimeoutException,
|
|
TaskQueueFullException,
|
|
ModelNotFoundException,
|
|
GenerationFailedException,
|
|
StorageException,
|
|
RateLimitExceededException,
|
|
UnauthorizedException,
|
|
ForbiddenException,
|
|
ConflictException
|
|
)
|
|
|
|
|
|
class TestExceptionHierarchy:
|
|
"""Test exception class hierarchy and initialization"""
|
|
|
|
def test_app_exception_base(self):
|
|
"""Test AppException base class"""
|
|
exc = AppException(
|
|
code=ErrorCode.UNKNOWN_ERROR,
|
|
message="Test error",
|
|
details={"key": "value"},
|
|
status_code=500
|
|
)
|
|
|
|
assert exc.code == ErrorCode.UNKNOWN_ERROR
|
|
assert exc.message == "Test error"
|
|
assert exc.details == {"key": "value"}
|
|
assert exc.status_code == 500
|
|
|
|
# Test to_dict conversion
|
|
exc_dict = exc.to_dict()
|
|
assert exc_dict["code"] == "1000"
|
|
assert exc_dict["message"] == "Test error"
|
|
assert exc_dict["details"] == {"key": "value"}
|
|
|
|
def test_business_exception(self):
|
|
"""Test BusinessException defaults to 400 status"""
|
|
exc = BusinessException(
|
|
code=ErrorCode.INVALID_PARAMETER,
|
|
message="Invalid input"
|
|
)
|
|
|
|
assert exc.status_code == 400
|
|
assert isinstance(exc, AppException)
|
|
|
|
def test_system_exception(self):
|
|
"""Test SystemException defaults to 500 status"""
|
|
exc = SystemException(
|
|
code=ErrorCode.UNKNOWN_ERROR,
|
|
message="System failure"
|
|
)
|
|
|
|
assert exc.status_code == 500
|
|
assert isinstance(exc, AppException)
|
|
|
|
|
|
class TestBusinessExceptions:
|
|
"""Test specific business exception classes"""
|
|
|
|
def test_invalid_parameter_exception(self):
|
|
"""Test InvalidParameterException"""
|
|
exc = InvalidParameterException(field="email", reason="Invalid format")
|
|
|
|
assert exc.code == ErrorCode.INVALID_PARAMETER
|
|
assert "email" in exc.message
|
|
assert exc.details["field"] == "email"
|
|
assert exc.details["reason"] == "Invalid format"
|
|
assert exc.status_code == 400
|
|
|
|
def test_resource_not_found_exception(self):
|
|
"""Test ResourceNotFoundException"""
|
|
exc = ResourceNotFoundException(resource_type="User", resource_id="123")
|
|
|
|
assert exc.code == ErrorCode.NOT_FOUND
|
|
assert "User" in exc.message
|
|
assert exc.details["resource_type"] == "User"
|
|
assert exc.details["resource_id"] == "123"
|
|
|
|
def test_project_not_found_exception(self):
|
|
"""Test ProjectNotFoundException"""
|
|
exc = ProjectNotFoundException(project_id="proj_123")
|
|
|
|
assert exc.code == ErrorCode.PROJECT_NOT_FOUND
|
|
assert exc.details["project_id"] == "proj_123"
|
|
assert isinstance(exc, BusinessException)
|
|
|
|
def test_task_not_found_exception(self):
|
|
"""Test TaskNotFoundException"""
|
|
exc = TaskNotFoundException(task_id="task_123")
|
|
|
|
assert exc.code == ErrorCode.TASK_NOT_FOUND
|
|
assert exc.details["task_id"] == "task_123"
|
|
|
|
def test_model_not_found_exception(self):
|
|
"""Test ModelNotFoundException"""
|
|
exc = ModelNotFoundException(model_id="flux-pro")
|
|
|
|
assert exc.code == ErrorCode.MODEL_NOT_FOUND
|
|
assert exc.details["model_id"] == "flux-pro"
|
|
|
|
def test_rate_limit_exceeded_exception(self):
|
|
"""Test RateLimitExceededException"""
|
|
exc = RateLimitExceededException(limit=100, window=60)
|
|
|
|
assert exc.code == ErrorCode.RATE_LIMIT_EXCEEDED
|
|
assert exc.status_code == 429
|
|
assert exc.details["limit"] == 100
|
|
assert exc.details["window_seconds"] == 60
|
|
|
|
def test_unauthorized_exception(self):
|
|
"""Test UnauthorizedException"""
|
|
exc = UnauthorizedException(reason="Invalid token")
|
|
|
|
assert exc.code == ErrorCode.UNAUTHORIZED
|
|
assert exc.status_code == 401
|
|
assert exc.details["reason"] == "Invalid token"
|
|
|
|
def test_forbidden_exception(self):
|
|
"""Test ForbiddenException"""
|
|
exc = ForbiddenException(reason="Insufficient permissions")
|
|
|
|
assert exc.code == ErrorCode.FORBIDDEN
|
|
assert exc.status_code == 403
|
|
|
|
def test_conflict_exception(self):
|
|
"""Test ConflictException"""
|
|
exc = ConflictException(resource_type="Project", reason="Name already exists")
|
|
|
|
assert exc.code == ErrorCode.CONFLICT
|
|
assert exc.status_code == 409
|
|
|
|
|
|
class TestSystemExceptions:
|
|
"""Test specific system exception classes"""
|
|
|
|
def test_task_timeout_exception(self):
|
|
"""Test TaskTimeoutException"""
|
|
exc = TaskTimeoutException(task_id="task_123", timeout=300)
|
|
|
|
assert exc.code == ErrorCode.TASK_TIMEOUT
|
|
assert exc.status_code == 500
|
|
assert exc.details["task_id"] == "task_123"
|
|
assert exc.details["timeout_seconds"] == 300
|
|
assert isinstance(exc, SystemException)
|
|
|
|
def test_task_queue_full_exception(self):
|
|
"""Test TaskQueueFullException"""
|
|
exc = TaskQueueFullException(queue_size=1000)
|
|
|
|
assert exc.code == ErrorCode.TASK_QUEUE_FULL
|
|
assert exc.status_code == 500
|
|
assert exc.details["queue_size"] == 1000
|
|
|
|
def test_generation_failed_exception(self):
|
|
"""Test GenerationFailedException"""
|
|
exc = GenerationFailedException(reason="API error", provider="dashscope")
|
|
|
|
assert exc.code == ErrorCode.GENERATION_FAILED
|
|
assert exc.status_code == 500
|
|
assert exc.details["reason"] == "API error"
|
|
assert exc.details["provider"] == "dashscope"
|
|
|
|
def test_storage_exception(self):
|
|
"""Test StorageException"""
|
|
exc = StorageException(operation="upload", reason="Disk full")
|
|
|
|
assert exc.code == ErrorCode.STORAGE_ERROR
|
|
assert exc.status_code == 500
|
|
assert exc.details["operation"] == "upload"
|
|
assert exc.details["reason"] == "Disk full"
|
|
|
|
|
|
class TestErrorCodes:
|
|
"""Test error code enumeration"""
|
|
|
|
def test_error_code_values(self):
|
|
"""Test error code values follow the format"""
|
|
# Success
|
|
assert ErrorCode.SUCCESS.value == "0000"
|
|
|
|
# General errors (1xxx)
|
|
assert ErrorCode.UNKNOWN_ERROR.value == "1000"
|
|
assert ErrorCode.INVALID_PARAMETER.value == "1001"
|
|
assert ErrorCode.NOT_FOUND.value == "1004"
|
|
|
|
# Business errors (2xxx)
|
|
assert ErrorCode.PROJECT_NOT_FOUND.value == "2001"
|
|
assert ErrorCode.ASSET_NOT_FOUND.value == "2011"
|
|
|
|
# Task errors (3xxx)
|
|
assert ErrorCode.TASK_NOT_FOUND.value == "3002"
|
|
assert ErrorCode.TASK_TIMEOUT.value == "3003"
|
|
|
|
# AI service errors (4xxx)
|
|
assert ErrorCode.MODEL_NOT_FOUND.value == "4001"
|
|
assert ErrorCode.GENERATION_FAILED.value == "4003"
|
|
|
|
# Storage errors (5xxx)
|
|
assert ErrorCode.STORAGE_ERROR.value == "5001"
|
|
assert ErrorCode.FILE_NOT_FOUND.value == "5002"
|
|
|
|
def test_error_code_categories(self):
|
|
"""Test error codes are properly categorized"""
|
|
# All general errors start with 1
|
|
assert ErrorCode.UNKNOWN_ERROR.value.startswith("1")
|
|
assert ErrorCode.INVALID_PARAMETER.value.startswith("1")
|
|
|
|
# All business errors start with 2
|
|
assert ErrorCode.PROJECT_NOT_FOUND.value.startswith("2")
|
|
|
|
# All task errors start with 3
|
|
assert ErrorCode.TASK_TIMEOUT.value.startswith("3")
|
|
|
|
# All AI service errors start with 4
|
|
assert ErrorCode.MODEL_NOT_FOUND.value.startswith("4")
|
|
|
|
# All storage errors start with 5
|
|
assert ErrorCode.STORAGE_ERROR.value.startswith("5")
|
|
|
|
|
|
class TestExceptionToDictConversion:
|
|
"""Test exception to dictionary conversion for API responses"""
|
|
|
|
def test_simple_exception_to_dict(self):
|
|
"""Test basic exception to dict conversion"""
|
|
exc = ProjectNotFoundException(project_id="proj_123")
|
|
exc_dict = exc.to_dict()
|
|
|
|
assert "code" in exc_dict
|
|
assert "message" in exc_dict
|
|
assert "details" in exc_dict
|
|
assert exc_dict["code"] == "2001"
|
|
assert exc_dict["details"]["project_id"] == "proj_123"
|
|
|
|
def test_exception_with_complex_details(self):
|
|
"""Test exception with nested details"""
|
|
exc = AppException(
|
|
code=ErrorCode.INVALID_PARAMETER,
|
|
message="Validation failed",
|
|
details={
|
|
"errors": [
|
|
{"field": "email", "message": "Invalid format"},
|
|
{"field": "age", "message": "Must be positive"}
|
|
]
|
|
}
|
|
)
|
|
|
|
exc_dict = exc.to_dict()
|
|
assert len(exc_dict["details"]["errors"]) == 2
|
|
assert exc_dict["details"]["errors"][0]["field"] == "email"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v"])
|