diff --git a/backend/apps/app_factory.py b/backend/apps/app_factory.py new file mode 100644 index 000000000..219da5b82 --- /dev/null +++ b/backend/apps/app_factory.py @@ -0,0 +1,108 @@ +""" +FastAPI application factory with common configurations and exception handlers. +""" +import logging + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from consts.exceptions import AppException + + +logger = logging.getLogger(__name__) + + +def create_app( + title: str = "Nexent API", + description: str = "", + version: str = "1.0.0", + root_path: str = "/api", + cors_origins: list = None, + cors_methods: list = None, + enable_monitoring: bool = True, +) -> FastAPI: + """ + Create a FastAPI application with common configurations. + + Args: + title: API title + description: API description + version: API version + root_path: Root path for the API + cors_origins: List of allowed CORS origins (default: ["*"]) + cors_methods: List of allowed CORS methods (default: ["*"]) + enable_monitoring: Whether to enable monitoring + + Returns: + Configured FastAPI application + """ + app = FastAPI( + title=title, + description=description, + version=version, + root_path=root_path + ) + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins or ["*"], + allow_credentials=True, + allow_methods=cors_methods or ["*"], + allow_headers=["*"], + ) + + # Register exception handlers + register_exception_handlers(app) + + # Initialize monitoring if enabled + if enable_monitoring: + try: + from utils.monitoring import monitoring_manager + monitoring_manager.setup_fastapi_app(app) + except ImportError: + logger.warning("Monitoring utilities not available") + + return app + + +def register_exception_handlers(app: FastAPI) -> None: + """ + Register common exception handlers for the FastAPI application. + + Args: + app: FastAPI application instance + """ + + @app.exception_handler(HTTPException) + async def http_exception_handler(request, exc): + logger.error(f"HTTPException: {exc.detail}") + return JSONResponse( + status_code=exc.status_code, + content={"message": exc.detail}, + ) + + @app.exception_handler(AppException) + async def app_exception_handler(request, exc): + logger.error(f"AppException: {exc.error_code.value} - {exc.message}") + return JSONResponse( + status_code=exc.http_status, + content={ + "code": exc.error_code.value, + "message": exc.message, + "details": exc.details if exc.details else None + }, + ) + + @app.exception_handler(Exception) + async def generic_exception_handler(request, exc): + # Don't catch AppException - it has its own handler + if isinstance(exc, AppException): + return await app_exception_handler(request, exc) + + logger.error(f"Generic Exception: {exc}") + return JSONResponse( + status_code=500, + content={"message": "Internal server error, please try again later."}, + ) diff --git a/backend/apps/config_app.py b/backend/apps/config_app.py index 8691b15e0..fb6a0a4f0 100644 --- a/backend/apps/config_app.py +++ b/backend/apps/config_app.py @@ -1,9 +1,6 @@ import logging -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - +from apps.app_factory import create_app from apps.agent_app import agent_config_router as agent_router from apps.config_sync_app import router as config_sync_router from apps.datamate_app import router as datamate_router @@ -26,21 +23,11 @@ from apps.invitation_app import router as invitation_router from consts.const import IS_SPEED_MODE -# Import monitoring utilities -from utils.monitoring import monitoring_manager - # Create logger instance logger = logging.getLogger("base_app") -app = FastAPI(root_path="/api") -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers -) +# Create FastAPI app with common configurations +app = create_app(title="Nexent Config API", description="Configuration APIs") app.include_router(model_manager_router) app.include_router(config_sync_router) @@ -69,26 +56,3 @@ app.include_router(group_router) app.include_router(user_router) app.include_router(invitation_router) - -# Initialize monitoring for the application -monitoring_manager.setup_fastapi_app(app) - - -# Global exception handler for HTTP exceptions -@app.exception_handler(HTTPException) -async def http_exception_handler(request, exc): - logger.error(f"HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -# Global exception handler for all uncaught exceptions -@app.exception_handler(Exception) -async def generic_exception_handler(request, exc): - logger.error(f"Generic Exception: {exc}") - return JSONResponse( - status_code=500, - content={"message": "Internal server error, please try again later."}, - ) diff --git a/backend/apps/dify_app.py b/backend/apps/dify_app.py index c7d9321af..99ce8d86e 100644 --- a/backend/apps/dify_app.py +++ b/backend/apps/dify_app.py @@ -13,6 +13,8 @@ from fastapi import APIRouter, Header, HTTPException, Query from fastapi.responses import JSONResponse +from consts.error_code import ErrorCode +from consts.exceptions import AppException from services.dify_service import fetch_dify_datasets_impl from utils.auth_utils import get_current_user_id @@ -35,18 +37,10 @@ async def fetch_dify_datasets_api( # Normalize URL by removing trailing slash dify_api_base = dify_api_base.rstrip('/') except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + logger.error(f"Invalid Dify configuration: {e}") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + f"Invalid URL format: {str(e)}") - try: - _, tenant_id = get_current_user_id(authorization) - except Exception as e: - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) try: result = fetch_dify_datasets_impl( @@ -57,15 +51,10 @@ async def fetch_dify_datasets_api( status_code=HTTPStatus.OK, content=result ) - except ValueError as e: - logger.warning(f"Invalid Dify configuration: {e}") - raise HTTPException( - status_code=HTTPStatus.BAD_REQUEST, - detail=str(e) - ) + except AppException: + # Re-raise AppException to be handled by global middleware + raise except Exception as e: logger.error(f"Failed to fetch Dify datasets: {e}") - raise HTTPException( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - detail=f"Failed to fetch Dify datasets: {str(e)}" - ) + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Failed to fetch Dify datasets: {str(e)}") diff --git a/backend/apps/northbound_base_app.py b/backend/apps/northbound_base_app.py index ff51e0e71..00e9f0462 100644 --- a/backend/apps/northbound_base_app.py +++ b/backend/apps/northbound_base_app.py @@ -1,47 +1,17 @@ -from http import HTTPStatus import logging -from fastapi import FastAPI, HTTPException -from fastapi.responses import JSONResponse -from fastapi.middleware.cors import CORSMiddleware +from apps.app_factory import create_app from .northbound_app import router as northbound_router -from consts.exceptions import LimitExceededError, UnauthorizedError, SignatureValidationError logger = logging.getLogger("northbound_base_app") - -northbound_app = FastAPI( +# Create FastAPI app with common configurations +northbound_app = create_app( title="Nexent Northbound API", description="Northbound APIs for partners", version="1.0.0", - root_path="/api" -) - -northbound_app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE"], - allow_headers=["*"], + cors_methods=["GET", "POST", "PUT", "DELETE"], + enable_monitoring=False # Disable monitoring for northbound API if not needed ) - northbound_app.include_router(northbound_router) - - -@northbound_app.exception_handler(HTTPException) -async def northbound_http_exception_handler(request, exc): - logger.error(f"Northbound HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -@northbound_app.exception_handler(Exception) -async def northbound_generic_exception_handler(request, exc): - logger.error(f"Northbound Generic Exception: {exc}") - return JSONResponse( - status_code=HTTPStatus.INTERNAL_SERVER_ERROR, - content={"message": "Internal server error, please try again later."}, - ) diff --git a/backend/apps/runtime_app.py b/backend/apps/runtime_app.py index 6db480ab6..7420a14a2 100644 --- a/backend/apps/runtime_app.py +++ b/backend/apps/runtime_app.py @@ -1,58 +1,24 @@ import logging -from fastapi import FastAPI, HTTPException -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse - +from apps.app_factory import create_app from apps.agent_app import agent_runtime_router as agent_router from apps.voice_app import voice_runtime_router as voice_router from apps.conversation_management_app import router as conversation_management_router from apps.memory_config_app import router as memory_config_router from apps.file_management_app import file_management_runtime_router as file_management_router - -# Import monitoring utilities -from utils.monitoring import monitoring_manager +from middleware.exception_handler import ExceptionHandlerMiddleware # Create logger instance logger = logging.getLogger("runtime_app") -app = FastAPI(root_path="/api") -# Add CORS middleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +# Create FastAPI app with common configurations +app = create_app(title="Nexent Runtime API", description="Runtime APIs") + +# Add global exception handler middleware +app.add_middleware(ExceptionHandlerMiddleware) app.include_router(agent_router) app.include_router(conversation_management_router) app.include_router(memory_config_router) app.include_router(file_management_router) app.include_router(voice_router) - -# Initialize monitoring for the application -monitoring_manager.setup_fastapi_app(app) - - -# Global exception handler for HTTP exceptions -@app.exception_handler(HTTPException) -async def http_exception_handler(request, exc): - logger.error(f"HTTPException: {exc.detail}") - return JSONResponse( - status_code=exc.status_code, - content={"message": exc.detail}, - ) - - -# Global exception handler for all uncaught exceptions -@app.exception_handler(Exception) -async def generic_exception_handler(request, exc): - logger.error(f"Generic Exception: {exc}") - return JSONResponse( - status_code=500, - content={"message": "Internal server error, please try again later."}, - ) - - diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py new file mode 100644 index 000000000..73decbbf3 --- /dev/null +++ b/backend/consts/error_code.py @@ -0,0 +1,219 @@ +""" +Error code definitions for the application. + +Format: XXYYZZ (6 digits, string) +- XX: Module code (01-99, based on sidebar) + 00: Common / 公共 - cross-module common errors + 01: Chat / 开始问答 + 02: QuickConfig / 快速配置 + 03: AgentSpace / 智能体空间 + 04: AgentMarket / 智能体市场 + 05: AgentDev / 智能体开发 + 06: Knowledge / 知识库 + 07: MCPTools / MCP 工具 + 08: MonitorOps / 监控与运维 + 09: Model / 模型管理 + 10: Memory / 记忆管理 + 11: Profile / 个人信息 + 12: TenantResource / 租户资源 + 13: External / 外部服务 (DataMate, Dify) + 15: Northbound / 北向接口 + 17: DataProcess / 数据处理 + 99: System / 系统级 - system internal errors +- YY: Sub module category (01-99) +- ZZ: Sequence in category (01-99) +""" + +from enum import Enum + + +class ErrorCode(Enum): + """Business error codes (stored as strings to preserve leading zeros).""" + + # ==================== 00 Common / 公共 ==================== + # 01 - Parameter & Validation + COMMON_VALIDATION_ERROR = "000101" # Validation error + COMMON_PARAMETER_INVALID = "000102" # Invalid parameter + COMMON_MISSING_REQUIRED_FIELD = "000103" # Missing required field + + # 02 - Auth & Permission + COMMON_UNAUTHORIZED = "000201" # Not logged in / unauthenticated + COMMON_FORBIDDEN = "000202" # No permission + COMMON_TOKEN_EXPIRED = "000203" # Token expired + COMMON_TOKEN_INVALID = "000204" # Invalid token + + # 03 - External Service + COMMON_EXTERNAL_SERVICE_ERROR = "000301" # External service error + COMMON_RATE_LIMIT_EXCEEDED = "000302" # Rate limit exceeded + + # 04 - File + FILE_NOT_FOUND = "000401" # File not found + FILE_UPLOAD_FAILED = "000402" # File upload failed + FILE_TOO_LARGE = "000403" # File too large + FILE_TYPE_NOT_ALLOWED = "000404" # File type not allowed + FILE_PREPROCESS_FAILED = "000405" # File preprocess failed + + # 05 - Resource + COMMON_RESOURCE_NOT_FOUND = "000501" # Resource not found + COMMON_RESOURCE_ALREADY_EXISTS = "000502" # Resource already exists + COMMON_RESOURCE_DISABLED = "000503" # Resource disabled + + # ==================== 01 Chat / 开始问答 ==================== + # 01 - Conversation + CHAT_CONVERSATION_NOT_FOUND = "010101" # Conversation not found + CHAT_MESSAGE_NOT_FOUND = "010102" # Message not found + CHAT_CONVERSATION_SAVE_FAILED = "010103" # Failed to save conversation + CHAT_TITLE_GENERATION_FAILED = "010104" # Failed to generate title + + # ==================== 02 QuickConfig / 快速配置 ==================== + # 01 - Configuration + QUICK_CONFIG_INVALID = "020101" # Invalid configuration + QUICK_CONFIG_SYNC_FAILED = "020102" # Sync configuration failed + + # ==================== 03 AgentSpace / 智能体空间 ==================== + # 01 - Agent + AGENTSPACE_AGENT_NOT_FOUND = "030101" # Agent not found + AGENTSPACE_AGENT_DISABLED = "030102" # Agent disabled + AGENTSPACE_AGENT_RUN_FAILED = "030103" # Agent run failed + AGENTSPACE_AGENT_NAME_DUPLICATE = "030104" # Duplicate agent name + AGENTSPACE_VERSION_NOT_FOUND = "030105" # Agent version not found + + # ==================== 04 AgentMarket / 智能体市场 ==================== + # 01 - Agent + AGENTMARKET_AGENT_NOT_FOUND = "040101" # Agent not found in market + + # ==================== 05 AgentDev / 智能体开发 ==================== + # 01 - Configuration + AGENTDEV_CONFIG_INVALID = "050101" # Invalid agent configuration + AGENTDEV_PROMPT_INVALID = "050102" # Invalid prompt + + # ==================== 06 Knowledge / 知识库 ==================== + # 01 - Knowledge Base + KNOWLEDGE_NOT_FOUND = "060101" # Knowledge not found + KNOWLEDGE_UPLOAD_FAILED = "060102" # Upload failed + KNOWLEDGE_SYNC_FAILED = "060103" # Sync failed + KNOWLEDGE_INDEX_NOT_FOUND = "060104" # Index not found + KNOWLEDGE_SEARCH_FAILED = "060105" # Search failed + + # ==================== 07 MCPTools / MCP 工具 ==================== + # 01 - Tool + MCP_TOOL_NOT_FOUND = "070101" # Tool not found + MCP_TOOL_EXECUTION_FAILED = "070102" # Tool execution failed + MCP_TOOL_CONFIG_INVALID = "070103" # Invalid tool configuration + + # 02 - Connection + MCP_CONNECTION_FAILED = "070201" # MCP connection failed + MCP_CONTAINER_ERROR = "070202" # MCP container error + + # 03 - Configuration + MCP_NAME_ILLEGAL = "070301" # Illegal MCP name + + # ==================== 08 MonitorOps / 监控与运维 ==================== + # 01 - Monitoring + MONITOROPS_METRIC_QUERY_FAILED = "080101" # Metric query failed + + # 02 - Alert + MONITOROPS_ALERT_CONFIG_INVALID = "080201" # Invalid alert configuration + + # ==================== 09 Model / 模型管理 ==================== + # 01 - Model + MODEL_NOT_FOUND = "090101" # Model not found + MODEL_CONFIG_INVALID = "090102" # Invalid model configuration + MODEL_HEALTH_CHECK_FAILED = "090103" # Health check failed + MODEL_PROVIDER_ERROR = "090104" # Model provider error + + # ==================== 10 Memory / 记忆管理 ==================== + # 01 - Memory + MEMORY_NOT_FOUND = "100101" # Memory not found + MEMORY_PREPARATION_FAILED = "100102" # Memory preparation failed + MEMORY_CONFIG_INVALID = "100103" # Invalid memory configuration + + # ==================== 11 Profile / 个人信息 ==================== + # 01 - User + PROFILE_USER_NOT_FOUND = "110101" # User not found + PROFILE_UPDATE_FAILED = "110102" # Profile update failed + PROFILE_USER_ALREADY_EXISTS = "110103" # User already exists + PROFILE_INVALID_CREDENTIALS = "110104" # Invalid credentials + + # ==================== 12 TenantResource / 租户资源 ==================== + # 01 - Tenant + TENANT_NOT_FOUND = "120101" # Tenant not found + TENANT_DISABLED = "120102" # Tenant disabled + TENANT_CONFIG_ERROR = "120103" # Tenant configuration error + TENANT_RESOURCE_EXCEEDED = "120104" # Tenant resource exceeded + + # ==================== 13 External / 外部服务 ==================== + # 01 - DataMate + DATAMATE_CONNECTION_FAILED = "130101" # DataMate connection failed + + # 02 - Dify + DIFY_SERVICE_ERROR = "130201" # Dify service error + DIFY_CONFIG_INVALID = "130202" # Invalid Dify configuration + DIFY_CONNECTION_ERROR = "130203" # Dify connection error + DIFY_AUTH_ERROR = "130204" # Dify auth error + DIFY_RATE_LIMIT = "130205" # Dify rate limit + DIFY_RESPONSE_ERROR = "130206" # Dify response error + + # 03 - ME Service + ME_CONNECTION_FAILED = "130301" # ME service connection failed + + # ==================== 14 Northbound / 北向接口 ==================== + # 01 - Request + NORTHBOUND_REQUEST_FAILED = "140101" # Northbound request failed + + # 02 - Configuration + NORTHBOUND_CONFIG_INVALID = "140201" # Invalid northbound configuration + + # ==================== 15 DataProcess / 数据处理 ==================== + # 01 - Task + DATAPROCESS_TASK_FAILED = "150101" # Data process task failed + DATAPROCESS_PARSE_FAILED = "150102" # Data parse failed + + # ==================== 99 System / 系统级 ==================== + # 01 - System Errors + SYSTEM_UNKNOWN_ERROR = "990101" # Unknown error + SYSTEM_SERVICE_UNAVAILABLE = "990102" # Service unavailable + SYSTEM_DATABASE_ERROR = "990103" # Database error + SYSTEM_TIMEOUT = "990104" # Timeout + SYSTEM_INTERNAL_ERROR = "990105" # Internal error + + # 02 - Config + CONFIG_NOT_FOUND = "990201" # Configuration not found + CONFIG_UPDATE_FAILED = "990202" # Configuration update failed + + +# HTTP status code mapping +ERROR_CODE_HTTP_STATUS = { + # Common - Auth + ErrorCode.COMMON_UNAUTHORIZED: 401, + ErrorCode.COMMON_TOKEN_EXPIRED: 401, + ErrorCode.COMMON_TOKEN_INVALID: 401, + ErrorCode.COMMON_FORBIDDEN: 403, + # Common - Validation + ErrorCode.COMMON_VALIDATION_ERROR: 400, + ErrorCode.COMMON_PARAMETER_INVALID: 400, + ErrorCode.COMMON_MISSING_REQUIRED_FIELD: 400, + # Common - Rate Limit + ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: 429, + # Common - Resource + ErrorCode.COMMON_RESOURCE_NOT_FOUND: 404, + ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: 409, + ErrorCode.COMMON_RESOURCE_DISABLED: 403, + # Common - File + ErrorCode.FILE_NOT_FOUND: 404, + ErrorCode.FILE_UPLOAD_FAILED: 500, + ErrorCode.FILE_TOO_LARGE: 413, + ErrorCode.FILE_TYPE_NOT_ALLOWED: 400, + ErrorCode.FILE_PREPROCESS_FAILED: 500, + # System + ErrorCode.SYSTEM_SERVICE_UNAVAILABLE: 503, + ErrorCode.SYSTEM_TIMEOUT: 504, + ErrorCode.SYSTEM_DATABASE_ERROR: 500, + ErrorCode.SYSTEM_INTERNAL_ERROR: 500, + # Dify (module 13) + ErrorCode.DIFY_CONFIG_INVALID: 400, + ErrorCode.DIFY_AUTH_ERROR: 401, + ErrorCode.DIFY_CONNECTION_ERROR: 502, + ErrorCode.DIFY_RESPONSE_ERROR: 502, + ErrorCode.DIFY_RATE_LIMIT: 429, +} diff --git a/backend/consts/error_message.py b/backend/consts/error_message.py new file mode 100644 index 000000000..aa7bf45e3 --- /dev/null +++ b/backend/consts/error_message.py @@ -0,0 +1,148 @@ +""" +Error message mappings for error codes. + +This module provides default English error messages. +Frontend should use i18n for localized messages. +""" + +from .error_code import ErrorCode + + +class ErrorMessage: + """Error code to message mapping.""" + + _MESSAGES = { + # ==================== 00 Common / 公共 ==================== + # 00 - Parameter & Validation + ErrorCode.COMMON_VALIDATION_ERROR: "Validation failed.", + ErrorCode.COMMON_PARAMETER_INVALID: "Invalid parameter.", + ErrorCode.COMMON_MISSING_REQUIRED_FIELD: "Required field is missing.", + # 01 - Auth & Permission + ErrorCode.COMMON_UNAUTHORIZED: "You are not authorized to perform this action.", + ErrorCode.COMMON_FORBIDDEN: "Access forbidden.", + ErrorCode.COMMON_TOKEN_EXPIRED: "Your session has expired. Please login again.", + ErrorCode.COMMON_TOKEN_INVALID: "Invalid token. Please login again.", + # 02 - External Service + ErrorCode.COMMON_EXTERNAL_SERVICE_ERROR: "External service error.", + ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: "Too many requests. Please try again later.", + # 03 - File + ErrorCode.FILE_NOT_FOUND: "File not found.", + ErrorCode.FILE_UPLOAD_FAILED: "Failed to upload file.", + ErrorCode.FILE_TOO_LARGE: "File size exceeds limit.", + ErrorCode.FILE_TYPE_NOT_ALLOWED: "File type not allowed.", + ErrorCode.FILE_PREPROCESS_FAILED: "File preprocessing failed.", + # 04 - Resource + ErrorCode.COMMON_RESOURCE_NOT_FOUND: "Resource not found.", + ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: "Resource already exists.", + ErrorCode.COMMON_RESOURCE_DISABLED: "Resource is disabled.", + + # ==================== 01 Chat / 开始问答 ==================== + ErrorCode.CHAT_CONVERSATION_NOT_FOUND: "Conversation not found.", + ErrorCode.CHAT_MESSAGE_NOT_FOUND: "Message not found.", + ErrorCode.CHAT_CONVERSATION_SAVE_FAILED: "Failed to save conversation.", + ErrorCode.CHAT_TITLE_GENERATION_FAILED: "Failed to generate conversation title.", + + # ==================== 02 QuickConfig / 快速配置 ==================== + ErrorCode.QUICK_CONFIG_INVALID: "Invalid configuration.", + ErrorCode.QUICK_CONFIG_SYNC_FAILED: "Sync configuration failed.", + + # ==================== 03 AgentSpace / 智能体空间 ==================== + ErrorCode.AGENTSPACE_AGENT_NOT_FOUND: "Agent not found.", + ErrorCode.AGENTSPACE_AGENT_DISABLED: "Agent is disabled.", + ErrorCode.AGENTSPACE_AGENT_RUN_FAILED: "Failed to run agent. Please try again later.", + ErrorCode.AGENTSPACE_AGENT_NAME_DUPLICATE: "Agent name already exists.", + ErrorCode.AGENTSPACE_VERSION_NOT_FOUND: "Agent version not found.", + + # ==================== 04 AgentMarket / 智能体市场 ==================== + ErrorCode.AGENTMARKET_AGENT_NOT_FOUND: "Agent not found in market.", + + # ==================== 05 AgentDev / 智能体开发 ==================== + ErrorCode.AGENTDEV_CONFIG_INVALID: "Invalid agent configuration.", + ErrorCode.AGENTDEV_PROMPT_INVALID: "Invalid prompt.", + + # ==================== 06 Knowledge / 知识库 ==================== + ErrorCode.KNOWLEDGE_NOT_FOUND: "Knowledge base not found.", + ErrorCode.KNOWLEDGE_UPLOAD_FAILED: "Failed to upload knowledge.", + ErrorCode.KNOWLEDGE_SYNC_FAILED: "Failed to sync knowledge base.", + ErrorCode.KNOWLEDGE_INDEX_NOT_FOUND: "Search index not found.", + ErrorCode.KNOWLEDGE_SEARCH_FAILED: "Knowledge search failed.", + + # ==================== 07 MCPTools / MCP 工具 ==================== + ErrorCode.MCP_TOOL_NOT_FOUND: "Tool not found.", + ErrorCode.MCP_TOOL_EXECUTION_FAILED: "Tool execution failed.", + ErrorCode.MCP_TOOL_CONFIG_INVALID: "Tool configuration is invalid.", + ErrorCode.MCP_CONNECTION_FAILED: "Failed to connect to MCP service.", + ErrorCode.MCP_CONTAINER_ERROR: "MCP container operation failed.", + ErrorCode.MCP_NAME_ILLEGAL: "MCP name contains invalid characters.", + + # ==================== 08 MonitorOps / 监控与运维 ==================== + ErrorCode.MONITOROPS_METRIC_QUERY_FAILED: "Metric query failed.", + ErrorCode.MONITOROPS_ALERT_CONFIG_INVALID: "Invalid alert configuration.", + + # ==================== 09 Model / 模型管理 ==================== + ErrorCode.MODEL_NOT_FOUND: "Model not found.", + ErrorCode.MODEL_CONFIG_INVALID: "Model configuration is invalid.", + ErrorCode.MODEL_HEALTH_CHECK_FAILED: "Model health check failed.", + ErrorCode.MODEL_PROVIDER_ERROR: "Model provider error.", + + # ==================== 10 Memory / 记忆管理 ==================== + ErrorCode.MEMORY_NOT_FOUND: "Memory not found.", + ErrorCode.MEMORY_PREPARATION_FAILED: "Failed to prepare memory.", + ErrorCode.MEMORY_CONFIG_INVALID: "Memory configuration is invalid.", + + # ==================== 11 Profile / 个人信息 ==================== + ErrorCode.PROFILE_USER_NOT_FOUND: "User not found.", + ErrorCode.PROFILE_UPDATE_FAILED: "Profile update failed.", + ErrorCode.PROFILE_USER_ALREADY_EXISTS: "User already exists.", + ErrorCode.PROFILE_INVALID_CREDENTIALS: "Invalid username or password.", + + # ==================== 12 TenantResource / 租户资源 ==================== + ErrorCode.TENANT_NOT_FOUND: "Tenant not found.", + ErrorCode.TENANT_DISABLED: "Tenant is disabled.", + ErrorCode.TENANT_CONFIG_ERROR: "Tenant configuration error.", + ErrorCode.TENANT_RESOURCE_EXCEEDED: "Tenant resource exceeded.", + + # ==================== 13 External / 外部服务 ==================== + ErrorCode.DATAMATE_CONNECTION_FAILED: "Failed to connect to DataMate service.", + ErrorCode.DIFY_SERVICE_ERROR: "Dify service error.", + ErrorCode.DIFY_CONFIG_INVALID: "Dify configuration invalid. Please check URL and API key format.", + ErrorCode.DIFY_CONNECTION_ERROR: "Failed to connect to Dify. Please check network connection and URL.", + ErrorCode.DIFY_RESPONSE_ERROR: "Failed to parse Dify response. Please check API URL.", + ErrorCode.DIFY_AUTH_ERROR: "Dify authentication failed. Please check your API key.", + ErrorCode.DIFY_RATE_LIMIT: "Dify API rate limit exceeded. Please try again later.", + ErrorCode.ME_CONNECTION_FAILED: "Failed to connect to ME service.", + + # ==================== 14 Northbound / 北向接口 ==================== + ErrorCode.NORTHBOUND_REQUEST_FAILED: "Northbound request failed.", + ErrorCode.NORTHBOUND_CONFIG_INVALID: "Invalid northbound configuration.", + + # ==================== 15 DataProcess / 数据处理 ==================== + ErrorCode.DATAPROCESS_TASK_FAILED: "Data process task failed.", + ErrorCode.DATAPROCESS_PARSE_FAILED: "Data parsing failed.", + + # ==================== 99 System / 系统级 ==================== + # 01 - System Errors + ErrorCode.SYSTEM_UNKNOWN_ERROR: "An unknown error occurred. Please try again later.", + ErrorCode.SYSTEM_SERVICE_UNAVAILABLE: "Service is temporarily unavailable. Please try again later.", + ErrorCode.SYSTEM_DATABASE_ERROR: "Database operation failed. Please try again later.", + ErrorCode.SYSTEM_TIMEOUT: "Operation timed out. Please try again later.", + ErrorCode.SYSTEM_INTERNAL_ERROR: "Internal server error. Please try again later.", + # 02 - Config + ErrorCode.CONFIG_NOT_FOUND: "Configuration not found.", + ErrorCode.CONFIG_UPDATE_FAILED: "Configuration update failed.", + } + + @classmethod + def get_message(cls, error_code: ErrorCode) -> str: + """Get error message by error code.""" + return cls._MESSAGES.get(error_code, "An error occurred. Please try again later.") + + @classmethod + def get_message_with_code(cls, error_code: ErrorCode) -> tuple[int, str]: + """Get error code and message as tuple.""" + return (error_code.value, cls.get_message(error_code)) + + @classmethod + def get_all_messages(cls) -> dict: + """Get all error code to message mappings.""" + return {code.value: msg for code, msg in cls._MESSAGES.items()} diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 94b1f770d..e9d270673 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -1,7 +1,74 @@ """ Custom exception classes for the application. + +This module provides two types of exceptions: + +1. New Framework (with ErrorCode): + from consts.error_code import ErrorCode + from consts.exceptions import AppException + + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + raise AppException(ErrorCode.MCP_CONNECTION_FAILED, "Connection timeout", details={"host": "localhost"}) + +2. Legacy Framework (simple exceptions): + from consts.exceptions import ValidationError, NotFoundException, MCPConnectionError + + raise ValidationError("Tenant name cannot be empty") + raise NotFoundException("Tenant 123 not found") + raise MCPConnectionError("MCP connection failed") + +The exception handler automatically maps legacy exception class names to ErrorCode. """ +from .error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from .error_message import ErrorMessage + + +# ==================== New Framework: AppException with ErrorCode ==================== + +class AppException(Exception): + """ + Base application exception with ErrorCode. + + Usage: + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + raise AppException(ErrorCode.MCP_CONNECTION_FAILED, "Timeout", details={"host": "x"}) + """ + + def __init__(self, error_code: ErrorCode, message: str = None, details: dict = None): + self.error_code = error_code + self.message = message or ErrorMessage.get_message(error_code) + self.details = details or {} + super().__init__(self.message) + + def to_dict(self) -> dict: + return { + "code": int(self.error_code.value), + "message": self.message, + "details": self.details if self.details else None + } + + @property + def http_status(self) -> int: + return ERROR_CODE_HTTP_STATUS.get(self.error_code, 500) + + +def raise_error(error_code: ErrorCode, message: str = None, details: dict = None): + """Raise an AppException with the given error code.""" + raise AppException(error_code, message, details) + + +# ==================== Legacy Framework: Simple Exception Classes ==================== +# These are simple exceptions that work with the old calling pattern. +# The exception handler automatically maps class names to ErrorCode. +# +# Usage (unchanged from before): +# raise ValidationError("Invalid input") +# raise NotFoundException("Resource not found") +# raise MCPConnectionError("Connection failed") +# +# These do NOT require ErrorCode - they are simple Exception subclasses. +# Exception handler will infer ErrorCode from class name. class AgentRunException(Exception): """Exception raised when agent run fails.""" @@ -58,7 +125,6 @@ class TimeoutException(Exception): pass - class ValidationError(Exception): """Raised when validation fails.""" pass @@ -70,7 +136,7 @@ class NotFoundException(Exception): class MEConnectionException(Exception): - """Raised when not found exception occurs.""" + """Raised when ME connection fails.""" pass @@ -112,3 +178,48 @@ class DuplicateError(Exception): class DataMateConnectionError(Exception): """Raised when DataMate connection fails or URL is not configured.""" pass + + +# ==================== Legacy Aliases (same as above, for compatibility) ==================== +# These are additional aliases that map to the same simple exception classes above. +# They provide backward compatibility for code that uses these names. + +# Common aliases +ParameterInvalidError = ValidationError +ForbiddenError = Exception # Generic fallback +ServiceUnavailableError = Exception # Generic fallback +DatabaseError = Exception # Generic fallback +TimeoutError = TimeoutException +UnknownError = Exception # Generic fallback + +# Domain specific aliases +UserNotFoundError = NotFoundException +UserAlreadyExistsError = DuplicateError +InvalidCredentialsError = UnauthorizedError + +TenantNotFoundError = NotFoundException +TenantDisabledError = Exception # Generic fallback + +AgentNotFoundError = NotFoundException +AgentDisabledError = Exception # Generic fallback + +ToolNotFoundError = NotFoundException + +ConversationNotFoundError = NotFoundException + +MemoryNotFoundError = NotFoundException +KnowledgeNotFoundError = NotFoundException + +ModelNotFoundError = NotFoundException + +# File aliases +FileNotFoundError = NotFoundException +FileUploadFailedError = Exception # Generic fallback +FileTooLargeError = Exception # Generic fallback + +# External service aliases +DifyServiceException = Exception # Generic fallback +ExternalAPIError = Exception # Generic fallback + +# Signature aliases +# SignatureValidationError already defined above diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 000000000..937f10972 --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1 @@ +# Backend middleware package diff --git a/backend/middleware/exception_handler.py b/backend/middleware/exception_handler.py new file mode 100644 index 000000000..14d9ebb38 --- /dev/null +++ b/backend/middleware/exception_handler.py @@ -0,0 +1,176 @@ +""" +Global exception handler middleware. + +This middleware provides centralized error handling for the FastAPI application. +It catches all exceptions and returns a standardized JSON response. +""" + +import logging +import traceback +import uuid +from typing import Callable + +from fastapi import Request, Response, HTTPException +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +from consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS +from consts.error_message import ErrorMessage + +logger = logging.getLogger(__name__) + + +def _http_status_to_error_code(status_code: int) -> ErrorCode: + """Map HTTP status codes to internal error codes for backward compatibility.""" + mapping = { + 400: ErrorCode.COMMON_VALIDATION_ERROR, + 401: ErrorCode.COMMON_UNAUTHORIZED, + 403: ErrorCode.COMMON_FORBIDDEN, + 404: ErrorCode.COMMON_RESOURCE_NOT_FOUND, + 429: ErrorCode.COMMON_RATE_LIMIT_EXCEEDED, + 500: ErrorCode.SYSTEM_INTERNAL_ERROR, + 502: ErrorCode.SYSTEM_SERVICE_UNAVAILABLE, + 503: ErrorCode.SYSTEM_SERVICE_UNAVAILABLE, + } + return mapping.get(status_code, ErrorCode.SYSTEM_UNKNOWN_ERROR) + + +class ExceptionHandlerMiddleware(BaseHTTPMiddleware): + """ + Global exception handler middleware. + + This middleware catches all exceptions and returns a standardized response: + - For AppException: returns the error code and message + - For other exceptions: logs the error and returns a generic error response + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # Generate trace ID for request tracking + trace_id = str(uuid.uuid4()) + request.state.trace_id = trace_id + + try: + response = await call_next(request) + return response + except Exception as exc: + # Check if it's an AppException by looking for the error_code attribute + # This handles both import path variations (backend.consts.exceptions vs consts.exceptions) + if hasattr(exc, 'error_code'): + # This is an AppException - get http_status from mapping + logger.error( + f"[{trace_id}] AppException: {exc.error_code.value} - {exc.message}", + extra={"trace_id": trace_id, + "error_code": exc.error_code.value} + ) + + # Use HTTP status from error code mapping, default to 500 + # Try to get http_status property first, then fall back to ERROR_CODE_HTTP_STATUS mapping + if hasattr(exc, 'http_status'): + http_status = exc.http_status + else: + http_status = ERROR_CODE_HTTP_STATUS.get( + exc.error_code, 500) + + return JSONResponse( + status_code=http_status, + content={ + "code": int(exc.error_code.value), + "message": exc.message, + "trace_id": trace_id, + "details": exc.details if exc.details else None + } + ) + elif isinstance(exc, HTTPException): + # Handle FastAPI HTTPException for backward compatibility + # Map HTTP status codes to error codes + error_code = _http_status_to_error_code(exc.status_code) + + return JSONResponse( + status_code=exc.status_code, + content={ + "code": int(error_code.value), + "message": exc.detail, + "trace_id": trace_id + } + ) + else: + # Log the full exception with traceback + logger.error( + f"[{trace_id}] Unhandled exception: {str(exc)}", + exc_info=True, + extra={"trace_id": trace_id} + ) + + # Return generic error response with proper HTTP 500 status + # Using mixed mode: HTTP status code + business error code + return JSONResponse( + status_code=500, + content={ + "code": ErrorCode.SYSTEM_INTERNAL_ERROR.value, + "message": ErrorMessage.get_message(ErrorCode.SYSTEM_INTERNAL_ERROR), + "trace_id": trace_id, + "details": None + } + ) + + +def create_error_response( + error_code: ErrorCode, + message: str = None, + trace_id: str = None, + details: dict = None, + http_status: int = None +) -> JSONResponse: + """ + Create a standardized error response with mixed mode (HTTP status + business error code). + + Args: + error_code: The error code + message: Optional custom message (defaults to standard message) + trace_id: Optional trace ID for tracking + details: Optional additional details + http_status: Optional HTTP status code (defaults to mapping from error_code) + + Returns: + JSONResponse with standardized error format + """ + # Use provided http_status or get from error code mapping + status = http_status if http_status else ERROR_CODE_HTTP_STATUS.get( + error_code, 500) + + return JSONResponse( + status_code=status, + content={ + "code": int(error_code.value), + "message": message or ErrorMessage.get_message(error_code), + "trace_id": trace_id, + "details": details + } + ) + + +def create_success_response( + data: any = None, + message: str = "OK", + trace_id: str = None +) -> JSONResponse: + """ + Create a standardized success response. + + Args: + data: The response data + message: Optional success message + trace_id: Optional trace ID for tracking + + Returns: + JSONResponse with standardized success format + """ + return JSONResponse( + status_code=200, + content={ + "code": 0, # 0 indicates success + "message": message, + "data": data, + "trace_id": trace_id + } + ) diff --git a/backend/services/dify_service.py b/backend/services/dify_service.py index 14e3a4d6f..76c297292 100644 --- a/backend/services/dify_service.py +++ b/backend/services/dify_service.py @@ -12,6 +12,8 @@ import httpx +from consts.error_code import ErrorCode +from consts.exceptions import AppException from nexent.utils.http_client_manager import http_client_manager logger = logging.getLogger("dify_service") @@ -67,11 +69,21 @@ def fetch_dify_datasets_impl( """ # Validate inputs if not dify_api_base or not isinstance(dify_api_base, str): - raise ValueError( - "dify_api_base is required and must be a non-empty string") + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL is required and must be a non-empty string" + ) + + # Validate URL format + if not (dify_api_base.startswith("http://") or dify_api_base.startswith("https://")): + raise AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Dify API URL must start with http:// or https://" + ) if not api_key or not isinstance(api_key, str): - raise ValueError("api_key is required and must be a non-empty string") + raise AppException(ErrorCode.DIFY_CONFIG_INVALID, + "Dify API key is required and must be a non-empty string") # Normalize API base URL api_base = dify_api_base.rstrip("/") @@ -95,7 +107,7 @@ def fetch_dify_datasets_impl( # Use shared HttpClientManager for connection pooling client = http_client_manager.get_sync_client( base_url=api_base, - timeout=30.0, + timeout=10.0, verify_ssl=False ) response = client.get(url, headers=headers) @@ -157,15 +169,35 @@ def fetch_dify_datasets_impl( except httpx.RequestError as e: logger.error(f"Dify API request failed: {str(e)}") - raise Exception(f"Dify API request failed: {str(e)}") + raise AppException(ErrorCode.DIFY_CONNECTION_ERROR, + f"Dify API request failed: {str(e)}") except httpx.HTTPStatusError as e: - logger.error(f"Dify API HTTP error: {str(e)}") - raise Exception(f"Dify API HTTP error: {str(e)}") + logger.error( + f"Dify API HTTP error: {str(e)}, status_code: {e.response.status_code}") + # Map HTTP status to specific error code + if e.response.status_code == 401: + logger.error("Raising DIFY_AUTH_ERROR for 401 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify authentication failed: {str(e)}") + elif e.response.status_code == 403: + logger.error("Raising DIFY_AUTH_ERROR for 403 error") + raise AppException(ErrorCode.DIFY_AUTH_ERROR, + f"Dify access forbidden: {str(e)}") + elif e.response.status_code == 429: + logger.error("Raising DIFY_RATE_LIMIT for 429 error") + raise AppException(ErrorCode.DIFY_RATE_LIMIT, + f"Dify API rate limit exceeded: {str(e)}") + else: + logger.error( + f"Raising DIFY_SERVICE_ERROR for status {e.response.status_code}") + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + f"Dify API HTTP error {e.response.status_code}: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Failed to parse Dify API response: {str(e)}") - raise Exception(f"Failed to parse Dify API response: {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Failed to parse Dify API response: {str(e)}") except KeyError as e: logger.error( f"Unexpected Dify API response format: missing key {str(e)}") - raise Exception( - f"Unexpected Dify API response format: missing key {str(e)}") + raise AppException(ErrorCode.DIFY_RESPONSE_ERROR, + f"Unexpected Dify API response format: missing key {str(e)}") diff --git a/frontend/const/errorCode.ts b/frontend/const/errorCode.ts new file mode 100644 index 000000000..88b8ba0cb --- /dev/null +++ b/frontend/const/errorCode.ts @@ -0,0 +1,207 @@ +/** + * Error code definitions for the frontend. + * + * Format: XXYYZZ (6 digits string) - Must match backend/consts/error_code.py + * - XX: Module code (01-99) + * - YY: Sub module category (01-99) + * - ZZ: Sequence in category (01-99) + * + * Module Numbers: + * - 00: Common / 公共 + * - 01: Chat / 开始问答 + * - 02: QuickConfig / 快速配置 + * - 03: AgentSpace / 智能体空间 + * - 04: AgentMarket / 智能体市场 + * - 05: AgentDev / 智能体开发 + * - 06: Knowledge / 知识库 + * - 07: MCPTools / MCP 工具 + * - 08: MonitorOps / 监控与运维 + * - 09: Model / 模型管理 + * - 10: Memory / 记忆管理 + * - 11: Profile / 个人信息 + * - 12: TenantResource / 租户资源 + * - 13: External / 外部服务 + * - 14: Northbound / 北向接口 + * - 15: DataProcess / 数据处理 + * - 99: System / 系统级 + */ + +export const ErrorCode = { + // ==================== 00 Common / 公共 ==================== + // 01 - Parameter & Validation + VALIDATION_ERROR: "000101", + PARAMETER_INVALID: "000102", + MISSING_REQUIRED_FIELD: "000103", + + // 02 - Auth & Permission + UNAUTHORIZED: "000201", + FORBIDDEN: "000202", + TOKEN_EXPIRED: "000203", + TOKEN_INVALID: "000204", + + // 03 - External Service + EXTERNAL_SERVICE_ERROR: "000301", + RATE_LIMIT_EXCEEDED: "000302", + + // 04 - File + FILE_NOT_FOUND: "000401", + FILE_UPLOAD_FAILED: "000402", + FILE_TOO_LARGE: "000403", + FILE_TYPE_NOT_ALLOWED: "000404", + FILE_PREPROCESS_FAILED: "000405", + + // 05 - Resource + RESOURCE_NOT_FOUND: "000501", + RESOURCE_ALREADY_EXISTS: "000502", + RESOURCE_DISABLED: "000503", + + // ==================== 01 Chat / 开始问答 ==================== + // 01 - Conversation + CONVERSATION_NOT_FOUND: "010101", + MESSAGE_NOT_FOUND: "010102", + CONVERSATION_SAVE_FAILED: "010103", + CONVERSATION_TITLE_GENERATION_FAILED: "010104", + + // ==================== 02 QuickConfig / 快速配置 ==================== + // 01 - Configuration + QUICK_CONFIG_INVALID: "020101", + QUICK_CONFIG_SYNC_FAILED: "020102", + + // ==================== 03 AgentSpace / 智能体空间 ==================== + // 01 - Agent + AGENT_NOT_FOUND: "030101", + AGENT_DISABLED: "030102", + AGENT_RUN_FAILED: "030103", + AGENT_NAME_DUPLICATE: "030104", + AGENT_VERSION_NOT_FOUND: "030105", + + // ==================== 04 AgentMarket / 智能体市场 ==================== + // 01 - Agent + AGENTMARKET_AGENT_NOT_FOUND: "040101", + + // ==================== 05 AgentDev / 智能体开发 ==================== + // 01 - Configuration + AGENTDEV_CONFIG_INVALID: "050101", + AGENTDEV_PROMPT_INVALID: "050102", + + // ==================== 06 Knowledge / 知识库 ==================== + // 01 - Knowledge Base + KNOWLEDGE_NOT_FOUND: "060101", + KNOWLEDGE_UPLOAD_FAILED: "060102", + KNOWLEDGE_SYNC_FAILED: "060103", + INDEX_NOT_FOUND: "060104", + KNOWLEDGE_SEARCH_FAILED: "060105", + + // ==================== 07 MCPTools / MCP 工具 ==================== + // 01 - Tool + TOOL_NOT_FOUND: "070101", + TOOL_EXECUTION_FAILED: "070102", + TOOL_CONFIG_INVALID: "070103", + + // 02 - Connection + MCP_CONNECTION_FAILED: "070201", + MCP_CONTAINER_ERROR: "070202", + + // 03 - Configuration + MCP_NAME_ILLEGAL: "070301", + + // ==================== 08 MonitorOps / 监控与运维 ==================== + // 01 - Monitoring + MONITOROPS_METRIC_QUERY_FAILED: "080101", + + // 02 - Alert + MONITOROPS_ALERT_CONFIG_INVALID: "080201", + + // ==================== 09 Model / 模型管理 ==================== + // 01 - Model + MODEL_NOT_FOUND: "090101", + MODEL_CONFIG_INVALID: "090102", + MODEL_HEALTH_CHECK_FAILED: "090103", + MODEL_PROVIDER_ERROR: "090104", + + // ==================== 10 Memory / 记忆管理 ==================== + // 01 - Memory + MEMORY_NOT_FOUND: "100101", + MEMORY_PREPARATION_FAILED: "100102", + MEMORY_CONFIG_INVALID: "100103", + + // ==================== 11 Profile / 个人信息 ==================== + // 01 - User + USER_NOT_FOUND: "110101", + USER_UPDATE_FAILED: "110102", + USER_ALREADY_EXISTS: "110103", + INVALID_CREDENTIALS: "110104", + + // ==================== 12 TenantResource / 租户资源 ==================== + // 01 - Tenant + TENANT_NOT_FOUND: "120101", + TENANT_DISABLED: "120102", + TENANT_CONFIG_ERROR: "120103", + TENANT_RESOURCE_EXCEEDED: "120104", + + // ==================== 13 External / 外部服务 ==================== + // 01 - DataMate + DATAMATE_CONNECTION_FAILED: "130101", + + // 02 - Dify + DIFY_SERVICE_ERROR: "130201", + DIFY_CONFIG_INVALID: "130202", + DIFY_CONNECTION_ERROR: "130203", + DIFY_AUTH_ERROR: "130204", + DIFY_RATE_LIMIT: "130205", + DIFY_RESPONSE_ERROR: "130206", + + // 03 - ME Service + ME_CONNECTION_FAILED: "130301", + + // ==================== 14 Northbound / 北向接口 ==================== + // 01 - Request + NORTHBOUND_REQUEST_FAILED: "140101", + + // 02 - Configuration + NORTHBOUND_CONFIG_INVALID: "140201", + + // ==================== 15 DataProcess / 数据处理 ==================== + // 01 - Task + DATA_PROCESS_FAILED: "150101", + DATA_PARSE_FAILED: "150102", + + // ==================== 99 System / 系统级 ==================== + // 01 - System Errors + UNKNOWN_ERROR: "990101", + SERVICE_UNAVAILABLE: "990102", + DATABASE_ERROR: "990103", + TIMEOUT: "990104", + INTERNAL_ERROR: "990105", + + // 02 - Config + CONFIG_NOT_FOUND: "990201", + CONFIG_UPDATE_FAILED: "990202", + + // ==================== Success Code ==================== + SUCCESS: "0", +} as const; + +export type ErrorCodeType = typeof ErrorCode[keyof typeof ErrorCode]; + +/** + * Check if an error code represents a success. + */ +export const isSuccess = (code: string | number): boolean => { + return code === ErrorCode.SUCCESS || code === 0; +}; + +/** + * Check if an error code represents an authentication error. + */ +export const isAuthError = (code: string | number): boolean => { + const codeStr = String(code); + return codeStr >= "000201" && codeStr < "000300"; +}; + +/** + * Check if an error code represents a session expiration. + */ +export const isSessionExpired = (code: string | number): boolean => { + return code === ErrorCode.TOKEN_EXPIRED || code === ErrorCode.TOKEN_INVALID; +}; diff --git a/frontend/const/errorMessage.ts b/frontend/const/errorMessage.ts new file mode 100644 index 000000000..90ae1c286 --- /dev/null +++ b/frontend/const/errorMessage.ts @@ -0,0 +1,212 @@ +/** + * Error message utility functions. + * + * This module provides functions to get error messages by error code. + * For i18n support, use the getI18nErrorMessage function which reads from translation files. + */ + +import { ErrorCode } from "./errorCode"; + +/** + * Default error messages (English). + * These are fallback messages when i18n is not available. + * Must match backend/consts/error_message.py + */ +export const DEFAULT_ERROR_MESSAGES: Record = { + // ==================== 00 Common / 公共 ==================== + // 01 - Parameter & Validation + [ErrorCode.VALIDATION_ERROR]: "Validation error.", + [ErrorCode.PARAMETER_INVALID]: "Invalid parameter.", + [ErrorCode.MISSING_REQUIRED_FIELD]: "Required field is missing.", + + // 02 - Auth & Permission + [ErrorCode.UNAUTHORIZED]: "You are not authorized to perform this action.", + [ErrorCode.FORBIDDEN]: "Access forbidden.", + [ErrorCode.TOKEN_EXPIRED]: "Your session has expired. Please login again.", + [ErrorCode.TOKEN_INVALID]: "Invalid token. Please login again.", + + // 03 - External Service + [ErrorCode.EXTERNAL_SERVICE_ERROR]: "External service error.", + [ErrorCode.RATE_LIMIT_EXCEEDED]: "Too many requests. Please try again later.", + + // 04 - File + [ErrorCode.FILE_NOT_FOUND]: "File not found.", + [ErrorCode.FILE_UPLOAD_FAILED]: "Failed to upload file.", + [ErrorCode.FILE_TOO_LARGE]: "File size exceeds limit.", + [ErrorCode.FILE_TYPE_NOT_ALLOWED]: "File type not allowed.", + [ErrorCode.FILE_PREPROCESS_FAILED]: "File preprocessing failed.", + + // 05 - Resource + [ErrorCode.RESOURCE_NOT_FOUND]: "Resource not found.", + [ErrorCode.RESOURCE_ALREADY_EXISTS]: "Resource already exists.", + [ErrorCode.RESOURCE_DISABLED]: "Resource is disabled.", + + // ==================== 01 Chat / 开始问答 ==================== + // 01 - Conversation + [ErrorCode.CONVERSATION_NOT_FOUND]: "Conversation not found.", + [ErrorCode.MESSAGE_NOT_FOUND]: "Message not found.", + [ErrorCode.CONVERSATION_SAVE_FAILED]: "Failed to save conversation.", + [ErrorCode.CONVERSATION_TITLE_GENERATION_FAILED]: + "Failed to generate conversation title.", + + // ==================== 02 QuickConfig / 快速配置 ==================== + // 01 - Configuration + [ErrorCode.QUICK_CONFIG_INVALID]: "Invalid configuration.", + [ErrorCode.QUICK_CONFIG_SYNC_FAILED]: "Sync configuration failed.", + + // ==================== 03 AgentSpace / 智能体空间 ==================== + // 01 - Agent + [ErrorCode.AGENT_NOT_FOUND]: "Agent not found.", + [ErrorCode.AGENT_DISABLED]: "Agent is disabled.", + [ErrorCode.AGENT_RUN_FAILED]: "Failed to run agent. Please try again later.", + [ErrorCode.AGENT_NAME_DUPLICATE]: "Agent name already exists.", + [ErrorCode.AGENT_VERSION_NOT_FOUND]: "Agent version not found.", + + // ==================== 04 AgentMarket / 智能体市场 ==================== + // 01 - Agent + [ErrorCode.AGENTMARKET_AGENT_NOT_FOUND]: "Agent not found in market.", + + // ==================== 05 AgentDev / 智能体开发 ==================== + // 01 - Configuration + [ErrorCode.AGENTDEV_CONFIG_INVALID]: "Invalid agent configuration.", + [ErrorCode.AGENTDEV_PROMPT_INVALID]: "Invalid prompt.", + + // ==================== 06 Knowledge / 知识库 ==================== + // 01 - Knowledge Base + [ErrorCode.KNOWLEDGE_NOT_FOUND]: "Knowledge base not found.", + [ErrorCode.KNOWLEDGE_UPLOAD_FAILED]: "Failed to upload knowledge.", + [ErrorCode.KNOWLEDGE_SYNC_FAILED]: "Failed to sync knowledge base.", + [ErrorCode.INDEX_NOT_FOUND]: "Search index not found.", + [ErrorCode.KNOWLEDGE_SEARCH_FAILED]: "Knowledge search failed.", + + // ==================== 07 MCPTools / MCP 工具 ==================== + // 01 - Tool + [ErrorCode.TOOL_NOT_FOUND]: "Tool not found.", + [ErrorCode.TOOL_EXECUTION_FAILED]: "Tool execution failed.", + [ErrorCode.TOOL_CONFIG_INVALID]: "Tool configuration is invalid.", + + // 02 - Connection + [ErrorCode.MCP_CONNECTION_FAILED]: "Failed to connect to MCP service.", + [ErrorCode.MCP_CONTAINER_ERROR]: "MCP container operation failed.", + + // 03 - Configuration + [ErrorCode.MCP_NAME_ILLEGAL]: "MCP name contains invalid characters.", + + // ==================== 08 MonitorOps / 监控与运维 ==================== + // 01 - Monitoring + [ErrorCode.MONITOROPS_METRIC_QUERY_FAILED]: "Metric query failed.", + + // 02 - Alert + [ErrorCode.MONITOROPS_ALERT_CONFIG_INVALID]: "Invalid alert configuration.", + + // ==================== 09 Model / 模型管理 ==================== + // 01 - Model + [ErrorCode.MODEL_NOT_FOUND]: "Model not found.", + [ErrorCode.MODEL_CONFIG_INVALID]: "Model configuration is invalid.", + [ErrorCode.MODEL_HEALTH_CHECK_FAILED]: "Model health check failed.", + [ErrorCode.MODEL_PROVIDER_ERROR]: "Model provider error.", + + // ==================== 10 Memory / 记忆管理 ==================== + // 01 - Memory + [ErrorCode.MEMORY_NOT_FOUND]: "Memory not found.", + [ErrorCode.MEMORY_PREPARATION_FAILED]: "Failed to prepare memory.", + [ErrorCode.MEMORY_CONFIG_INVALID]: "Memory configuration is invalid.", + + // ==================== 11 Profile / 个人信息 ==================== + // 01 - User + [ErrorCode.USER_NOT_FOUND]: "User not found.", + [ErrorCode.USER_UPDATE_FAILED]: "Profile update failed.", + [ErrorCode.USER_ALREADY_EXISTS]: "User already exists.", + [ErrorCode.INVALID_CREDENTIALS]: "Invalid username or password.", + + // ==================== 12 TenantResource / 租户资源 ==================== + // 01 - Tenant + [ErrorCode.TENANT_NOT_FOUND]: "Tenant not found.", + [ErrorCode.TENANT_DISABLED]: "Tenant is disabled.", + [ErrorCode.TENANT_CONFIG_ERROR]: "Tenant configuration error.", + [ErrorCode.TENANT_RESOURCE_EXCEEDED]: "Tenant resource exceeded.", + + // ==================== 13 External / 外部服务 ==================== + // 01 - DataMate + [ErrorCode.DATAMATE_CONNECTION_FAILED]: + "Failed to connect to DataMate service.", + + // 02 - Dify + [ErrorCode.DIFY_SERVICE_ERROR]: "Dify service error.", + [ErrorCode.DIFY_CONFIG_INVALID]: + "Dify configuration invalid. Please check URL and API key format.", + [ErrorCode.DIFY_CONNECTION_ERROR]: + "Failed to connect to Dify. Please check network connection and URL.", + [ErrorCode.DIFY_AUTH_ERROR]: + "Dify authentication failed. Please check your API key.", + [ErrorCode.DIFY_RATE_LIMIT]: + "Dify API rate limit exceeded. Please try again later.", + [ErrorCode.DIFY_RESPONSE_ERROR]: + "Failed to parse Dify response. Please check API URL.", + + // 03 - ME Service + [ErrorCode.ME_CONNECTION_FAILED]: "Failed to connect to ME service.", + + // ==================== 14 Northbound / 北向接口 ==================== + // 01 - Request + [ErrorCode.NORTHBOUND_REQUEST_FAILED]: "Northbound request failed.", + + // 02 - Configuration + [ErrorCode.NORTHBOUND_CONFIG_INVALID]: "Invalid northbound configuration.", + + // ==================== 15 DataProcess / 数据处理 ==================== + // 01 - Task + [ErrorCode.DATA_PROCESS_FAILED]: "Data processing failed.", + [ErrorCode.DATA_PARSE_FAILED]: "Data parsing failed.", + + // ==================== 99 System / 系统级 ==================== + // 01 - System Errors + [ErrorCode.UNKNOWN_ERROR]: "An unknown error occurred. Please try again later.", + [ErrorCode.SERVICE_UNAVAILABLE]: + "Service is temporarily unavailable. Please try again later.", + [ErrorCode.DATABASE_ERROR]: + "Database operation failed. Please try again later.", + [ErrorCode.TIMEOUT]: "Operation timed out. Please try again later.", + [ErrorCode.INTERNAL_ERROR]: "Internal server error. Please try again later.", + + // 02 - Config + [ErrorCode.CONFIG_NOT_FOUND]: "Configuration not found.", + [ErrorCode.CONFIG_UPDATE_FAILED]: "Configuration update failed.", + + // ==================== Success ==================== + [ErrorCode.SUCCESS]: "Success", +}; + +/** + * Get error message by error code. + * + * @param code - The error code (string or number) + * @returns The error message + */ +export const getErrorMessage = (code: string | number): string => { + const key = String(code); + return ( + DEFAULT_ERROR_MESSAGES[key] || "An error occurred. Please try again later." + ); +}; + +/** + * API Response interface. + */ +export interface ApiResponse { + code: number; + message: string; + data?: T; + trace_id?: string; + details?: any; +} + +/** + * Check if API response indicates success. + * + * @param response - The API response + * @returns True if success + */ +export const isApiSuccess = (response: ApiResponse): boolean => { + return response.code === 0;; +} diff --git a/frontend/const/errorMessageI18n.ts b/frontend/const/errorMessageI18n.ts new file mode 100644 index 000000000..1c21257f7 --- /dev/null +++ b/frontend/const/errorMessageI18n.ts @@ -0,0 +1,238 @@ +/** + * Error message utility with i18n support. + * + * This module provides functions to get localized error messages by error code. + */ + +import { message } from "antd"; +import { useTranslation } from "react-i18next"; +import { ErrorCode } from "./errorCode"; +import { DEFAULT_ERROR_MESSAGES } from "./errorMessage"; +import { handleSessionExpired } from "@/lib/session"; +import { isSessionExpired } from "./errorCode"; +import log from "@/lib/logger"; + +/** + * Get error message by error code with i18n support. + * + * This function tries to get the message from i18n translation files first, + * then falls back to default English messages. + * + * @param code - The error code + * @param t - Optional translation function (if not provided, returns default message) + * @returns The localized error message + */ +export const getI18nErrorMessage = ( + code: string | number, + t?: (key: string) => string +): string => { + // Try i18n translation first + if (t) { + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + // Check if translation exists (i18next returns the key if not found) + if (translated !== i18nKey) { + return translated; + } + } + + // Fall back to default message + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); +}; + +/** + * Hook to get error message with i18n support. + * + * @returns A function that takes an error code and returns the localized message + */ +export const useErrorMessage = () => { + const { t } = useTranslation(); + + return (code: string | number) => getI18nErrorMessage(code, t); +}; + +/** + * Handle API error and return user-friendly message. + * + * @param error - The error object (can be ApiError, Error, or any) + * @param t - Optional translation function + * @returns User-friendly error message + */ +export const handleApiError = ( + error: any, + t?: (key: string) => string +): string => { + // Handle ApiError with code + if (error && typeof error === "object" && "code" in error) { + return getI18nErrorMessage(error.code as string | number, t); + } + + // Handle standard Error + if (error instanceof Error) { + return error.message; + } + + // Handle unknown error + return getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); +}; + +/** + * Options for showing error to user + */ +export interface ShowErrorOptions { + /** Whether to handle session expiration */ + handleSession?: boolean; + /** Whether to show error message (default: true) */ + showMessage?: boolean; + /** Custom error message (overrides auto-detected message) */ + customMessage?: string; + /** Callback after error is shown */ + onError?: (error: any) => void; +} + +/** + * Show error to user with i18n support. + * + * This is a convenience function that: + * 1. Extracts the error code from the error object + * 2. Gets the i18n translated message + * 3. Shows the message to user via antd message + * 4. Optionally handles session expiration + * + * @param error - The error object (ApiError, Error, or any) + * @param t - Translation function (optional, will use default if not provided) + * @param options - Additional options + * + * @example + * // Simple usage + * showErrorToUser(error); + * + * @example + * // With translation function + * const { t } = useTranslation(); + * showErrorToUser(error, t); + * + * @example + * // With options + * showErrorToUser(error, t, { handleSession: true, onError: (e) => console.log(e) }); + */ +export const showErrorToUser = ( + error: any, + t?: (key: string) => string, + options: ShowErrorOptions = {} +): void => { + const { + handleSession = true, + showMessage = true, + customMessage, + onError, + } = options; + + // Get error code if available + let errorCode: number | undefined; + if (error && typeof error === "object" && "code" in error) { + errorCode = error.code as number; + } + + // Handle session expiration + if (handleSession && errorCode && isSessionExpired(errorCode)) { + handleSessionExpired(); + } + + // Get the error message + let errorMessage: string; + if (customMessage) { + errorMessage = customMessage; + } else if (errorCode) { + errorMessage = getI18nErrorMessage(errorCode, t); + } else if (error instanceof Error) { + errorMessage = error.message; + } else { + errorMessage = getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR, t); + } + + // Log the error + log.error(`Error [${errorCode || "unknown"}]: ${errorMessage}`, error); + + // Show message to user + if (showMessage) { + message.error(errorMessage); + } + + // Call onError callback + if (onError) { + onError(error); + } +}; + +/** + * Wrap an async function with automatic error handling. + * + * @param fn - The async function to wrap + * @param options - Error handling options + * @returns Wrapped function that automatically handles errors + * + * @example + * const safeFetchData = withErrorHandler(async () => { + * const result = await api.fetchData(); + * return result; + * }, { handleSession: true }); + * + * // Usage + * await safeFetchData(); + */ +export const withErrorHandler = ( + fn: (...args: any[]) => Promise, + options: ShowErrorOptions = {} +) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + showErrorToUser(error, undefined, options); + throw error; + } + }; +}; + +/** + * Check if error requires session refresh action. + * + * @param code - The error code + * @returns True if user needs to re-login + */ +export const requiresSessionRefresh = (code: string | number): boolean => { + const codeStr = String(code); + return codeStr === ErrorCode.TOKEN_EXPIRED || codeStr === ErrorCode.TOKEN_INVALID; +}; + +/** + * Check if error is a validation error. + * + * @param code - The error code + * @returns True if it's a validation error + */ +export const isValidationError = (code: string | number): boolean => { + const codeStr = String(code); + return codeStr >= "000101" && codeStr < "000200"; // 00 Common - 01 Parameter & Validation +}; + +/** + * Check if error is a resource not found error. + * + * @param code - The error code + * @returns True if resource not found + */ +export const isNotFoundError = (code: string | number): boolean => { + const codeStr = String(code); + return ( + codeStr === ErrorCode.RESOURCE_NOT_FOUND || + codeStr === ErrorCode.AGENT_NOT_FOUND || + codeStr === ErrorCode.USER_NOT_FOUND || + codeStr === ErrorCode.FILE_NOT_FOUND || + codeStr === ErrorCode.KNOWLEDGE_NOT_FOUND + ); +}; diff --git a/frontend/hooks/useErrorHandler.ts b/frontend/hooks/useErrorHandler.ts new file mode 100644 index 000000000..eb8e57477 --- /dev/null +++ b/frontend/hooks/useErrorHandler.ts @@ -0,0 +1,167 @@ +/** + * Custom hook for handling API errors with i18n support. + * + * This hook provides utilities to: + * - Convert error codes to localized messages + * - Handle session expiration + * - Provide consistent error handling across the app + */ + +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { message } from "antd"; + +import { ErrorCode, isSessionExpired } from "@/const/errorCode"; +import { DEFAULT_ERROR_MESSAGES } from "@/const/errorMessage"; +import { ApiError } from "@/services/api"; +import { handleSessionExpired } from "@/lib/session"; +import log from "@/lib/logger"; + +/** + * Options for error handling + */ +export interface ErrorHandlerOptions { + /** Whether to show error message to user */ + showMessage?: boolean; + /** Custom error message key prefix */ + messagePrefix?: string; + /** Callback on error */ + onError?: (error: Error) => void; + /** Whether to handle session expiration */ + handleSession?: boolean; +} + +/** + * Default error handler options + */ +const DEFAULT_OPTIONS: ErrorHandlerOptions = { + showMessage: true, + handleSession: true, +}; + +/** + * Hook for handling API errors with i18n support + */ +export const useErrorHandler = () => { + const { t } = useTranslation(); + + /** + * Get i18n error message by error code + */ + const getI18nErrorMessage = useCallback( + (code: string | number): string => { + // Try to get i18n key + const i18nKey = `errorCode.${code}`; + const translated = t(i18nKey); + + // If translation exists (not equal to key), return translated message + if (translated !== i18nKey) { + return translated; + } + + // Fallback to default messages + return ( + DEFAULT_ERROR_MESSAGES[code] || + DEFAULT_ERROR_MESSAGES[ErrorCode.UNKNOWN_ERROR] + ); + }, + [t] + ); + + /** + * Handle API error + */ + const handleError = useCallback( + (error: unknown, options: ErrorHandlerOptions = {}) => { + const { showMessage, onError, handleSession } = { + ...DEFAULT_OPTIONS, + ...options, + }; + + // Handle ApiError + if (error instanceof ApiError) { + // Handle session expiration + if (handleSession && isSessionExpired(error.code)) { + handleSessionExpired(); + } + + // Get localized message + const errorMessage = getI18nErrorMessage(error.code); + + // Log error + log.error(`API Error [${error.code}]: ${errorMessage}`, error); + + // Show message to user + if (showMessage) { + message.error(errorMessage); + } + + // Call onError callback + if (onError) { + onError(error); + } + + return { + code: error.code, + message: errorMessage, + originalError: error, + }; + } + + // Handle unknown error + if (error instanceof Error) { + log.error("Unknown error:", error); + + if (showMessage) { + message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); + } + + if (onError) { + onError(error); + } + + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: error, + }; + } + + // Handle non-Error objects + log.error("Non-error object thrown:", error); + + if (showMessage) { + message.error(getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR)); + } + + return { + code: ErrorCode.UNKNOWN_ERROR, + message: getI18nErrorMessage(ErrorCode.UNKNOWN_ERROR), + originalError: null, + }; + }, + [getI18nErrorMessage] + ); + + /** + * Wrap async function with error handling + */ + const withErrorHandler = useCallback( + (fn: (...args: any[]) => Promise, options: ErrorHandlerOptions = {}) => { + return async (...args: any[]) => { + try { + return await fn(...args); + } catch (error) { + throw handleError(error, options); + } + }; + }, + [handleError] + ); + + return { + getI18nErrorMessage, + handleError, + withErrorHandler, + }; +}; diff --git a/frontend/hooks/useKnowledgeBaseSelector.ts b/frontend/hooks/useKnowledgeBaseSelector.ts index b3f5de554..a38102291 100644 --- a/frontend/hooks/useKnowledgeBaseSelector.ts +++ b/frontend/hooks/useKnowledgeBaseSelector.ts @@ -2,10 +2,12 @@ import { useState, useCallback } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import knowledgeBaseService from "@/services/knowledgeBaseService"; import { KnowledgeBase } from "@/types/knowledgeBase"; import log from "@/lib/logger"; +import { showErrorToUser } from "@/const/errorMessageI18n"; /** * Query key factory for knowledge bases @@ -34,6 +36,8 @@ export function useKnowledgeBasesForToolConfig( apiKey?: string; } ) { + const { t } = useTranslation(); + // Support both difyConfig and datamateConfig naming conventions const difyConfig = config; const datamateConfig = config; @@ -94,8 +98,10 @@ export function useKnowledgeBasesForToolConfig( difyConfig.serverUrl, difyConfig.apiKey ); - } catch (error) { + } catch (error: any) { log.error("Failed to fetch Dify knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); kbs = []; } } else { @@ -130,6 +136,7 @@ export function useKnowledgeBasesForToolConfig( * Call this when the user navigates to the agent config page */ export function usePrefetchKnowledgeBases() { + const { t } = useTranslation(); const queryClient = useQueryClient(); const prefetchKnowledgeBases = useCallback( @@ -194,8 +201,10 @@ export function usePrefetchKnowledgeBases() { difyConfig.serverUrl, difyConfig.apiKey ); - } catch (error) { + } catch (error: any) { log.error("Failed to prefetch Dify knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); kbs = []; } } else { @@ -225,6 +234,7 @@ export function usePrefetchKnowledgeBases() { * Hook for syncing knowledge bases by tool type */ export function useSyncKnowledgeBases() { + const { t } = useTranslation(); const [isSyncing, setIsSyncing] = useState(null); const syncKnowledgeBases = useCallback( @@ -261,6 +271,10 @@ export function useSyncKnowledgeBases() { // Default sync behavior - sync Nexent only await knowledgeBaseService.getKnowledgeBasesInfo(false, false); } + } catch (error: any) { + log.error("Failed to sync knowledge bases:", error); + // Show i18n error message to user + showErrorToUser(error, t); } finally { setIsSyncing(null); } diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 4d8cefd0a..e69033126 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1779,5 +1779,165 @@ "agent.version.statusUpdateError": "Failed to update version status", "agent.version.needTwoVersions": "At least two versions are required to compare", "agent.version.selectDifferentVersions": "Please select two different versions to compare", - "agent.error.agentNotFound": "Agent not found" + "agent.error.agentNotFound": "Agent not found", + + "errorCode.101001": "An unknown error occurred. Please try again later.", + "errorCode.101002": "Service is temporarily unavailable. Please try again later.", + "errorCode.101003": "Database operation failed. Please try again later.", + "errorCode.101004": "Operation timed out. Please try again later.", + "errorCode.101005": "Internal server error. Please try again later.", + "errorCode.102001": "You are not authorized to perform this action.", + "errorCode.102002": "Your session has expired. Please login again.", + "errorCode.102003": "Invalid token. Please login again.", + "errorCode.102004": "Request signature verification failed.", + "errorCode.102005": "Access forbidden.", + "errorCode.103001": "User not found.", + "errorCode.103002": "User registration failed. Please try again later.", + "errorCode.103003": "User already exists.", + "errorCode.103004": "Invalid username or password.", + "errorCode.104001": "Tenant not found.", + "errorCode.104002": "Tenant is disabled.", + "errorCode.104003": "Tenant configuration error.", + "errorCode.105001": "Agent not found.", + "errorCode.105002": "Failed to run agent. Please try again later.", + "errorCode.105003": "Agent name already exists.", + "errorCode.105004": "Agent is disabled.", + "errorCode.105005": "Agent version not found.", + "errorCode.106001": "Tool not found.", + "errorCode.106002": "Tool execution failed.", + "errorCode.106003": "Tool configuration is invalid.", + "errorCode.106101": "Failed to connect to MCP service.", + "errorCode.106102": "MCP name contains invalid characters.", + "errorCode.106103": "MCP container operation failed.", + "errorCode.107001": "Conversation not found.", + "errorCode.107002": "Failed to save conversation.", + "errorCode.107003": "Message not found.", + "errorCode.107004": "Failed to generate conversation title.", + "errorCode.108001": "Memory not found.", + "errorCode.108002": "Failed to prepare memory.", + "errorCode.108003": "Memory configuration is invalid.", + "errorCode.109001": "Knowledge base not found.", + "errorCode.109002": "Failed to sync knowledge base.", + "errorCode.109003": "Search index not found.", + "errorCode.109004": "Knowledge search failed.", + "errorCode.109005": "Failed to upload knowledge.", + "errorCode.110001": "Model not found.", + "errorCode.110002": "Model configuration is invalid.", + "errorCode.110003": "Model health check failed.", + "errorCode.110004": "Model provider error.", + "errorCode.111001": "Voice service error.", + "errorCode.111002": "Failed to connect to speech recognition service.", + "errorCode.111003": "Failed to connect to speech synthesis service.", + "errorCode.111004": "Voice configuration is invalid.", + "errorCode.112001": "File not found.", + "errorCode.112002": "Failed to upload file.", + "errorCode.112003": "File size exceeds limit.", + "errorCode.112004": "File type not allowed.", + "errorCode.112005": "File preprocessing failed.", + "errorCode.113001": "Invite code not found.", + "errorCode.113002": "Invalid invite code.", + "errorCode.113003": "Invite code has expired.", + "errorCode.114001": "Group not found.", + "errorCode.114002": "Group already exists.", + "errorCode.114003": "Member is not in the group.", + "errorCode.115001": "Data processing failed.", + "errorCode.115002": "Data parsing failed.", + "errorCode.130101": "Failed to connect to DataMate service.", + "errorCode.130201": "Dify service error.", + "errorCode.130202": "Dify configuration invalid. Please check URL and API key format.", + "errorCode.130203": "Failed to connect to Dify. Please check network connection and URL.", + "errorCode.130204": "Dify authentication failed. Please check your API key.", + "errorCode.130205": "Dify API rate limit exceeded. Please try again later.", + "errorCode.130206": "Failed to parse Dify response. Please check API URL.", + "errorCode.130301": "Failed to connect to ME service.", + + "errorCode.101": "Validation failed.", + "errorCode.102": "Invalid parameter.", + "errorCode.103": "Required field is missing.", + + "errorCode.201": "You are not authorized to perform this action.", + "errorCode.202": "Access forbidden.", + "errorCode.203": "Your session has expired. Please login again.", + "errorCode.204": "Invalid token. Please login again.", + + "errorCode.301": "External service error.", + "errorCode.302": "Too many requests. Please try again later.", + + "errorCode.401": "File not found.", + "errorCode.402": "Failed to upload file.", + "errorCode.403": "File size exceeds limit.", + "errorCode.404": "File type not allowed.", + "errorCode.405": "File preprocessing failed.", + + "errorCode.501": "Resource not found.", + "errorCode.502": "Resource already exists.", + "errorCode.503": "Resource is disabled.", + + "errorCode.10101": "Conversation not found.", + "errorCode.10102": "Message not found.", + "errorCode.10103": "Failed to save conversation.", + "errorCode.10104": "Failed to generate conversation title.", + + "errorCode.20101": "Invalid configuration.", + "errorCode.20102": "Sync configuration failed.", + + "errorCode.30101": "Agent not found.", + "errorCode.30102": "Agent is disabled.", + "errorCode.30103": "Failed to run agent. Please try again later.", + "errorCode.30104": "Agent name already exists.", + "errorCode.30105": "Agent version not found.", + + "errorCode.40101": "Agent not found in market.", + + "errorCode.50101": "Invalid agent configuration.", + "errorCode.50102": "Invalid prompt.", + + "errorCode.60101": "Knowledge base not found.", + "errorCode.60102": "Failed to upload knowledge.", + "errorCode.60103": "Failed to sync knowledge base.", + "errorCode.60104": "Search index not found.", + "errorCode.60105": "Knowledge search failed.", + + "errorCode.70101": "Tool not found.", + "errorCode.70102": "Tool execution failed.", + "errorCode.70103": "Tool configuration is invalid.", + "errorCode.70201": "Failed to connect to MCP service.", + "errorCode.70202": "MCP container operation failed.", + "errorCode.70301": "MCP name contains invalid characters.", + + "errorCode.80101": "Metric query failed.", + "errorCode.80201": "Invalid alert configuration.", + + "errorCode.90101": "Model not found.", + "errorCode.90102": "Model configuration is invalid.", + "errorCode.90103": "Model health check failed.", + "errorCode.90104": "Model provider error.", + + "errorCode.100101": "Memory not found.", + "errorCode.100102": "Failed to prepare memory.", + "errorCode.100103": "Memory configuration is invalid.", + + "errorCode.110101": "User not found.", + "errorCode.110102": "Profile update failed.", + "errorCode.110103": "User already exists.", + "errorCode.110104": "Invalid username or password.", + + "errorCode.120101": "Tenant not found.", + "errorCode.120102": "Tenant is disabled.", + "errorCode.120103": "Tenant configuration error.", + "errorCode.120104": "Tenant resource exceeded.", + + "errorCode.140101": "Northbound request failed.", + "errorCode.140201": "Invalid northbound configuration.", + + "errorCode.150101": "Data processing failed.", + "errorCode.150102": "Data parsing failed.", + + "errorCode.990101": "An unknown error occurred. Please try again later.", + "errorCode.990102": "Service is temporarily unavailable. Please try again later.", + "errorCode.990103": "Database operation failed. Please try again later.", + "errorCode.990104": "Operation timed out. Please try again later.", + "errorCode.990105": "Internal server error. Please try again later.", + "errorCode.990201": "Configuration not found.", + "errorCode.990202": "Configuration update failed." } diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index e5c65e03b..7b16eea84 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1781,5 +1781,180 @@ "agent.version.statusUpdateError": "更新版本状态失败", "agent.version.needTwoVersions": "至少需要两个版本才能进行对比", "agent.version.selectDifferentVersions": "请选择两个不同的版本进行对比", - "agent.error.agentNotFound": "未找到智能体" + "agent.error.agentNotFound": "未找到智能体", + + "errorCode.101001": "发生未知错误,请稍后重试", + "errorCode.101002": "服务暂时不可用,请稍后重试", + "errorCode.101003": "数据库操作失败,请稍后重试", + "errorCode.101004": "操作超时,请稍后重试", + "errorCode.101005": "服务器内部错误,请稍后重试", + + "errorCode.102001": "您没有执行此操作的权限", + "errorCode.102002": "您的登录已过期,请重新登录", + "errorCode.102003": "登录令牌无效,请重新登录", + "errorCode.102004": "请求签名验证失败", + "errorCode.102005": "禁止访问", + + "errorCode.103001": "用户不存在", + "errorCode.103002": "用户注册失败,请稍后重试", + "errorCode.103003": "用户已存在", + "errorCode.103004": "用户名或密码错误", + + "errorCode.104001": "租户不存在", + "errorCode.104002": "租户已被禁用", + "errorCode.104003": "租户配置错误", + + "errorCode.105001": "智能体不存在", + "errorCode.105002": "运行智能体失败,请稍后重试", + "errorCode.105003": "智能体名称已存在", + "errorCode.105004": "智能体已被禁用", + "errorCode.105005": "智能体版本不存在", + + "errorCode.106001": "工具不存在", + "errorCode.106002": "工具执行失败", + "errorCode.106003": "工具配置无效", + "errorCode.106101": "连接MCP服务失败", + "errorCode.106102": "MCP名称包含非法字符", + "errorCode.106103": "MCP容器操作失败", + + "errorCode.107001": "对话不存在", + "errorCode.107002": "保存对话失败", + "errorCode.107003": "消息不存在", + "errorCode.107004": "生成对话标题失败", + + "errorCode.108001": "记忆不存在", + "errorCode.108002": "准备记忆失败", + "errorCode.108003": "记忆配置无效", + + "errorCode.109001": "知识库不存在", + "errorCode.109002": "同步知识库失败", + "errorCode.109003": "搜索索引不存在", + "errorCode.109004": "知识搜索失败", + "errorCode.109005": "上传知识失败", + + "errorCode.110001": "模型不存在", + "errorCode.110002": "模型配置无效", + "errorCode.110003": "模型健康检查失败", + "errorCode.110004": "模型提供商错误", + + "errorCode.111001": "语音服务错误", + "errorCode.111002": "连接语音识别服务失败", + "errorCode.111003": "连接语音合成服务失败", + "errorCode.111004": "语音配置无效", + + "errorCode.112001": "文件不存在", + "errorCode.112002": "文件上传失败", + "errorCode.112003": "文件大小超出限制", + "errorCode.112004": "不支持的文件类型", + "errorCode.112005": "文件预处理失败", + + "errorCode.113001": "邀请码不存在", + "errorCode.113002": "邀请码无效", + "errorCode.113003": "邀请码已过期", + + "errorCode.114001": "群组不存在", + "errorCode.114002": "群组已存在", + "errorCode.114003": "成员不在群组中", + + "errorCode.115001": "数据处理失败", + "errorCode.115002": "数据解析失败", + + "errorCode.130101": "连接DataMate服务失败", + "errorCode.130201": "Dify服务错误", + "errorCode.130202": "Dify配置无效,请检查URL和API Key格式", + "errorCode.130203": "连接Dify失败,请检查网络和URL", + "errorCode.130204": "Dify认证失败,请检查API Key", + "errorCode.130205": "Dify请求频率超限,请稍后重试", + "errorCode.130206": "Dify响应解析失败,请检查API URL", + "errorCode.130301": "连接ME服务失败", + + "errorCode.101": "验证失败", + "errorCode.102": "参数无效", + "errorCode.103": "缺少必填字段", + + "errorCode.201": "您没有执行此操作的权限", + "errorCode.202": "禁止访问", + "errorCode.203": "您的登录已过期,请重新登录", + "errorCode.204": "登录令牌无效,请重新登录", + + "errorCode.301": "外部服务错误", + "errorCode.302": "请求过于频繁,请稍后重试", + + "errorCode.401": "文件不存在", + "errorCode.402": "文件上传失败", + "errorCode.403": "文件大小超出限制", + "errorCode.404": "不支持的文件类型", + "errorCode.405": "文件预处理失败", + + "errorCode.501": "资源不存在", + "errorCode.502": "资源已存在", + "errorCode.503": "资源已被禁用", + + "errorCode.10101": "对话不存在", + "errorCode.10102": "消息不存在", + "errorCode.10103": "保存对话失败", + "errorCode.10104": "生成对话标题失败", + + "errorCode.20101": "配置无效", + "errorCode.20102": "同步配置失败", + + "errorCode.30101": "智能体不存在", + "errorCode.30102": "智能体已被禁用", + "errorCode.30103": "运行智能体失败,请稍后重试", + "errorCode.30104": "智能体名称已存在", + "errorCode.30105": "智能体版本不存在", + + "errorCode.40101": "市场中智能体不存在", + + "errorCode.50101": "智能体配置无效", + "errorCode.50102": "提示词无效", + + "errorCode.60101": "知识库不存在", + "errorCode.60102": "上传知识失败", + "errorCode.60103": "同步知识库失败", + "errorCode.60104": "搜索索引不存在", + "errorCode.60105": "知识搜索失败", + + "errorCode.70101": "工具不存在", + "errorCode.70102": "工具执行失败", + "errorCode.70103": "工具配置无效", + "errorCode.70201": "连接MCP服务失败", + "errorCode.70202": "MCP容器操作失败", + "errorCode.70301": "MCP名称包含非法字符", + + "errorCode.80101": "指标查询失败", + "errorCode.80201": "告警配置无效", + + "errorCode.90101": "模型不存在", + "errorCode.90102": "模型配置无效", + "errorCode.90103": "模型健康检查失败", + "errorCode.90104": "模型提供商错误", + + "errorCode.100101": "记忆不存在", + "errorCode.100102": "准备记忆失败", + "errorCode.100103": "记忆配置无效", + + "errorCode.110101": "用户不存在", + "errorCode.110102": "更新用户信息失败", + "errorCode.110103": "用户已存在", + "errorCode.110104": "用户名或密码错误", + + "errorCode.120101": "租户不存在", + "errorCode.120102": "租户已被禁用", + "errorCode.120103": "租户配置错误", + "errorCode.120104": "租户资源超限", + + "errorCode.140101": "北向接口请求失败", + "errorCode.140201": "北向接口配置无效", + + "errorCode.150101": "数据处理失败", + "errorCode.150102": "数据解析失败", + + "errorCode.990101": "发生未知错误,请稍后重试", + "errorCode.990102": "服务暂时不可用,请稍后重试", + "errorCode.990103": "数据库操作失败,请稍后重试", + "errorCode.990104": "操作超时,请稍后重试", + "errorCode.990105": "服务器内部错误,请稍后重试", + "errorCode.990201": "配置不存在", + "errorCode.990202": "配置更新失败" } diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 3237905ed..31174e830 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -1,4 +1,5 @@ import { STATUS_CODES } from "@/const/auth"; +import { ErrorCode } from "@/const/errorCode"; import { handleSessionExpired } from "@/lib/session"; import log from "@/lib/logger"; import type { MarketAgentListParams } from "@/types/market"; @@ -301,7 +302,7 @@ export const API_ENDPOINTS = { // Common error handling export class ApiError extends Error { constructor( - public code: number, + public code: string | number, message: string ) { super(message); @@ -319,20 +320,42 @@ export const fetchWithErrorHandling = async ( // Handle HTTP errors if (!response.ok) { - // Check if it's a session expired error (401) - if (response.status === 401) { + // Try to parse JSON response for business error code first + let errorCode = response.status; + let errorMessage = `Request failed: ${response.status}`; + const errorText = await response.text(); + + let parsedErrorData = null; + try { + const errorData = JSON.parse(errorText); + if (errorData && errorData.code) { + parsedErrorData = errorData; + errorCode = errorData.code; + errorMessage = errorData.message || errorMessage; + } else { + errorMessage = errorText || errorMessage; + } + } catch { + // Not JSON, use text as message + errorMessage = errorText || errorMessage; + } + + // Check if it's a session expiration error based on business error code + // TOKEN_EXPIRED = "000203", TOKEN_INVALID = "000204" + const errorCodeStr = String(errorCode); + if ( + errorCodeStr === ErrorCode.TOKEN_EXPIRED || + errorCodeStr === ErrorCode.TOKEN_INVALID + ) { handleSessionExpired(); - throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, - "Login expired, please login again" - ); + throw new ApiError(errorCode, errorMessage); } // Handle custom 499 error code (client closed connection) if (response.status === 499) { handleSessionExpired(); throw new ApiError( - STATUS_CODES.TOKEN_EXPIRED, + ErrorCode.TOKEN_EXPIRED, "Connection disconnected, session may have expired" ); } @@ -340,17 +363,12 @@ export const fetchWithErrorHandling = async ( // Handle request entity too large error (413) if (response.status === 413) { throw new ApiError( - STATUS_CODES.REQUEST_ENTITY_TOO_LARGE, - "REQUEST_ENTITY_TOO_LARGE" + ErrorCode.FILE_TOO_LARGE, + "File size exceeds limit." ); } - // Other HTTP errors - const errorText = await response.text(); - throw new ApiError( - response.status, - errorText || `Request failed: ${response.status}` - ); + throw new ApiError(errorCode, errorMessage); } return response; diff --git a/frontend/services/knowledgeBaseService.ts b/frontend/services/knowledgeBaseService.ts index 0fe76ced7..5558eabd0 100644 --- a/frontend/services/knowledgeBaseService.ts +++ b/frontend/services/knowledgeBaseService.ts @@ -2,7 +2,7 @@ import i18n from "i18next"; -import { API_ENDPOINTS } from "./api"; +import { API_ENDPOINTS, ApiError } from "./api"; import { NAME_CHECK_STATUS } from "@/const/agentConfig"; import { FILE_TYPES, EXTENSION_TO_TYPE_MAP } from "@/const/knowledgeBase"; @@ -53,32 +53,35 @@ class KnowledgeBaseService { count: number; indices_info: any[]; }> { - try { - // Call backend proxy endpoint to avoid CORS issues - const url = new URL(API_ENDPOINTS.dify.datasets, window.location.origin); - url.searchParams.set("dify_api_base", difyApiBase); - url.searchParams.set("api_key", apiKey); - - const response = await fetch(url.toString(), { - method: "GET", - headers: getAuthHeaders(), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to fetch Dify datasets"); - } - - const result = await response.json(); - return { - indices: result.indices || [], - count: result.count || 0, - indices_info: result.indices_info || [], - }; - } catch (error) { - log.error("Failed to sync Dify knowledge bases:", error); - throw error; + // Call backend proxy endpoint to avoid CORS issues + const url = new URL(API_ENDPOINTS.dify.datasets, window.location.origin); + url.searchParams.set("dify_api_base", difyApiBase); + url.searchParams.set("api_key", apiKey); + + const response = await fetch(url.toString(), { + method: "GET", + headers: getAuthHeaders(), + }); + + const result = await response.json(); + + // Check for error response from middleware (has code field) + if (result.code !== undefined && result.code !== 0) { + // Use backend error code and message + const errorCode = result.code || response.status; + const errorMessage = result.message || "Failed to fetch Dify datasets"; + log.error("Dify API error:", { code: errorCode, message: errorMessage }); + + // Use ApiError for proper error handling with i18n support + throw new ApiError(errorCode, errorMessage); } + + // Success: result is directly the data (indices, count, indices_info) + return { + indices: result.indices || [], + count: result.count || 0, + indices_info: result.indices_info || [], + }; } // Get Dify knowledge bases as KnowledgeBase array diff --git a/test/backend/app/test_app_factory.py b/test/backend/app/test_app_factory.py new file mode 100644 index 000000000..0e46cae7b --- /dev/null +++ b/test/backend/app/test_app_factory.py @@ -0,0 +1,701 @@ +""" +Unit tests for app_factory module. + +Tests the create_app function and register_exception_handlers function +for FastAPI application factory with common configurations and exception handlers. +""" +import sys +import os + +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +# Add the backend directory to path so we can import modules +backend_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), '../../../backend')) +sys.path.insert(0, backend_path) + +# Import AppException from consts.exceptions where it is defined +from consts.error_code import ErrorCode +from consts.exceptions import AppException +from backend.apps.app_factory import create_app, register_exception_handlers + +class TestCreateApp: + """Test class for create_app function.""" + + def test_create_app_default_parameters(self): + """Test creating app with default parameters.""" + app = create_app() + + assert app is not None + assert isinstance(app, FastAPI) + assert app.title == "Nexent API" + assert app.version == "1.0.0" + assert app.root_path == "/api" + + def test_create_app_custom_title(self): + """Test creating app with custom title.""" + app = create_app(title="Custom API") + + assert app.title == "Custom API" + + def test_create_app_custom_description(self): + """Test creating app with custom description.""" + app = create_app(description="Custom description") + + assert app.description == "Custom description" + + def test_create_app_custom_version(self): + """Test creating app with custom version.""" + app = create_app(version="2.0.0") + + assert app.version == "2.0.0" + + def test_create_app_custom_root_path(self): + """Test creating app with custom root path.""" + app = create_app(root_path="/custom") + + assert app.root_path == "/custom" + + def test_create_app_custom_cors_origins(self): + """Test creating app with custom CORS origins.""" + custom_origins = ["https://example.com", "https://api.example.com"] + app = create_app(cors_origins=custom_origins) + + assert app is not None + + def test_create_app_custom_cors_methods(self): + """Test creating app with custom CORS methods.""" + custom_methods = ["GET", "POST", "PUT", "DELETE"] + app = create_app(cors_methods=custom_methods) + + assert app is not None + + def test_create_app_with_monitoring_disabled(self): + """Test creating app with monitoring disabled.""" + app = create_app(enable_monitoring=False) + + assert app is not None + assert isinstance(app, FastAPI) + + def test_create_app_with_monitoring_enabled(self): + """Test creating app with monitoring enabled.""" + app = create_app(enable_monitoring=True) + + assert app is not None + assert isinstance(app, FastAPI) + + def test_create_app_all_parameters(self): + """Test creating app with all parameters.""" + app = create_app( + title="Full Test API", + description="Full description", + version="3.0.0", + root_path="/v3", + cors_origins=["https://test.com"], + cors_methods=["GET", "POST"], + enable_monitoring=True + ) + + assert app.title == "Full Test API" + assert app.description == "Full description" + assert app.version == "3.0.0" + assert app.root_path == "/v3" + + +class TestRegisterExceptionHandlers: + """Test class for register_exception_handlers function.""" + + def test_register_exception_handlers_basic(self): + """Test registering exception handlers on a basic FastAPI app.""" + app = FastAPI() + register_exception_handlers(app) + + assert app is not None + + def test_http_exception_handler(self): + """Test HTTPException handler returns correct response.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-http-exception") + def raise_http_exception(): + raise HTTPException(status_code=404, detail="Not found") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-http-exception") + + assert response.status_code == 404 + assert response.json() == {"message": "Not found"} + + def test_http_exception_handler_400(self): + """Test HTTPException handler with 400 status.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-bad-request") + def raise_bad_request(): + raise HTTPException(status_code=400, detail="Bad request") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-bad-request") + + assert response.status_code == 400 + assert response.json() == {"message": "Bad request"} + + def test_http_exception_handler_500(self): + """Test HTTPException handler with 500 status.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-server-error") + def raise_server_error(): + raise HTTPException( + status_code=500, detail="Internal server error") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-server-error") + + assert response.status_code == 500 + assert response.json() == {"message": "Internal server error"} + + def test_app_exception_handler(self): + """Test AppException handler returns correct response.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-app-exception") + def raise_app_exception(): + raise AppException( + ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-app-exception") + + assert response.status_code == 400 + assert response.json()[ + "code"] == ErrorCode.COMMON_VALIDATION_ERROR.value + assert response.json()["message"] == "Validation failed" + + def test_app_exception_handler_with_details(self): + """Test AppException handler with details returns correct response.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-app-exception-details") + def raise_app_exception_with_details(): + raise AppException( + ErrorCode.MCP_CONNECTION_FAILED, + "Connection failed", + details={"host": "localhost", "port": 8080} + ) + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-app-exception-details") + + # MCP_CONNECTION_FAILED maps to 500 by default + assert response.status_code == 500 + assert response.json()["code"] == ErrorCode.MCP_CONNECTION_FAILED.value + assert response.json()["details"] == { + "host": "localhost", "port": 8080} + + def test_app_exception_handler_unauthorized(self): + """Test AppException handler with UNAUTHORIZED error code.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-unauthorized") + def raise_unauthorized(): + raise AppException(ErrorCode.COMMON_UNAUTHORIZED, + "Unauthorized access") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-unauthorized") + + assert response.status_code == 401 + + def test_app_exception_handler_forbidden(self): + """Test AppException handler with FORBIDDEN error code.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-forbidden") + def raise_forbidden(): + raise AppException(ErrorCode.COMMON_FORBIDDEN, "Access forbidden") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-forbidden") + + assert response.status_code == 403 + + def test_app_exception_handler_rate_limit(self): + """Test AppException handler with RATE_LIMIT_EXCEEDED error code.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-rate-limit") + def raise_rate_limit(): + raise AppException(ErrorCode.COMMON_RATE_LIMIT_EXCEEDED, + "Too many requests") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-rate-limit") + + assert response.status_code == 429 + + def test_generic_exception_handler(self): + """Test generic Exception handler returns correct response.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-generic-exception") + def raise_generic_exception(): + raise RuntimeError("Something went wrong") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-generic-exception") + + assert response.status_code == 500 + assert response.json() == { + "message": "Internal server error, please try again later."} + + def test_generic_exception_handler_value_error(self): + """Test generic Exception handler with ValueError.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-value-error") + def raise_value_error(): + raise ValueError("Invalid value") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-value-error") + + assert response.status_code == 500 + + def test_app_exception_takes_precedence_in_generic_handler(self): + """Test that AppException is handled by its own handler, not generic.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-app-exception-in-generic") + def raise_app_exception(): + # This should be handled by AppException handler, not generic + # Use VALIDATION_ERROR which maps to 400 + raise AppException( + ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-app-exception-in-generic") + + # Should return 400 (mapped from VALIDATION_ERROR) + assert response.status_code == 400 + assert response.json()[ + "code"] == ErrorCode.COMMON_VALIDATION_ERROR.value + + +class TestExceptionMappingToHttpStatus: + """Test class for exception mapping to HTTP status codes.""" + + def test_validation_error_maps_to_400(self): + """Test VALIDATION_ERROR maps to 400.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/validation-error") + def test_validation(): + raise AppException( + ErrorCode.COMMON_VALIDATION_ERROR, "Invalid input") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/validation-error") + + assert response.status_code == 400 + + def test_parameter_invalid_maps_to_400(self): + """Test PARAMETER_INVALID maps to 400.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/parameter-invalid") + def test_param(): + raise AppException(ErrorCode.COMMON_PARAMETER_INVALID, + "Invalid parameter") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/parameter-invalid") + + assert response.status_code == 400 + + def test_missing_required_field_maps_to_400(self): + """Test MISSING_REQUIRED_FIELD maps to 400.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/missing-field") + def test_missing(): + raise AppException( + ErrorCode.COMMON_MISSING_REQUIRED_FIELD, "Field missing") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/missing-field") + + assert response.status_code == 400 + + def test_file_too_large_maps_to_413(self): + """Test FILE_TOO_LARGE maps to 413.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/file-too-large") + def test_file(): + raise AppException(ErrorCode.FILE_TOO_LARGE, "File too large") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/file-too-large") + + assert response.status_code == 413 + + +class TestMonitoringIntegration: + """Test class for monitoring integration.""" + + def test_monitoring_enabled_does_not_raise(self): + """Test that create_app with enable_monitoring=True does not raise.""" + # This tests that the monitoring code path runs without error + # Even if monitoring is not available, it should be caught gracefully + app = create_app(enable_monitoring=True) + + assert app is not None + assert isinstance(app, FastAPI) + + def test_monitoring_disabled_does_not_raise(self): + """Test that create_app with enable_monitoring=False does not raise.""" + app = create_app(enable_monitoring=False) + + assert app is not None + assert isinstance(app, FastAPI) + + def test_monitoring_with_actual_module(self): + """Test that create_app works when monitoring module is available.""" + # Test with monitoring disabled to avoid actual module dependency + app = create_app(enable_monitoring=False) + + assert app is not None + # Verify basic app attributes are set correctly + assert app.title == "Nexent API" + assert app.version == "1.0.0" + assert app.root_path == "/api" + + +class TestCORSConfiguration: + """Test class for CORS configuration.""" + + def test_cors_middleware_added_with_default_origins(self): + """Test CORS middleware is added with default origins.""" + app = create_app() + + # Verify CORS middleware is in the middleware stack + # FastAPI adds middleware as wrappers + middleware_stack = app.user_middleware + assert len(middleware_stack) > 0 + + def test_cors_middleware_added_with_custom_origins(self): + """Test CORS middleware is added with custom origins.""" + custom_origins = ["https://example.com"] + app = create_app(cors_origins=custom_origins) + + assert app is not None + + def test_cors_middleware_with_credentials(self): + """Test CORS middleware allows credentials.""" + app = create_app() + + # Middleware should be added + assert app is not None + + +class TestAppExceptionResponseFormat: + """Test class for AppException response format.""" + + def test_response_contains_code_field(self): + """Test response contains 'code' field.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-code-field") + def test_code(): + raise AppException( + ErrorCode.AGENTSPACE_AGENT_NOT_FOUND, "Agent not found") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-code-field") + + assert "code" in response.json() + + def test_response_contains_message_field(self): + """Test response contains 'message' field.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-message-field") + def test_message(): + raise AppException( + ErrorCode.AGENTSPACE_AGENT_NOT_FOUND, "Agent not found") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-message-field") + + assert "message" in response.json() + + def test_response_details_is_none_when_not_provided(self): + """Test response details is None when not provided.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-no-details") + def test_no_details(): + raise AppException( + ErrorCode.COMMON_VALIDATION_ERROR, "Validation failed") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-no-details") + + # details should be None when not provided + assert response.json().get("details") is None + + +class TestMultipleExceptionHandlers: + """Test class for multiple exception handlers in same app.""" + + def test_multiple_routes_with_different_exceptions(self): + """Test app handles different exception types from different routes.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/http-exc") + def route_http(): + raise HTTPException(status_code=400, detail="HTTP error") + + @app.get("/app-exc") + def route_app(): + raise AppException(ErrorCode.COMMON_VALIDATION_ERROR, "App error") + + @app.get("/gen-exc") + def route_gen(): + raise Exception("Generic error") + + client = TestClient(app, raise_server_exceptions=False) + + assert client.get("/http-exc").status_code == 400 + assert client.get("/app-exc").status_code == 400 + assert client.get("/gen-exc").status_code == 500 + + +class TestMonitoringImportFailure: + """Test class for monitoring import failure scenarios. + + Tests the logger.warning when monitoring utilities are not available. + """ + + def test_create_app_monitoring_import_failure_logs_warning(self): + """Test that create_app logs warning when monitoring module import fails.""" + import logging + from unittest.mock import patch, MagicMock + + # Mock the monitoring module to raise ImportError + with patch.dict('sys.modules', {'utils.monitoring': None}): + with patch('backend.apps.app_factory.logger') as mock_logger: + # Create app with monitoring enabled - import will fail + app = create_app(enable_monitoring=True) + + # Verify logger.warning was called with expected message + mock_logger.warning.assert_called_once_with( + "Monitoring utilities not available" + ) + + assert app is not None + assert isinstance(app, FastAPI) + + def test_create_app_monitoring_disabled_no_warning(self): + """Test that no warning is logged when monitoring is disabled.""" + from unittest.mock import patch + + with patch('backend.apps.app_factory.logger') as mock_logger: + app = create_app(enable_monitoring=False) + + # Warning should not be called when monitoring is disabled + mock_logger.warning.assert_not_called() + + assert app is not None + + def test_create_app_monitoring_import_error_specific_exception(self): + """Test that create_app handles ImportError specifically.""" + from unittest.mock import patch, MagicMock + + # Create a mock monitoring module that raises ImportError when accessed + mock_module = MagicMock() + mock_module.monitoring_manager = MagicMock() + mock_module.monitoring_manager.setup_fastapi_app.side_effect = ImportError( + "No module named 'monitoring'" + ) + + with patch.dict('sys.modules', {'utils.monitoring': mock_module}): + with patch('backend.apps.app_factory.logger') as mock_logger: + app = create_app(enable_monitoring=True) + + # Should log warning about monitoring utilities not available + mock_logger.warning.assert_called_with( + "Monitoring utilities not available" + ) + + assert app is not None + + +class TestGenericExceptionHandlerAppExceptionCheck: + """Test class for generic exception handler's AppException check. + + Tests the logic that prevents AppException from being caught + by the generic Exception handler. + """ + + def test_generic_handler_does_not_catch_app_exception_with_different_codes(self): + """Test that generic handler does not catch AppException for various error codes.""" + from fastapi.testclient import TestClient + from fastapi import FastAPI + + # Test multiple AppException error codes to ensure they're all handled correctly + error_codes_to_test = [ + (ErrorCode.COMMON_VALIDATION_ERROR, 400), + (ErrorCode.COMMON_UNAUTHORIZED, 401), + (ErrorCode.COMMON_FORBIDDEN, 403), + (ErrorCode.COMMON_RESOURCE_NOT_FOUND, 404), + (ErrorCode.COMMON_RATE_LIMIT_EXCEEDED, 429), + ] + + for error_code, expected_status in error_codes_to_test: + app = FastAPI() + register_exception_handlers(app) + + @app.get(f"/test-{error_code.value}") + def raise_specific_app_exception(): + raise AppException(error_code, f"Error {error_code.value}") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get(f"/test-{error_code.value}") + + # Each AppException should be handled by its specific handler, not generic + assert response.status_code == expected_status, \ + f"Expected {expected_status} for {error_code.value}, got {response.status_code}" + assert "code" in response.json() + assert response.json()["code"] == error_code.value + + def test_generic_handler_does_catch_non_app_exception(self): + """Test that generic handler correctly catches non-AppException errors.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-runtime-error") + def raise_runtime_error(): + raise RuntimeError("Runtime error occurred") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-runtime-error") + + # Generic exception handler should catch this + assert response.status_code == 500 + assert response.json()[ + "message"] == "Internal server error, please try again later." + + def test_generic_handler_does_catch_value_error(self): + """Test that generic handler catches ValueError.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-value-error") + def raise_value_error(): + raise ValueError("Invalid value provided") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-value-error") + + # Generic exception handler should catch ValueError + assert response.status_code == 500 + assert response.json()[ + "message"] == "Internal server error, please try again later." + + def test_generic_handler_does_catch_type_error(self): + """Test that generic handler catches TypeError.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-type-error") + def raise_type_error(): + raise TypeError("Type mismatch") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-type-error") + + # Generic exception handler should catch TypeError + assert response.status_code == 500 + + def test_app_exception_with_custom_http_status(self): + """Test AppException with custom HTTP status is handled correctly.""" + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-custom-status") + def raise_custom_status(): + # Use an error code that maps to a different status + raise AppException(ErrorCode.DIFY_SERVICE_ERROR, + "Dify service error") + + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-custom-status") + + # DIFY_SERVICE_ERROR is not in the mapping, so it defaults to 500 + # This test verifies that custom error codes still work correctly + assert response.status_code == 500 + assert response.json()["code"] == ErrorCode.DIFY_SERVICE_ERROR.value + + def test_both_exception_handlers_registered(self): + """Test that both AppException and generic Exception handlers are registered.""" + app = FastAPI() + register_exception_handlers(app) + + # Check that exception handlers are registered + exception_handlers = app.exception_handlers + + # Both HTTPException and Exception handlers should be registered + assert HTTPException in exception_handlers + assert Exception in exception_handlers + + def test_app_exception_not_duplicated_in_generic_handler_logs(self): + """Test that AppException is not logged as generic exception.""" + import logging + from unittest.mock import patch + + app = FastAPI() + register_exception_handlers(app) + + @app.get("/test-app-exc") + def raise_app_exc(): + raise AppException( + ErrorCode.COMMON_VALIDATION_ERROR, "Validation error") + + # Use capture to check logging + with patch('backend.apps.app_factory.logger') as mock_logger: + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/test-app-exc") + + # AppException should NOT trigger the generic exception logger + # It should go through the app_exception_handler which also logs + # But the generic handler should NOT log it as "Generic Exception" + assert response.status_code == 400 + + # Verify the AppException handler logged it (not generic) + # The AppException handler logs: f"AppException: {exc.error_code.value} - {exc.message}" + app_exception_logged = any( + "AppException:" in str(call) for call in mock_logger.error.call_args_list + ) + assert app_exception_logged, "AppException should be logged by app_exception_handler" diff --git a/test/backend/app/test_config_app.py b/test/backend/app/test_config_app.py index 87ca2b959..a4dd4566b 100644 --- a/test/backend/app/test_config_app.py +++ b/test/backend/app/test_config_app.py @@ -1,8 +1,13 @@ +import pytest import unittest from unittest.mock import patch, MagicMock, Mock import sys import os +from fastapi import HTTPException +from fastapi.testclient import TestClient +import atexit + # Add the backend directory to path so we can import modules backend_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../backend')) sys.path.insert(0, backend_path) @@ -23,8 +28,10 @@ # before any module imports that might trigger MinioClient initialization critical_patches = [ # Patch storage factory and MinIO config validation FIRST - patch('nexent.storage.storage_client_factory.create_storage_client_from_config', return_value=storage_client_mock), - patch('nexent.storage.minio_config.MinIOStorageConfig.validate', lambda self: None), + patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock), + patch('nexent.storage.minio_config.MinIOStorageConfig.validate', + lambda self: None), # Mock boto3 client patch('boto3.client', return_value=Mock()), # Mock boto3 resource @@ -54,27 +61,30 @@ # After import, we can patch get_db_session if needed try: from backend.database import client as db_client_module + # Patch get_db_session after module is imported - db_session_patch = patch.object(db_client_module, 'get_db_session', return_value=Mock()) + db_session_patch = patch.object( + db_client_module, 'get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) except ImportError: # If import fails, try patching the path directly (may trigger import) - db_session_patch = patch('backend.database.client.get_db_session', return_value=Mock()) + db_session_patch = patch( + 'backend.database.client.get_db_session', return_value=Mock()) db_session_patch.start() all_patches.append(db_session_patch) -# Now safe to import app modules -from fastapi import HTTPException -from fastapi.testclient import TestClient +# Now safe to import app modules - imports moved after patches from apps.config_app import app - # Stop all patches at the end of the module -import atexit + + def stop_patches(): for p in all_patches: p.stop() + + atexit.register(stop_patches) @@ -94,9 +104,9 @@ def test_cors_middleware(self): if middleware.cls.__name__ == "CORSMiddleware": cors_middleware = middleware break - + self.assertIsNotNone(cors_middleware) - + # In FastAPI, middleware options are stored in 'middleware.kwargs' self.assertEqual(cors_middleware.kwargs.get("allow_origins"), ["*"]) self.assertTrue(cors_middleware.kwargs.get("allow_credentials")) @@ -107,31 +117,10 @@ def test_routers_included(self): """Test that all routers are included in the app.""" # Get all routes in the app routes = [route.path for route in app.routes] - + # Check if routes exist (at least some routes should be present) self.assertTrue(len(routes) > 0) - def test_http_exception_handler(self): - """Test the HTTP exception handler.""" - # Test that the exception handler is registered - exception_handlers = app.exception_handlers - self.assertIn(HTTPException, exception_handlers) - - # Test that the handler function exists and is callable - http_exception_handler = exception_handlers[HTTPException] - self.assertIsNotNone(http_exception_handler) - self.assertTrue(callable(http_exception_handler)) - - def test_generic_exception_handler(self): - """Test the generic exception handler.""" - # Test that the exception handler is registered - exception_handlers = app.exception_handlers - self.assertIn(Exception, exception_handlers) - - # Test that the handler function exists and is callable - generic_exception_handler = exception_handlers[Exception] - self.assertTrue(callable(generic_exception_handler)) - def test_exception_handling_with_client(self): """Test exception handling using the test client.""" # This test requires mocking an endpoint that raises an exception @@ -178,7 +167,8 @@ def test_all_routers_included(self): 'file_manager_router', 'proxy_router', 'tool_config_router', - 'mock_user_management_router', # or 'user_management_router' depending on IS_SPEED_MODE + # or 'user_management_router' depending on IS_SPEED_MODE + 'mock_user_management_router', 'summary_router', 'prompt_router', 'tenant_config_router', @@ -197,19 +187,8 @@ def test_all_routers_included(self): # Since it's hard to identify routers directly from routes, # we'll check that we have a reasonable number of routes - self.assertGreater(len(app.routes), 10) # Should have many routes from all routers - - def test_http_exception_handler_registration(self): - """Test that HTTP exception handler is properly registered.""" - # Test that the exception handler exists in the app - exception_handlers = app.exception_handlers - self.assertIn(HTTPException, exception_handlers) - - def test_generic_exception_handler_registration(self): - """Test that generic exception handler is properly registered.""" - # Test that the exception handler exists in the app - exception_handlers = app.exception_handlers - self.assertIn(Exception, exception_handlers) + # Should have many routes from all routers + self.assertGreater(len(app.routes), 10) if __name__ == "__main__": diff --git a/test/backend/app/test_dify_app.py b/test/backend/app/test_dify_app.py index ad9c8c53c..63395a868 100644 --- a/test/backend/app/test_dify_app.py +++ b/test/backend/app/test_dify_app.py @@ -132,7 +132,8 @@ async def test_fetch_dify_datasets_api_success(self, dify_mocks): response_body = json.loads(result.body.decode()) assert response_body == expected_result - dify_mocks['get_current_user_id'].assert_called_once_with(mock_auth_header) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function dify_mocks['fetch_dify'].assert_called_once_with( dify_api_base=dify_api_base.rstrip('/'), api_key=api_key @@ -172,28 +173,35 @@ async def test_fetch_dify_datasets_api_url_normalization(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_auth_error(self, dify_mocks): """Test endpoint with authentication error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer invalid-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" # Mock authentication failure - dify_mocks['get_current_user_id'].side_effect = Exception("Invalid token") + dify_mocks['get_current_user_id'].side_effect = Exception( + "Invalid token") - # Execute and Assert - with pytest.raises(HTTPException) as exc_info: + # Execute and Assert - the code catches Exception and converts to AppException + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - dify_mocks['logger'].error.assert_not_called() + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks): """Test endpoint with service layer validation error (ValueError).""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "" @@ -201,21 +209,24 @@ async def test_fetch_dify_datasets_api_service_validation_error(self, dify_mocks dify_mocks['get_current_user_id'].return_value = ( "test_user_id", "test_tenant_id" ) - dify_mocks['fetch_dify'].side_effect = ValueError("api_key is required") + dify_mocks['fetch_dify'].side_effect = ValueError( + "api_key is required") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.BAD_REQUEST - assert "api_key is required" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR @pytest.mark.asyncio async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): """Test endpoint with general service layer error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -223,23 +234,27 @@ async def test_fetch_dify_datasets_api_service_error(self, dify_mocks): dify_mocks['get_current_user_id'].return_value = ( "test_user_id", "test_tenant_id" ) - dify_mocks['fetch_dify'].side_effect = Exception("Dify API connection failed") + dify_mocks['fetch_dify'].side_effect = Exception( + "Dify API connection failed") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) - assert "Dify API connection failed" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + assert "Dify API connection failed" in str(exc_info.value.message) dify_mocks['logger'].error.assert_called() @pytest.mark.asyncio async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks): """Test endpoint when service raises HTTP-related exception.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -248,21 +263,25 @@ async def test_fetch_dify_datasets_api_http_error_from_service(self, dify_mocks) "test_user_id", "test_tenant_id" ) # Simulate HTTP error from service - dify_mocks['fetch_dify'].side_effect = Exception("Dify API HTTP error: 404 Not Found") + dify_mocks['fetch_dify'].side_effect = Exception( + "Dify API HTTP error: 404 Not Found") - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_mocks): """Test endpoint when service raises request error.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -271,17 +290,21 @@ async def test_fetch_dify_datasets_api_request_error_from_service(self, dify_moc "test_user_id", "test_tenant_id" ) # Simulate request error from service - dify_mocks['fetch_dify'].side_effect = Exception("Dify API request failed: Connection refused") + dify_mocks['fetch_dify'].side_effect = Exception( + "Dify API request failed: Connection refused") + + from consts.exceptions import AppException + from consts.error_code import ErrorCode - with pytest.raises(HTTPException) as exc_info: + with pytest.raises(AppException) as exc_info: await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, authorization=mock_auth_header ) - assert exc_info.value.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - assert "Failed to fetch Dify datasets" in str(exc_info.value.detail) + assert exc_info.value.error_code == ErrorCode.DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) @pytest.mark.asyncio async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): @@ -297,7 +320,7 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): "pagination": {"embedding_available": False} } - # Mock user and tenant ID for None auth + # Mock user and tenant ID for None auth (even though it's not used in the current implementation) dify_mocks['get_current_user_id'].return_value = ( "default_user", "default_tenant" ) @@ -312,7 +335,8 @@ async def test_fetch_dify_datasets_api_none_auth_header(self, dify_mocks): assert isinstance(result, JSONResponse) assert result.status_code == HTTPStatus.OK - dify_mocks['get_current_user_id'].assert_called_once_with(None) + # Note: get_current_user_id is imported but not used in dify_app.py + # The test verifies the actual behavior of the function @pytest.mark.asyncio async def test_fetch_dify_datasets_api_empty_result(self, dify_mocks): @@ -457,6 +481,8 @@ async def test_fetch_dify_datasets_api_logger_info_call(self, dify_mocks): @pytest.mark.asyncio async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): """Test that endpoint logs errors appropriately.""" + from consts.exceptions import AppException + mock_auth_header = "Bearer test-token" dify_api_base = "https://dify.example.com" api_key = "test-api-key" @@ -466,7 +492,7 @@ async def test_fetch_dify_datasets_api_logger_error_call(self, dify_mocks): ) dify_mocks['fetch_dify'].side_effect = Exception("Connection timeout") - with pytest.raises(HTTPException): + with pytest.raises(AppException): await fetch_dify_datasets_api( dify_api_base=dify_api_base, api_key=api_key, @@ -557,3 +583,474 @@ def test_router_has_datasets_endpoint(self): routes = [route.path for route in router.routes] # Router prefix is /dify, and route is /datasets, so full path is /dify/datasets assert "/dify/datasets" in routes + + +class TestDifyAppExceptionHandlers: + """Test exception handlers in dify_app.py""" + + def test_dify_app_exception_handler_functions_exist(self): + """Test that dify_app module can import exception handlers if defined.""" + # dify_app.py doesn't define its own exception handlers, + # it relies on the global middleware in config_app.py + # This test verifies the module structure + from backend.apps import dify_app + from backend.apps.dify_app import router, logger, fetch_dify_datasets_api + + # Verify router exists + assert router is not None + # Verify logger exists + assert logger is not None + # Verify endpoint function exists + assert fetch_dify_datasets_api is not None + + @pytest.mark.asyncio + async def test_dify_app_logs_service_error(self, dify_mocks): + """Test that service errors are logged and converted to AppException.""" + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + dify_mocks['get_current_user_id'].return_value = ( + "test_user_id", "test_tenant_id" + ) + + # Test with service error + dify_mocks['fetch_dify'].side_effect = Exception( + "URL connection error") + + from consts.exceptions import AppException + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify it's a DIFY_SERVICE_ERROR + assert "Failed to fetch Dify datasets" in str(exc_info.value.message) + dify_mocks['logger'].error.assert_called() + + +class TestFetchDifyDatasetsApiConfigValidation: + """Test class for fetch_dify_datasets_api endpoint configuration validation. + + Tests the first try-except block that handles invalid Dify configuration + (e.g., when dify_api_base.rstrip('/') fails due to invalid input). + """ + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_invalid_dify_api_base_none(self): + """Test endpoint raises DIFY_CONFIG_INVALID when dify_api_base is None.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = None + api_key = "test-api-key" + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONFIG_INVALID + assert "Invalid URL format" in str(exc_info.value.message) + mock_logger.error.assert_called() + mock_fetch_dify.assert_not_called() + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_invalid_dify_api_base_integer(self): + """Test endpoint raises DIFY_CONFIG_INVALID when dify_api_base is an integer.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = 12345 # Invalid type - should be string + api_key = "test-api-key" + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONFIG_INVALID + assert "Invalid URL format" in str(exc_info.value.message) + mock_logger.error.assert_called() + mock_fetch_dify.assert_not_called() + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_invalid_dify_api_base_object(self): + """Test endpoint raises DIFY_CONFIG_INVALID when dify_api_base is an object without rstrip.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + # Invalid type - should be string + dify_api_base = {"url": "https://dify.example.com"} + api_key = "test-api-key" + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONFIG_INVALID + mock_logger.error.assert_called() + mock_fetch_dify.assert_not_called() + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_invalid_dify_api_base_list(self): + """Test endpoint raises DIFY_CONFIG_INVALID when dify_api_base is a list.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + # Invalid type - should be string + dify_api_base = ["https://dify.example.com"] + api_key = "test-api-key" + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONFIG_INVALID + mock_logger.error.assert_called() + mock_fetch_dify.assert_not_called() + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_dify_config_invalid_logs_error_message(self): + """Test that DIFY_CONFIG_INVALID error logs the actual exception message.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + # This will cause AttributeError: 'NoneType' object has no attribute 'rstrip' + dify_api_base = None + api_key = "test-api-key" + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify logger was called with the error + mock_logger.error.assert_called_once() + call_args = mock_logger.error.call_args + assert "Invalid Dify configuration" in call_args[0][0] + assert "'NoneType' object has no attribute 'rstrip'" in call_args[ + 0][0] or "NoneType" in call_args[0][0] + + @pytest.mark.asyncio + async def test_fetch_dify_datasets_api_success_after_config_validation(self): + """Test endpoint succeeds when config validation passes (valid string input).""" + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + expected_result = { + "indices": ["ds-1"], + "count": 1, + "indices_info": [], + "pagination": {"embedding_available": True} + } + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.return_value = expected_result + + result = await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert isinstance(result, JSONResponse) + assert result.status_code == HTTPStatus.OK + # Verify the service was called with normalized URL + mock_fetch_dify.assert_called_once_with( + dify_api_base="https://dify.example.com", + api_key=api_key + ) + + +class TestAppExceptionReRaising: + """Test class for AppException re-raising to global middleware. + + Tests the except AppException: raise block that propagates AppException + from the service layer to be handled by global middleware. + """ + + @pytest.mark.asyncio + async def test_service_raises_app_exception_re_raised_to_middleware(self): + """Test that AppException from service is re-raised for global middleware.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # Create an AppException that the service would raise + service_exception = AppException( + ErrorCode.DIFY_CONNECTION_ERROR, + "Failed to connect to Dify API" + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # The AppException should be re-raised (not converted) + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify the original AppException is re-raised with its original error code + assert exc_info.value.error_code == ErrorCode.DIFY_CONNECTION_ERROR + assert "Failed to connect to Dify API" in str( + exc_info.value.message) + + @pytest.mark.asyncio + async def test_service_raises_dify_config_invalid_app_exception(self): + """Test that DIFY_CONFIG_INVALID AppException from service is re-raised.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # Simulate service raising DIFY_CONFIG_INVALID + service_exception = AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Invalid Dify API key format" + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # Should re-raise the AppException + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONFIG_INVALID + + @pytest.mark.asyncio + async def test_service_raises_dify_auth_error_app_exception(self): + """Test that DIFY_AUTH_ERROR AppException from service is re-raised.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # Simulate service raising DIFY_AUTH_ERROR + service_exception = AppException( + ErrorCode.DIFY_AUTH_ERROR, + "Invalid API key provided" + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # Should re-raise the AppException + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_AUTH_ERROR + + @pytest.mark.asyncio + async def test_service_raises_app_exception_with_details(self): + """Test that AppException with details from service is re-raised.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # AppException with details + service_exception = AppException( + ErrorCode.DIFY_CONNECTION_ERROR, + "Connection failed", + details={"host": "dify.example.com", "port": 443} + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # Should re-raise the AppException with details preserved + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_CONNECTION_ERROR + assert exc_info.value.details == { + "host": "dify.example.com", "port": 443} + + @pytest.mark.asyncio + async def test_service_raises_dify_rate_limit_app_exception(self): + """Test that DIFY_RATE_LIMIT AppException from service is re-raised.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # Simulate service raising DIFY_RATE_LIMIT + service_exception = AppException( + ErrorCode.DIFY_RATE_LIMIT, + "Rate limit exceeded" + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # Should re-raise the AppException + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + assert exc_info.value.error_code == ErrorCode.DIFY_RATE_LIMIT + + @pytest.mark.asyncio + async def test_app_exception_not_wrapped_or_converted(self): + """Test that AppException is not wrapped or converted to another exception.""" + from consts.exceptions import AppException + from consts.error_code import ErrorCode + + mock_auth_header = "Bearer test-token" + dify_api_base = "https://dify.example.com" + api_key = "test-api-key" + + # Use a non-Dify error code to verify it's not converted + service_exception = AppException( + ErrorCode.COMMON_UNAUTHORIZED, + "Unauthorized access" + ) + + with patch('backend.apps.dify_app.get_current_user_id') as mock_get_current_user_id, \ + patch('backend.apps.dify_app.fetch_dify_datasets_impl') as mock_fetch_dify, \ + patch('backend.apps.dify_app.logger') as mock_logger: + + mock_get_current_user_id.return_value = ( + "test_user_id", "test_tenant_id" + ) + mock_fetch_dify.side_effect = service_exception + + # Should re-raise the exact same AppException + with pytest.raises(AppException) as exc_info: + await fetch_dify_datasets_api( + dify_api_base=dify_api_base, + api_key=api_key, + authorization=mock_auth_header + ) + + # Verify it's the exact same exception instance (not a new one) + assert exc_info.value is service_exception + assert exc_info.value.error_code == ErrorCode.COMMON_UNAUTHORIZED diff --git a/test/backend/app/test_northbound_base_app.py b/test/backend/app/test_northbound_base_app.py index 4316ddd64..56a798916 100644 --- a/test/backend/app/test_northbound_base_app.py +++ b/test/backend/app/test_northbound_base_app.py @@ -71,6 +71,27 @@ class SignatureValidationError(Exception): # Provide 'consts.exceptions' stub so that northbound_base_app import succeeds # --------------------------------------------------------------------------- consts_exceptions_module = types.ModuleType("consts.exceptions") + + +class AppException(Exception): + """Dummy AppException for testing purposes.""" + pass + + +class LimitExceededError(Exception): + """Dummy rate-limit exception for testing purposes.""" + pass + +class UnauthorizedError(Exception): + """Dummy unauthorized exception for testing purposes.""" + pass + +class SignatureValidationError(Exception): + """Dummy signature validation exception for testing purposes.""" + pass + + +consts_exceptions_module.AppException = AppException consts_exceptions_module.LimitExceededError = LimitExceededError consts_exceptions_module.UnauthorizedError = UnauthorizedError consts_exceptions_module.SignatureValidationError = SignatureValidationError @@ -140,4 +161,4 @@ def test_dummy_endpoint_success(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/test/backend/consts/test_error_code.py b/test/backend/consts/test_error_code.py new file mode 100644 index 000000000..11e8aea80 --- /dev/null +++ b/test/backend/consts/test_error_code.py @@ -0,0 +1,353 @@ +""" +Unit tests for Error Code definitions. + +Tests the ErrorCode enum and ERROR_CODE_HTTP_STATUS mapping +to ensure error codes are properly defined and mapped. +""" +import pytest +from backend.consts.error_code import ErrorCode, ERROR_CODE_HTTP_STATUS + + +class TestErrorCodeEnum: + """Test class for ErrorCode enum values.""" + + def test_dify_error_codes_exist(self): + """Test that all Dify-related error codes are defined.""" + assert ErrorCode.DIFY_SERVICE_ERROR is not None + assert ErrorCode.DIFY_CONFIG_INVALID is not None + assert ErrorCode.DIFY_CONNECTION_ERROR is not None + assert ErrorCode.DIFY_AUTH_ERROR is not None + assert ErrorCode.DIFY_RATE_LIMIT is not None + assert ErrorCode.DIFY_RESPONSE_ERROR is not None + + def test_datamate_error_codes_exist(self): + """Test that DataMate error code is defined.""" + assert ErrorCode.DATAMATE_CONNECTION_FAILED is not None + + def test_me_error_codes_exist(self): + """Test that ME service error code is defined.""" + assert ErrorCode.ME_CONNECTION_FAILED is not None + + +class TestErrorCodeValues: + """Test class for ErrorCode string values with leading zeros.""" + + def test_dify_auth_error_value(self): + """Test DIFY_AUTH_ERROR has correct string value.""" + assert ErrorCode.DIFY_AUTH_ERROR.value == "130204" + + def test_dify_config_invalid_value(self): + """Test DIFY_CONFIG_INVALID has correct string value.""" + assert ErrorCode.DIFY_CONFIG_INVALID.value == "130202" + + def test_dify_connection_error_value(self): + """Test DIFY_CONNECTION_ERROR has correct string value.""" + assert ErrorCode.DIFY_CONNECTION_ERROR.value == "130203" + + def test_dify_service_error_value(self): + """Test DIFY_SERVICE_ERROR has correct string value.""" + assert ErrorCode.DIFY_SERVICE_ERROR.value == "130201" + + def test_dify_rate_limit_value(self): + """Test DIFY_RATE_LIMIT has correct string value.""" + assert ErrorCode.DIFY_RATE_LIMIT.value == "130205" + + def test_dify_response_error_value(self): + """Test DIFY_RESPONSE_ERROR has correct string value.""" + assert ErrorCode.DIFY_RESPONSE_ERROR.value == "130206" + + def test_datamate_connection_failed_value(self): + """Test DATAMATE_CONNECTION_FAILED has correct string value.""" + assert ErrorCode.DATAMATE_CONNECTION_FAILED.value == "130101" + + def test_me_connection_failed_value(self): + """Test ME_CONNECTION_FAILED has correct string value.""" + assert ErrorCode.ME_CONNECTION_FAILED.value == "130301" + + def test_common_validation_error_value(self): + """Test COMMON_VALIDATION_ERROR has correct string value.""" + assert ErrorCode.COMMON_VALIDATION_ERROR.value == "000101" + + def test_common_unauthorized_value(self): + """Test COMMON_UNAUTHORIZED has correct string value.""" + assert ErrorCode.COMMON_UNAUTHORIZED.value == "000201" + + def test_common_token_expired_value(self): + """Test COMMON_TOKEN_EXPIRED has correct string value.""" + assert ErrorCode.COMMON_TOKEN_EXPIRED.value == "000203" + + def test_common_token_invalid_value(self): + """Test COMMON_TOKEN_INVALID has correct string value.""" + assert ErrorCode.COMMON_TOKEN_INVALID.value == "000204" + + def test_common_rate_limit_exceeded_value(self): + """Test COMMON_RATE_LIMIT_EXCEEDED has correct string value.""" + assert ErrorCode.COMMON_RATE_LIMIT_EXCEEDED.value == "000302" + + def test_file_not_found_value(self): + """Test FILE_NOT_FOUND has correct string value.""" + assert ErrorCode.FILE_NOT_FOUND.value == "000401" + + def test_file_too_large_value(self): + """Test FILE_TOO_LARGE has correct string value.""" + assert ErrorCode.FILE_TOO_LARGE.value == "000403" + + def test_common_resource_not_found_value(self): + """Test COMMON_RESOURCE_NOT_FOUND has correct string value.""" + assert ErrorCode.COMMON_RESOURCE_NOT_FOUND.value == "000501" + + def test_chat_conversation_not_found_value(self): + """Test CHAT_CONVERSATION_NOT_FOUND has correct string value.""" + assert ErrorCode.CHAT_CONVERSATION_NOT_FOUND.value == "010101" + + def test_knowledge_not_found_value(self): + """Test KNOWLEDGE_NOT_FOUND has correct string value.""" + assert ErrorCode.KNOWLEDGE_NOT_FOUND.value == "060101" + + def test_memory_not_found_value(self): + """Test MEMORY_NOT_FOUND has correct string value.""" + assert ErrorCode.MEMORY_NOT_FOUND.value == "100101" + + def test_model_not_found_value(self): + """Test MODEL_NOT_FOUND has correct string value.""" + assert ErrorCode.MODEL_NOT_FOUND.value == "090101" + + def test_mcp_connection_failed_value(self): + """Test MCP_CONNECTION_FAILED has correct string value.""" + assert ErrorCode.MCP_CONNECTION_FAILED.value == "070201" + + def test_northbound_request_failed_value(self): + """Test NORTHBOUND_REQUEST_FAILED has correct string value.""" + assert ErrorCode.NORTHBOUND_REQUEST_FAILED.value == "140101" + + def test_dataprocess_task_failed_value(self): + """Test DATAPROCESS_TASK_FAILED has correct string value.""" + assert ErrorCode.DATAPROCESS_TASK_FAILED.value == "150101" + + def test_system_unknown_error_value(self): + """Test SYSTEM_UNKNOWN_ERROR has correct string value.""" + assert ErrorCode.SYSTEM_UNKNOWN_ERROR.value == "990101" + + def test_system_internal_error_value(self): + """Test SYSTEM_INTERNAL_ERROR has correct string value.""" + assert ErrorCode.SYSTEM_INTERNAL_ERROR.value == "990105" + + +class TestErrorCodeHttpStatusMapping: + """Test class for ERROR_CODE_HTTP_STATUS mapping.""" + + def test_dify_auth_error_maps_to_401(self): + """Test DIFY_AUTH_ERROR maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_AUTH_ERROR] == 401 + + def test_dify_config_invalid_maps_to_400(self): + """Test DIFY_CONFIG_INVALID maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONFIG_INVALID] == 400 + + def test_dify_connection_error_maps_to_502(self): + """Test DIFY_CONNECTION_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_CONNECTION_ERROR] == 502 + + def test_dify_response_error_maps_to_502(self): + """Test DIFY_RESPONSE_ERROR maps to HTTP 502.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RESPONSE_ERROR] == 502 + + def test_dify_rate_limit_maps_to_429(self): + """Test DIFY_RATE_LIMIT maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.DIFY_RATE_LIMIT] == 429 + + def test_common_token_expired_maps_to_401(self): + """Test COMMON_TOKEN_EXPIRED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_TOKEN_EXPIRED] == 401 + + def test_common_token_invalid_maps_to_401(self): + """Test COMMON_TOKEN_INVALID maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_TOKEN_INVALID] == 401 + + def test_common_unauthorized_maps_to_401(self): + """Test COMMON_UNAUTHORIZED maps to HTTP 401.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_UNAUTHORIZED] == 401 + + def test_common_forbidden_maps_to_403(self): + """Test COMMON_FORBIDDEN maps to HTTP 403.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_FORBIDDEN] == 403 + + def test_common_rate_limit_exceeded_maps_to_429(self): + """Test COMMON_RATE_LIMIT_EXCEEDED maps to HTTP 429.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_RATE_LIMIT_EXCEEDED] == 429 + + def test_common_validation_error_maps_to_400(self): + """Test COMMON_VALIDATION_ERROR maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_VALIDATION_ERROR] == 400 + + def test_common_parameter_invalid_maps_to_400(self): + """Test COMMON_PARAMETER_INVALID maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_PARAMETER_INVALID] == 400 + + def test_common_missing_required_field_maps_to_400(self): + """Test COMMON_MISSING_REQUIRED_FIELD maps to HTTP 400.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_MISSING_REQUIRED_FIELD] == 400 + + def test_file_too_large_maps_to_413(self): + """Test FILE_TOO_LARGE maps to HTTP 413.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FILE_TOO_LARGE] == 413 + + def test_file_not_found_maps_to_404(self): + """Test FILE_NOT_FOUND maps to HTTP 404.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.FILE_NOT_FOUND] == 404 + + def test_common_resource_not_found_maps_to_404(self): + """Test COMMON_RESOURCE_NOT_FOUND maps to HTTP 404.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_RESOURCE_NOT_FOUND] == 404 + + def test_common_resource_already_exists_maps_to_409(self): + """Test COMMON_RESOURCE_ALREADY_EXISTS maps to HTTP 409.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS] == 409 + + def test_common_resource_disabled_maps_to_403(self): + """Test COMMON_RESOURCE_DISABLED maps to HTTP 403.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.COMMON_RESOURCE_DISABLED] == 403 + + def test_system_service_unavailable_maps_to_503(self): + """Test SYSTEM_SERVICE_UNAVAILABLE maps to HTTP 503.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.SYSTEM_SERVICE_UNAVAILABLE] == 503 + + def test_system_timeout_maps_to_504(self): + """Test SYSTEM_TIMEOUT maps to HTTP 504.""" + assert ERROR_CODE_HTTP_STATUS[ErrorCode.SYSTEM_TIMEOUT] == 504 + + +class TestErrorCodeFormat: + """Test class for error code format consistency.""" + + def test_all_dify_codes_start_with_1302(self): + """Test all Dify error codes start with 1302 (module 13, sub-module 02).""" + dify_codes = [ + ErrorCode.DIFY_SERVICE_ERROR, + ErrorCode.DIFY_CONFIG_INVALID, + ErrorCode.DIFY_CONNECTION_ERROR, + ErrorCode.DIFY_AUTH_ERROR, + ErrorCode.DIFY_RATE_LIMIT, + ErrorCode.DIFY_RESPONSE_ERROR, + ] + for code in dify_codes: + assert str(code.value).startswith("1302"), f"{code} should start with 1302" + + def test_all_datamate_codes_start_with_1301(self): + """Test DataMate error code starts with 1301 (module 13, sub-module 01).""" + assert str(ErrorCode.DATAMATE_CONNECTION_FAILED.value).startswith("1301") + + def test_all_me_codes_start_with_1303(self): + """Test ME service error code starts with 1303 (module 13, sub-module 03).""" + assert str(ErrorCode.ME_CONNECTION_FAILED.value).startswith("1303") + + def test_all_common_auth_codes_start_with_0002(self): + """Test common auth error codes start with 0002.""" + auth_codes = [ + ErrorCode.COMMON_UNAUTHORIZED, + ErrorCode.COMMON_TOKEN_EXPIRED, + ErrorCode.COMMON_TOKEN_INVALID, + ErrorCode.COMMON_FORBIDDEN, + ] + for code in auth_codes: + assert str(code.value).startswith("0002"), f"{code} should start with 0002" + + def test_all_common_validation_codes_start_with_0001(self): + """Test common validation error codes start with 0001.""" + validation_codes = [ + ErrorCode.COMMON_VALIDATION_ERROR, + ErrorCode.COMMON_PARAMETER_INVALID, + ErrorCode.COMMON_MISSING_REQUIRED_FIELD, + ] + for code in validation_codes: + assert str(code.value).startswith("0001"), f"{code} should start with 0001" + + def test_all_system_codes_start_with_99(self): + """Test system error codes start with 99.""" + system_codes = [ + ErrorCode.SYSTEM_UNKNOWN_ERROR, + ErrorCode.SYSTEM_SERVICE_UNAVAILABLE, + ErrorCode.SYSTEM_DATABASE_ERROR, + ErrorCode.SYSTEM_TIMEOUT, + ErrorCode.SYSTEM_INTERNAL_ERROR, + ] + for code in system_codes: + assert str(code.value).startswith("99"), f"{code} should start with 99" + + def test_all_chat_codes_start_with_01(self): + """Test chat error codes start with 01.""" + assert str(ErrorCode.CHAT_CONVERSATION_NOT_FOUND.value).startswith("01") + + def test_all_knowledge_codes_start_with_06(self): + """Test knowledge error codes start with 06.""" + assert str(ErrorCode.KNOWLEDGE_NOT_FOUND.value).startswith("06") + + def test_all_mcp_codes_start_with_07(self): + """Test MCP error codes start with 07.""" + assert str(ErrorCode.MCP_CONNECTION_FAILED.value).startswith("07") + + def test_all_model_codes_start_with_09(self): + """Test model error codes start with 09.""" + assert str(ErrorCode.MODEL_NOT_FOUND.value).startswith("09") + + def test_all_memory_codes_start_with_10(self): + """Test memory error codes start with 10.""" + assert str(ErrorCode.MEMORY_NOT_FOUND.value).startswith("10") + + def test_all_northbound_codes_start_with_14(self): + """Test northbound error codes start with 14.""" + assert str(ErrorCode.NORTHBOUND_REQUEST_FAILED.value).startswith("14") + + def test_all_dataprocess_codes_start_with_15(self): + """Test dataprocess error codes start with 15.""" + assert str(ErrorCode.DATAPROCESS_TASK_FAILED.value).startswith("15") + + +class TestErrorCodeStringFormat: + """Test that ErrorCode values are strings with 6 digits.""" + + def test_error_code_is_string(self): + """Test ErrorCode values are strings.""" + assert isinstance(ErrorCode.DIFY_AUTH_ERROR.value, str) + assert ErrorCode.DIFY_AUTH_ERROR.value == "130204" + + def test_error_code_preserves_leading_zeros(self): + """Test ErrorCode values preserve leading zeros.""" + # Common codes have leading zeros + assert ErrorCode.COMMON_VALIDATION_ERROR.value == "000101" + assert ErrorCode.COMMON_UNAUTHORIZED.value == "000201" + assert ErrorCode.COMMON_RATE_LIMIT_EXCEEDED.value == "000302" + + def test_error_code_length_is_six(self): + """Test all ErrorCode values have 6 digits.""" + all_codes = [ + ErrorCode.COMMON_VALIDATION_ERROR, + ErrorCode.COMMON_UNAUTHORIZED, + ErrorCode.COMMON_TOKEN_EXPIRED, + ErrorCode.DIFY_AUTH_ERROR, + ErrorCode.DATAMATE_CONNECTION_FAILED, + ErrorCode.CHAT_CONVERSATION_NOT_FOUND, + ErrorCode.KNOWLEDGE_NOT_FOUND, + ErrorCode.MCP_CONNECTION_FAILED, + ErrorCode.SYSTEM_UNKNOWN_ERROR, + ] + for code in all_codes: + assert len(code.value) == 6, f"{code} should have 6 digits" + + +class TestErrorCodeIntConversion: + """Test ErrorCode can be converted to integer for JSON response.""" + + def test_error_code_can_be_converted_to_int(self): + """Test ErrorCode value can be converted to int for HTTP response.""" + # The response should use int() to convert string to number + assert int(ErrorCode.DIFY_AUTH_ERROR.value) == 130204 + assert int(ErrorCode.COMMON_VALIDATION_ERROR.value) == 101 + + def test_error_code_in_conditional(self): + """Test ErrorCode can be used in conditionals.""" + code = ErrorCode.DIFY_AUTH_ERROR + if code.value == "130204": + assert True + else: + assert False diff --git a/test/backend/consts/test_error_message.py b/test/backend/consts/test_error_message.py new file mode 100644 index 000000000..ae1aa9240 --- /dev/null +++ b/test/backend/consts/test_error_message.py @@ -0,0 +1,257 @@ +""" +Unit tests for Error Message definitions. + +Tests the ErrorMessage class and its methods for getting error messages. +""" +import pytest +from backend.consts.error_code import ErrorCode +from backend.consts.error_message import ErrorMessage + + +class TestErrorMessageGetMessage: + """Test class for ErrorMessage.get_message method.""" + + def test_get_message_dify_auth_error(self): + """Test getting message for DIFY_AUTH_ERROR.""" + msg = ErrorMessage.get_message(ErrorCode.DIFY_AUTH_ERROR) + assert "Dify authentication failed" in msg + assert "API key" in msg + + def test_get_message_dify_config_invalid(self): + """Test getting message for DIFY_CONFIG_INVALID.""" + msg = ErrorMessage.get_message(ErrorCode.DIFY_CONFIG_INVALID) + assert "Dify configuration" in msg + + def test_get_message_dify_connection_error(self): + """Test getting message for DIFY_CONNECTION_ERROR.""" + msg = ErrorMessage.get_message(ErrorCode.DIFY_CONNECTION_ERROR) + assert "connect to Dify" in msg + + def test_get_message_dify_rate_limit(self): + """Test getting message for DIFY_RATE_LIMIT.""" + msg = ErrorMessage.get_message(ErrorCode.DIFY_RATE_LIMIT) + assert "rate limit" in msg + + def test_get_message_common_validation_error(self): + """Test getting message for COMMON_VALIDATION_ERROR.""" + msg = ErrorMessage.get_message(ErrorCode.COMMON_VALIDATION_ERROR) + assert "Validation" in msg + + def test_get_message_common_unauthorized(self): + """Test getting message for COMMON_UNAUTHORIZED.""" + msg = ErrorMessage.get_message(ErrorCode.COMMON_UNAUTHORIZED) + assert "not authorized" in msg.lower() + + def test_get_message_common_token_expired(self): + """Test getting message for COMMON_TOKEN_EXPIRED.""" + msg = ErrorMessage.get_message(ErrorCode.COMMON_TOKEN_EXPIRED) + assert "session" in msg.lower() + assert "expired" in msg.lower() + + def test_get_message_common_token_invalid(self): + """Test getting message for COMMON_TOKEN_INVALID.""" + msg = ErrorMessage.get_message(ErrorCode.COMMON_TOKEN_INVALID) + assert "token" in msg.lower() + + def test_get_message_common_rate_limit_exceeded(self): + """Test getting message for COMMON_RATE_LIMIT_EXCEEDED.""" + msg = ErrorMessage.get_message(ErrorCode.COMMON_RATE_LIMIT_EXCEEDED) + assert "requests" in msg.lower() + + def test_get_message_file_not_found(self): + """Test getting message for FILE_NOT_FOUND.""" + msg = ErrorMessage.get_message(ErrorCode.FILE_NOT_FOUND) + assert "File" in msg + assert "not found" in msg.lower() + + def test_get_message_file_too_large(self): + """Test getting message for FILE_TOO_LARGE.""" + msg = ErrorMessage.get_message(ErrorCode.FILE_TOO_LARGE) + assert "size" in msg.lower() + + def test_get_message_system_unknown_error(self): + """Test getting message for SYSTEM_UNKNOWN_ERROR.""" + msg = ErrorMessage.get_message(ErrorCode.SYSTEM_UNKNOWN_ERROR) + assert "unknown error" in msg.lower() + + def test_get_message_system_internal_error(self): + """Test getting message for SYSTEM_INTERNAL_ERROR.""" + msg = ErrorMessage.get_message(ErrorCode.SYSTEM_INTERNAL_ERROR) + assert "internal" in msg.lower() or "server" in msg.lower() + + def test_get_message_knowledge_not_found(self): + """Test getting message for KNOWLEDGE_NOT_FOUND.""" + msg = ErrorMessage.get_message(ErrorCode.KNOWLEDGE_NOT_FOUND) + assert "Knowledge" in msg + + def test_get_message_memory_not_found(self): + """Test getting message for MEMORY_NOT_FOUND.""" + msg = ErrorMessage.get_message(ErrorCode.MEMORY_NOT_FOUND) + assert "Memory" in msg + + def test_get_message_mcp_connection_failed(self): + """Test getting message for MCP_CONNECTION_FAILED.""" + msg = ErrorMessage.get_message(ErrorCode.MCP_CONNECTION_FAILED) + assert "MCP" in msg + + def test_get_message_northbound_request_failed(self): + """Test getting message for NORTHBOUND_REQUEST_FAILED.""" + msg = ErrorMessage.get_message(ErrorCode.NORTHBOUND_REQUEST_FAILED) + assert "Northbound" in msg + + def test_get_message_dataprocess_task_failed(self): + """Test getting message for DATAPROCESS_TASK_FAILED.""" + msg = ErrorMessage.get_message(ErrorCode.DATAPROCESS_TASK_FAILED) + assert "Data" in msg or "process" in msg.lower() + + def test_get_message_unknown_code_returns_default(self): + """Test that unknown error code returns default message.""" + # This tests that the fallback works + msg = ErrorMessage.get_message(ErrorCode.DIFY_AUTH_ERROR) + assert msg != "" + + +class TestErrorMessageGetMessageWithCode: + """Test class for ErrorMessage.get_message_with_code method.""" + + def test_get_message_with_code_returns_tuple(self): + """Test that get_message_with_code returns tuple.""" + code, msg = ErrorMessage.get_message_with_code( + ErrorCode.DIFY_AUTH_ERROR) + assert isinstance(code, str) + assert isinstance(msg, str) + + def test_get_message_with_code_dify_auth(self): + """Test get_message_with_code for DIFY_AUTH_ERROR.""" + code, msg = ErrorMessage.get_message_with_code( + ErrorCode.DIFY_AUTH_ERROR) + assert code == "130204" + assert "Dify authentication failed" in msg + + def test_get_message_with_code_common_validation(self): + """Test get_message_with_code for COMMON_VALIDATION_ERROR.""" + code, msg = ErrorMessage.get_message_with_code( + ErrorCode.COMMON_VALIDATION_ERROR) + assert code == "000101" + assert "Validation" in msg + + def test_get_message_with_code_system_error(self): + """Test get_message_with_code for SYSTEM_INTERNAL_ERROR.""" + code, msg = ErrorMessage.get_message_with_code( + ErrorCode.SYSTEM_INTERNAL_ERROR) + assert code == "990105" + assert "error" in msg.lower() + + def test_get_message_with_code_tuple_length(self): + """Test that get_message_with_code returns exactly 2 elements.""" + result = ErrorMessage.get_message_with_code( + ErrorCode.DIFY_CONFIG_INVALID) + assert len(result) == 2 + + def test_get_message_with_code_tuple_order(self): + """Test that get_message_with_code returns (code, message) in correct order.""" + code, msg = ErrorMessage.get_message_with_code( + ErrorCode.KNOWLEDGE_NOT_FOUND) + # First element should be the error code string + assert code == ErrorCode.KNOWLEDGE_NOT_FOUND.value + # Second element should be the message + assert msg == ErrorMessage.get_message(ErrorCode.KNOWLEDGE_NOT_FOUND) + + +class TestErrorMessageGetAllMessages: + """Test class for ErrorMessage.get_all_messages method.""" + + def test_get_all_messages_returns_dict(self): + """Test that get_all_messages returns a dictionary.""" + messages = ErrorMessage.get_all_messages() + assert isinstance(messages, dict) + + def test_get_all_messages_contains_dify_codes(self): + """Test that get_all_messages contains Dify error codes.""" + messages = ErrorMessage.get_all_messages() + assert "130201" in messages # DIFY_SERVICE_ERROR + assert "130202" in messages # DIFY_CONFIG_INVALID + assert "130203" in messages # DIFY_CONNECTION_ERROR + assert "130204" in messages # DIFY_AUTH_ERROR + assert "130205" in messages # DIFY_RATE_LIMIT + assert "130206" in messages # DIFY_RESPONSE_ERROR + + def test_get_all_messages_contains_common_codes(self): + """Test that get_all_messages contains common error codes.""" + messages = ErrorMessage.get_all_messages() + assert "000101" in messages # COMMON_VALIDATION_ERROR + assert "000201" in messages # COMMON_UNAUTHORIZED + assert "000203" in messages # COMMON_TOKEN_EXPIRED + + def test_get_all_messages_contains_system_codes(self): + """Test that get_all_messages contains system error codes.""" + messages = ErrorMessage.get_all_messages() + assert "990101" in messages # SYSTEM_UNKNOWN_ERROR + assert "990105" in messages # SYSTEM_INTERNAL_ERROR + + def test_get_all_messages_all_values_are_strings(self): + """Test that all message values in get_all_messages are non-empty strings.""" + messages = ErrorMessage.get_all_messages() + for code, msg in messages.items(): + assert isinstance(msg, str), f"Message for {code} is not a string" + assert len(msg) > 0, f"Message for {code} is empty" + + def test_get_all_messages_all_keys_are_strings(self): + """Test that all keys in get_all_messages are string error codes.""" + messages = ErrorMessage.get_all_messages() + for key in messages.keys(): + assert isinstance(key, str), f"Key {key} is not a string" + # Error codes should be numeric strings + assert key.isdigit(), f"Key {key} is not a numeric error code" + + def test_get_all_messages_contains_multiple_categories(self): + """Test that get_all_messages contains errors from multiple categories.""" + messages = ErrorMessage.get_all_messages() + # Should have errors from different modules + # Common (00), Chat (01), Knowledge (06), System (99) + has_common = any(key.startswith("00") for key in messages.keys()) + has_chat = any(key.startswith("01") for key in messages.keys()) + has_knowledge = any(key.startswith("06") for key in messages.keys()) + has_system = any(key.startswith("99") for key in messages.keys()) + assert has_common and has_chat and has_knowledge and has_system + + def test_get_all_messages_count(self): + """Test that get_all_messages returns expected number of messages.""" + messages = ErrorMessage.get_all_messages() + # Should have at least 30 error messages + assert len(messages) >= 30 + + def test_get_all_messages_mcp_codes(self): + """Test that get_all_messages contains MCP error codes.""" + messages = ErrorMessage.get_all_messages() + assert "070101" in messages # MCP_TOOL_NOT_FOUND + assert "070102" in messages # MCP_TOOL_EXECUTION_FAILED + assert "070103" in messages # MCP_TOOL_CONFIG_INVALID + + +class TestErrorMessageCoverage: + """Test class for error message coverage.""" + + def test_all_error_codes_have_messages(self): + """Test that all defined ErrorCodes have messages.""" + # Get all error codes from ErrorCode enum + all_codes = list(ErrorCode) + + for code in all_codes: + msg = ErrorMessage.get_message(code) + assert msg != "", f"Error code {code} has no message" + assert isinstance(msg, str), f"Message for {code} is not a string" + + def test_message_not_generic_for_specific_errors(self): + """Test that specific errors have specific messages, not the default.""" + # Dify auth error should have specific message + msg = ErrorMessage.get_message(ErrorCode.DIFY_AUTH_ERROR) + assert "authentication failed" in msg.lower() + + # Connection errors should mention connection + msg = ErrorMessage.get_message(ErrorCode.DIFY_CONNECTION_ERROR) + assert "connect" in msg.lower() + + # Rate limit should mention rate limit + msg = ErrorMessage.get_message(ErrorCode.DIFY_RATE_LIMIT) + assert "rate" in msg.lower() or "limit" in msg.lower() diff --git a/test/backend/consts/test_exceptions.py b/test/backend/consts/test_exceptions.py new file mode 100644 index 000000000..4ec5d0234 --- /dev/null +++ b/test/backend/consts/test_exceptions.py @@ -0,0 +1,195 @@ +""" +Unit tests for Exception classes. + +Tests the AppException class and helper functions. +""" +import pytest +from backend.consts.error_code import ErrorCode +from backend.consts.exceptions import AppException, raise_error + + +class TestAppException: + """Test class for AppException.""" + + def test_app_exception_creation_with_code(self): + """Test creating AppException with error code.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR) + assert exc.error_code == ErrorCode.DIFY_AUTH_ERROR + + def test_app_exception_creation_with_custom_message(self): + """Test creating AppException with custom message.""" + custom_msg = "Custom error message" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, custom_msg) + assert exc.message == custom_msg + + def test_app_exception_default_message(self): + """Test that AppException uses default message when not provided.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR) + # Default message should be from ErrorMessage + assert exc.message != "" + assert "Dify authentication failed" in exc.message + + def test_app_exception_with_details(self): + """Test creating AppException with details.""" + details = {"field": "api_key", "reason": "invalid"} + exc = AppException(ErrorCode.DIFY_CONFIG_INVALID, "Invalid config", details) + assert exc.details == details + + def test_app_exception_empty_details_defaults_to_dict(self): + """Test that empty details defaults to empty dict.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR) + assert exc.details == {} + + def test_app_exception_to_dict(self): + """Test AppException.to_dict() method.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Auth failed", {"key": "value"}) + result = exc.to_dict() + + assert result["code"] == 130204 + assert result["message"] == "Auth failed" + assert result["details"] == {"key": "value"} + + def test_app_exception_to_dict_null_details(self): + """Test that to_dict() returns null for empty details.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Auth failed") + result = exc.to_dict() + + assert result["details"] is None + + def test_app_exception_http_status_property(self): + """Test AppException.http_status property.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR) + assert exc.http_status == 401 + + def test_app_exception_http_status_for_different_codes(self): + """Test http_status for different error codes.""" + test_cases = [ + (ErrorCode.DIFY_AUTH_ERROR, 401), + (ErrorCode.DIFY_CONFIG_INVALID, 400), + (ErrorCode.DIFY_RATE_LIMIT, 429), + (ErrorCode.COMMON_VALIDATION_ERROR, 400), + (ErrorCode.COMMON_TOKEN_EXPIRED, 401), + (ErrorCode.COMMON_FORBIDDEN, 403), + ] + + for error_code, expected_status in test_cases: + exc = AppException(error_code) + assert exc.http_status == expected_status, \ + f"Expected {expected_status} for {error_code}" + + def test_app_exception_is_subclass_of_exception(self): + """Test that AppException is a subclass of Exception.""" + assert issubclass(AppException, Exception) + + def test_app_exception_can_be_raised_and_caught(self): + """Test that AppException can be raised and caught.""" + try: + raise AppException(ErrorCode.DIFY_AUTH_ERROR, "Test error") + except AppException as e: + assert e.error_code == ErrorCode.DIFY_AUTH_ERROR + assert e.message == "Test error" + + def test_app_exception_str_representation(self): + """Test string representation of AppException.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Test error") + assert str(exc) == "Test error" + + +class TestRaiseError: + """Test class for raise_error helper function.""" + + def test_raise_error_raises_app_exception(self): + """Test that raise_error raises AppException.""" + with pytest.raises(AppException): + raise_error(ErrorCode.DIFY_AUTH_ERROR) + + def test_raise_error_with_custom_message(self): + """Test raise_error with custom message.""" + custom_msg = "Custom error" + try: + raise_error(ErrorCode.DIFY_AUTH_ERROR, custom_msg) + except AppException as e: + assert e.message == custom_msg + + def test_raise_error_with_details(self): + """Test raise_error with details.""" + details = {"info": "test"} + try: + raise_error(ErrorCode.DIFY_CONFIG_INVALID, "Error", details) + except AppException as e: + assert e.details == details + + +class TestLegacyExceptions: + """Test class for legacy exception classes.""" + + def test_agent_run_exception_exists(self): + """Test AgentRunException can be instantiated.""" + from backend.consts.exceptions import AgentRunException + exc = AgentRunException("Agent run failed") + assert str(exc) == "Agent run failed" + + def test_limit_exceeded_error_exists(self): + """Test LimitExceededError can be instantiated.""" + from backend.consts.exceptions import LimitExceededError + exc = LimitExceededError("Rate limit exceeded") + assert str(exc) == "Rate limit exceeded" + + def test_unauthorized_error_exists(self): + """Test UnauthorizedError can be instantiated.""" + from backend.consts.exceptions import UnauthorizedError + exc = UnauthorizedError("Unauthorized") + assert str(exc) == "Unauthorized" + + def test_validation_error_exists(self): + """Test ValidationError can be instantiated.""" + from backend.consts.exceptions import ValidationError + exc = ValidationError("Validation failed") + assert str(exc) == "Validation failed" + + def test_not_found_exception_exists(self): + """Test NotFoundException can be instantiated.""" + from backend.consts.exceptions import NotFoundException + exc = NotFoundException("Resource not found") + assert str(exc) == "Resource not found" + + def test_mcp_connection_error_exists(self): + """Test MCPConnectionError can be instantiated.""" + from backend.consts.exceptions import MCPConnectionError + exc = MCPConnectionError("MCP connection failed") + assert str(exc) == "MCP connection failed" + + def test_data_mate_connection_error_exists(self): + """Test DataMateConnectionError can be instantiated.""" + from backend.consts.exceptions import DataMateConnectionError + exc = DataMateConnectionError("DataMate connection failed") + assert str(exc) == "DataMate connection failed" + + +class TestLegacyAliases: + """Test class for legacy exception aliases.""" + + def test_parameter_invalid_error_alias(self): + """Test ParameterInvalidError alias exists.""" + from backend.consts.exceptions import ParameterInvalidError + assert ParameterInvalidError is not None + + def test_timeout_error_alias(self): + """Test TimeoutError alias exists.""" + from backend.consts.exceptions import TimeoutError + assert TimeoutError is not None + + def test_user_not_found_error_alias(self): + """Test UserNotFoundError alias exists.""" + from backend.consts.exceptions import UserNotFoundError + assert UserNotFoundError is not None + + def test_tenant_not_found_error_alias(self): + """Test TenantNotFoundError alias exists.""" + from backend.consts.exceptions import TenantNotFoundError + assert TenantNotFoundError is not None + + def test_agent_not_found_error_alias(self): + """Test AgentNotFoundError alias exists.""" + from backend.consts.exceptions import AgentNotFoundError + assert AgentNotFoundError is not None diff --git a/test/backend/middleware/test_exception_handler.py b/test/backend/middleware/test_exception_handler.py new file mode 100644 index 000000000..0b2bfc865 --- /dev/null +++ b/test/backend/middleware/test_exception_handler.py @@ -0,0 +1,537 @@ +""" +Unit tests for Exception Handler Middleware. + +Tests the ExceptionHandlerMiddleware class and helper functions +for centralized error handling in the FastAPI application. +""" +import atexit +import sys +import os + +# Add backend directory to path for imports BEFORE any module imports +# From test/backend/middleware/ -> go up 3 levels to project root -> backend/ +backend_dir = os.path.abspath(os.path.join( + os.path.dirname(__file__), "../../../backend")) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +import pytest +from fastapi import Request, HTTPException +from fastapi.responses import Response +from backend.middleware.exception_handler import ( + ExceptionHandlerMiddleware, + _http_status_to_error_code, + create_error_response, + create_success_response, +) +from consts.exceptions import AppException +from consts.error_code import ErrorCode +from unittest.mock import patch, MagicMock, AsyncMock, Mock + + +# Apply critical patches before importing any modules +# This prevents real AWS/MinIO/Elasticsearch calls during import +patch('botocore.client.BaseClient._make_api_call', return_value={}).start() + +# Patch storage factory and MinIO config validation to avoid errors during initialization +# These patches must be started before any imports that use MinioClient +storage_client_mock = MagicMock() +minio_mock = MagicMock() +minio_mock._ensure_bucket_exists = MagicMock() +minio_mock.client = MagicMock() + +# Start critical patches first - storage factory and config validation must be patched +# before any module imports that might trigger MinioClient initialization +critical_patches = [ + # Patch storage factory and MinIO config validation FIRST + patch('nexent.storage.storage_client_factory.create_storage_client_from_config', + return_value=storage_client_mock), + patch('nexent.storage.minio_config.MinIOStorageConfig.validate', + lambda self: None), + # Mock boto3 client + patch('boto3.client', return_value=Mock()), + # Mock boto3 resource + patch('boto3.resource', return_value=Mock()), + # Mock Elasticsearch to prevent connection errors + patch('elasticsearch.Elasticsearch', return_value=Mock()), +] + +for p in critical_patches: + p.start() + +# Patch MinioClient class to return mock instance when instantiated +# This prevents real initialization during module import +patches = [ + patch('backend.database.client.MinioClient', return_value=minio_mock), + patch('database.client.MinioClient', return_value=minio_mock), + patch('backend.database.client.minio_client', minio_mock), +] + +for p in patches: + p.start() + +# Combine all patches for cleanup +all_patches = critical_patches + patches + +# Now safe to import modules that use database.client +# After import, we can patch get_db_session if needed +try: + from backend.database import client as db_client_module + # Patch get_db_session after module is imported + db_session_patch = patch.object( + db_client_module, 'get_db_session', return_value=Mock()) + db_session_patch.start() + all_patches.append(db_session_patch) +except ImportError: + # If import fails, try patching the path directly (may trigger import) + db_session_patch = patch( + 'backend.database.client.get_db_session', return_value=Mock()) + db_session_patch.start() + all_patches.append(db_session_patch) + +# Now safe to import app modules - AFTER all patches are applied +# Import exception classes + +# Import pytest for test decorators + +# Stop all patches at the end of the module + + +def stop_patches(): + for p in all_patches: + p.stop() + + +atexit.register(stop_patches) + + +class TestHttpStatusToErrorCode: + """Test class for _http_status_to_error_code function.""" + + def test_maps_400_to_common_validation_error(self): + """Test that HTTP 400 maps to COMMON_VALIDATION_ERROR.""" + assert _http_status_to_error_code(400) == ErrorCode.COMMON_VALIDATION_ERROR + + def test_maps_401_to_common_unauthorized(self): + """Test that HTTP 401 maps to COMMON_UNAUTHORIZED.""" + assert _http_status_to_error_code(401) == ErrorCode.COMMON_UNAUTHORIZED + + def test_maps_403_to_common_forbidden(self): + """Test that HTTP 403 maps to COMMON_FORBIDDEN.""" + assert _http_status_to_error_code(403) == ErrorCode.COMMON_FORBIDDEN + + def test_maps_404_to_common_resource_not_found(self): + """Test that HTTP 404 maps to COMMON_RESOURCE_NOT_FOUND.""" + assert _http_status_to_error_code(404) == ErrorCode.COMMON_RESOURCE_NOT_FOUND + + def test_maps_429_to_common_rate_limit_exceeded(self): + """Test that HTTP 429 maps to COMMON_RATE_LIMIT_EXCEEDED.""" + assert _http_status_to_error_code(429) == ErrorCode.COMMON_RATE_LIMIT_EXCEEDED + + def test_maps_500_to_system_internal_error(self): + """Test that HTTP 500 maps to SYSTEM_INTERNAL_ERROR.""" + assert _http_status_to_error_code(500) == ErrorCode.SYSTEM_INTERNAL_ERROR + + def test_maps_502_to_system_service_unavailable(self): + """Test that HTTP 502 maps to SYSTEM_SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(502) == ErrorCode.SYSTEM_SERVICE_UNAVAILABLE + + def test_maps_503_to_system_service_unavailable(self): + """Test that HTTP 503 maps to SYSTEM_SERVICE_UNAVAILABLE.""" + assert _http_status_to_error_code(503) == ErrorCode.SYSTEM_SERVICE_UNAVAILABLE + + def test_unknown_status_returns_system_unknown_error(self): + """Test that unknown HTTP status codes map to SYSTEM_UNKNOWN_ERROR.""" + assert _http_status_to_error_code(418) == ErrorCode.SYSTEM_UNKNOWN_ERROR + assert _http_status_to_error_code(599) == ErrorCode.SYSTEM_UNKNOWN_ERROR + + +class TestCreateErrorResponse: + """Test class for create_error_response function.""" + + def test_create_error_response_default(self): + """Test creating error response with default values.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + assert response.body is not None + + def test_create_error_response_custom_message(self): + """Test creating error response with custom message.""" + custom_message = "Custom error message" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + message=custom_message + ) + + assert response.status_code == 401 + + def test_create_error_response_with_trace_id(self): + """Test creating error response with trace ID.""" + trace_id = "test-trace-id-123" + response = create_error_response( + ErrorCode.DIFY_AUTH_ERROR, + trace_id=trace_id + ) + + assert response.status_code == 401 + + def test_create_error_response_with_details(self): + """Test creating error response with additional details.""" + details = {"field": "api_key", "issue": "invalid format"} + response = create_error_response( + ErrorCode.DIFY_CONFIG_INVALID, + details=details + ) + + assert response.status_code == 400 + + def test_create_error_response_custom_http_status(self): + """Test creating error response with custom HTTP status.""" + response = create_error_response( + ErrorCode.DIFY_SERVICE_ERROR, + http_status=502 + ) + + assert response.status_code == 502 + + def test_create_error_response_dify_auth_error(self): + """Test creating error response for DIFY_AUTH_ERROR.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + + assert response.status_code == 401 + + def test_create_error_response_dify_config_invalid(self): + """Test creating error response for DIFY_CONFIG_INVALID.""" + response = create_error_response(ErrorCode.DIFY_CONFIG_INVALID) + + assert response.status_code == 400 + + def test_create_error_response_dify_rate_limit(self): + """Test creating error response for DIFY_RATE_LIMIT.""" + response = create_error_response(ErrorCode.DIFY_RATE_LIMIT) + + assert response.status_code == 429 + + def test_create_error_response_validation_error(self): + """Test creating error response for COMMON_VALIDATION_ERROR.""" + response = create_error_response(ErrorCode.COMMON_VALIDATION_ERROR) + + assert response.status_code == 400 + + def test_create_error_response_token_expired(self): + """Test creating error response for COMMON_TOKEN_EXPIRED.""" + response = create_error_response(ErrorCode.COMMON_TOKEN_EXPIRED) + + assert response.status_code == 401 + + +class TestCreateSuccessResponse: + """Test class for create_success_response function.""" + + def test_create_success_response_default(self): + """Test creating success response with default values.""" + response = create_success_response() + + assert response.status_code == 200 + + def test_create_success_response_with_data(self): + """Test creating success response with data.""" + data = {"key": "value"} + response = create_success_response(data=data) + + assert response.status_code == 200 + + def test_create_success_response_custom_message(self): + """Test creating success response with custom message.""" + response = create_success_response(message="Operation successful") + + assert response.status_code == 200 + + def test_create_success_response_with_trace_id(self): + """Test creating success response with trace ID.""" + trace_id = "test-trace-id-456" + response = create_success_response(trace_id=trace_id) + + assert response.status_code == 200 + + def test_create_success_response_all_params(self): + """Test creating success response with all parameters.""" + data = {"result": "ok"} + message = "Success" + trace_id = "trace-789" + response = create_success_response( + data=data, + message=message, + trace_id=trace_id + ) + + assert response.status_code == 200 + + +class TestExceptionHandlerMiddleware: + """Test class for ExceptionHandlerMiddleware.""" + + @pytest.mark.asyncio + async def test_dispatch_normal_request(self): + """Test that normal requests pass through without error.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + mock_call_next.assert_called_once_with(mock_request) + assert response == mock_response + + @pytest.mark.asyncio + async def test_dispatch_app_exception(self): + """Test handling of AppException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate AppException being raised + app_exception = AppException( + ErrorCode.DIFY_AUTH_ERROR, + "Dify authentication failed" + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_dispatch_http_exception(self): + """Test handling of FastAPI HTTPException.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate HTTPException being raised + http_exception = HTTPException(status_code=404, detail="Not found") + mock_call_next = AsyncMock(side_effect=http_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_dispatch_generic_exception(self): + """Test handling of generic exceptions.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # Simulate generic exception being raised + generic_exception = RuntimeError("Something went wrong") + mock_call_next = AsyncMock(side_effect=generic_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Should return 500 with internal error code + assert response.status_code == 500 + + @pytest.mark.asyncio + async def test_trace_id_generated(self): + """Test that trace ID is generated for each request.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + mock_response = MagicMock(spec=Response) + mock_call_next = AsyncMock(return_value=mock_response) + + response = await middleware.dispatch(mock_request, mock_call_next) + + # Verify trace_id was set on request.state + assert hasattr(mock_request.state, 'trace_id') + + @pytest.mark.asyncio + async def test_app_exception_with_details(self): + """Test handling of AppException with details.""" + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + # AppException with details + app_exception = AppException( + ErrorCode.DIFY_CONFIG_INVALID, + "Invalid configuration", + details={"field": "api_key"} + ) + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == 400 + + @pytest.mark.asyncio + async def test_different_error_codes_map_to_correct_status(self): + """Test that different error codes produce correct HTTP status.""" + test_cases = [ + (ErrorCode.COMMON_TOKEN_EXPIRED, 401), + (ErrorCode.COMMON_TOKEN_INVALID, 401), + (ErrorCode.COMMON_FORBIDDEN, 403), + (ErrorCode.COMMON_RATE_LIMIT_EXCEEDED, 429), + (ErrorCode.COMMON_VALIDATION_ERROR, 400), + (ErrorCode.FILE_TOO_LARGE, 413), + ] + + middleware = ExceptionHandlerMiddleware(app=MagicMock()) + mock_request = MagicMock(spec=Request) + mock_request.state = MagicMock() + + for error_code, expected_status in test_cases: + app_exception = AppException(error_code, "Test error") + mock_call_next = AsyncMock(side_effect=app_exception) + + response = await middleware.dispatch(mock_request, mock_call_next) + + assert response.status_code == expected_status, \ + f"Expected {expected_status} for {error_code}, got {response.status_code}" + + +class TestErrorResponseFormat: + """Test class for error response format.""" + + def test_error_response_contains_code_as_int(self): + """Test that error response contains code as integer.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + # Parse response body + import json + body = json.loads(response.body) + assert "code" in body + # Code should be integer when converted (string "130204" -> int 130204) + assert body["code"] == 130204 + + def test_error_response_contains_message(self): + """Test that error response contains message.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR, message="Custom message") + import json + body = json.loads(response.body) + assert body["message"] == "Custom message" + + def test_error_response_contains_trace_id(self): + """Test that error response contains trace_id.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR, trace_id="test-123") + import json + body = json.loads(response.body) + assert body["trace_id"] == "test-123" + + def test_error_response_contains_details(self): + """Test that error response contains details.""" + details = {"field": "api_key", "reason": "invalid"} + response = create_error_response(ErrorCode.DIFY_CONFIG_INVALID, details=details) + import json + body = json.loads(response.body) + assert body["details"] == details + + def test_error_response_details_null_when_not_provided(self): + """Test that details is null when not provided.""" + response = create_error_response(ErrorCode.DIFY_AUTH_ERROR) + import json + body = json.loads(response.body) + assert body["details"] is None + + +class TestNewErrorCodes: + """Test class for new error codes.""" + + def test_datamate_connection_failed(self): + """Test DATAMATE_CONNECTION_FAILED error code.""" + assert ErrorCode.DATAMATE_CONNECTION_FAILED.value == "130101" + + def test_me_connection_failed(self): + """Test ME_CONNECTION_FAILED error code.""" + assert ErrorCode.ME_CONNECTION_FAILED.value == "130301" + + def test_northbound_request_failed(self): + """Test NORTHBOUND_REQUEST_FAILED error code.""" + assert ErrorCode.NORTHBOUND_REQUEST_FAILED.value == "140101" + + def test_northbound_config_invalid(self): + """Test NORTHBOUND_CONFIG_INVALID error code.""" + assert ErrorCode.NORTHBOUND_CONFIG_INVALID.value == "140201" + + def test_dataprocess_task_failed(self): + """Test DATAPROCESS_TASK_FAILED error code.""" + assert ErrorCode.DATAPROCESS_TASK_FAILED.value == "150101" + + def test_dataprocess_parse_failed(self): + """Test DATAPROCESS_PARSE_FAILED error code.""" + assert ErrorCode.DATAPROCESS_PARSE_FAILED.value == "150102" + + def test_quick_config_invalid(self): + """Test QUICK_CONFIG_INVALID error code.""" + assert ErrorCode.QUICK_CONFIG_INVALID.value == "020101" + + def test_agentspace_agent_not_found(self): + """Test AGENTSPACE_AGENT_NOT_FOUND error code.""" + assert ErrorCode.AGENTSPACE_AGENT_NOT_FOUND.value == "030101" + + def test_knowledge_not_found(self): + """Test KNOWLEDGE_NOT_FOUND error code.""" + assert ErrorCode.KNOWLEDGE_NOT_FOUND.value == "060101" + + def test_memory_not_found(self): + """Test MEMORY_NOT_FOUND error code.""" + assert ErrorCode.MEMORY_NOT_FOUND.value == "100101" + + def test_profile_user_not_found(self): + """Test PROFILE_USER_NOT_FOUND error code.""" + assert ErrorCode.PROFILE_USER_NOT_FOUND.value == "110101" + + def test_tenant_not_found(self): + """Test TENANT_NOT_FOUND error code.""" + assert ErrorCode.TENANT_NOT_FOUND.value == "120101" + + def test_mcp_tool_not_found(self): + """Test MCP_TOOL_NOT_FOUND error code.""" + assert ErrorCode.MCP_TOOL_NOT_FOUND.value == "070101" + + def test_mcp_name_illegal(self): + """Test MCP_NAME_ILLEGAL error code.""" + assert ErrorCode.MCP_NAME_ILLEGAL.value == "070301" + + def test_model_not_found(self): + """Test MODEL_NOT_FOUND error code.""" + assert ErrorCode.MODEL_NOT_FOUND.value == "090101" + + +class TestAppExceptionToDict: + """Test class for AppException.to_dict() method.""" + + def test_to_dict_contains_code(self): + """Test that to_dict contains code as integer.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Auth failed") + result = exc.to_dict() + assert result["code"] == 130204 + + def test_to_dict_contains_message(self): + """Test that to_dict contains message.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Custom message") + result = exc.to_dict() + assert result["message"] == "Custom message" + + def test_to_dict_contains_details(self): + """Test that to_dict contains details.""" + exc = AppException(ErrorCode.DIFY_CONFIG_INVALID, "Invalid", details={"key": "value"}) + result = exc.to_dict() + assert result["details"] == {"key": "value"} + + def test_to_dict_details_null_when_empty(self): + """Test that details is null when empty dict.""" + exc = AppException(ErrorCode.DIFY_AUTH_ERROR, "Auth failed", details={}) + result = exc.to_dict() + assert result["details"] is None diff --git a/test/backend/services/test_dify_service.py b/test/backend/services/test_dify_service.py index a6ed3b091..77090711f 100644 --- a/test/backend/services/test_dify_service.py +++ b/test/backend/services/test_dify_service.py @@ -9,6 +9,9 @@ from unittest.mock import MagicMock, patch import httpx +from backend.consts.error_code import ErrorCode +from backend.consts.exceptions import AppException + def _create_mock_client(mock_response): """ @@ -145,82 +148,84 @@ def test_fetch_dify_datasets_impl_empty_response(self): assert result["pagination"]["embedding_available"] is False def test_fetch_dify_datasets_impl_invalid_api_base_none(self): - """Test ValueError when dify_api_base is None.""" + """Test AppException when dify_api_base is None.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + # Catch Exception and verify it's an AppException with expected error code + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base=None, api_key="test-api-key" ) - assert "dify_api_base is required and must be a non-empty string" in str( - excinfo.value) + # Verify it's an AppException with the correct error code + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_invalid_api_base_empty_string(self): - """Test ValueError when dify_api_base is empty string.""" + """Test AppException when dify_api_base is empty string.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base="", api_key="test-api-key" ) - assert "dify_api_base is required and must be a non-empty string" in str( - excinfo.value) + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_invalid_api_base_not_string(self): - """Test ValueError when dify_api_base is not a string.""" + """Test AppException when dify_api_base is not a string.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base=12345, api_key="test-api-key" ) - assert "dify_api_base is required and must be a non-empty string" in str( - excinfo.value) + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_invalid_api_key_none(self): - """Test ValueError when api_key is None.""" + """Test AppException when api_key is None.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base="https://dify.example.com", api_key=None ) - assert "api_key is required and must be a non-empty string" in str( - excinfo.value) + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_invalid_api_key_empty_string(self): - """Test ValueError when api_key is empty string.""" + """Test AppException when api_key is empty string.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base="https://dify.example.com", api_key="" ) - assert "api_key is required and must be a non-empty string" in str( - excinfo.value) + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_invalid_api_key_not_string(self): - """Test ValueError when api_key is not a string.""" + """Test AppException when api_key is not a string.""" from backend.services.dify_service import fetch_dify_datasets_impl - with pytest.raises(ValueError) as excinfo: + with pytest.raises(Exception) as excinfo: fetch_dify_datasets_impl( dify_api_base="https://dify.example.com", api_key=[] # list is not a string ) - assert "api_key is required and must be a non-empty string" in str( - excinfo.value) + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_CONFIG_INVALID.value def test_fetch_dify_datasets_impl_url_normalization_trailing_slash(self): """Test that trailing slash is removed from API base URL.""" @@ -695,3 +700,154 @@ def test_fetch_dify_datasets_impl_url_v1_suffix_parametrized(self, api_base_url) assert "/v1/v1" not in called_url, f"URL duplication detected: {called_url}" # Verify URL ends with /v1/datasets assert called_url.endswith("/v1/datasets") + + def test_fetch_dify_datasets_impl_url_without_protocol(self): + """Test ValueError when dify_api_base doesn't start with http:// or https://.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_url_with_ftp_protocol(self): + """Test ValueError when dify_api_base uses unsupported protocol.""" + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="ftp://dify.example.com", + api_key="test-api-key" + ) + + assert "must start with http:// or https://" in str(excinfo.value) + + def test_fetch_dify_datasets_impl_http_401_auth_error(self): + """Test that HTTP 401 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + # Create a proper mock response object with status_code as a real integer + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "401 Unauthorized", + request=MagicMock(), + response=type('MockResponse', (), {'status_code': 401})() + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + + # Catch Exception and verify it's an AppException with DIFY_AUTH_ERROR + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_AUTH_ERROR.value + + def test_fetch_dify_datasets_impl_http_403_auth_error(self): + """Test that HTTP 403 maps to DIFY_AUTH_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "403 Forbidden", + request=MagicMock(), + response=type('MockResponse', (), {'status_code': 403})() + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_AUTH_ERROR.value + + def test_fetch_dify_datasets_impl_http_429_rate_limit(self): + """Test that HTTP 429 maps to DIFY_RATE_LIMIT.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "429 Too Many Requests", + request=MagicMock(), + response=type('MockResponse', (), {'status_code': 429})() + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_RATE_LIMIT.value + + def test_fetch_dify_datasets_impl_http_500_service_error(self): + """Test that HTTP 500 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", + request=MagicMock(), + response=type('MockResponse', (), {'status_code': 500})() + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_SERVICE_ERROR.value + + def test_fetch_dify_datasets_impl_http_404_service_error(self): + """Test that HTTP 404 maps to DIFY_SERVICE_ERROR.""" + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "404 Not Found", + request=MagicMock(), + response=type('MockResponse', (), {'status_code': 404})() + ) + + mock_client = _create_mock_client(mock_response) + + with patch('backend.services.dify_service.http_client_manager') as mock_manager: + mock_manager.get_sync_client.return_value = mock_client + + from backend.services.dify_service import fetch_dify_datasets_impl + + with pytest.raises(Exception) as excinfo: + fetch_dify_datasets_impl( + dify_api_base="https://dify.example.com", + api_key="test-api-key" + ) + + assert hasattr(excinfo.value, 'error_code') + assert excinfo.value.error_code.value == ErrorCode.DIFY_SERVICE_ERROR.value