From a3c89df63c531e52072ffcec1412e7fa9ed9bc06 Mon Sep 17 00:00:00 2001
From: zhizhi <928570418@qq.com>
Date: Wed, 4 Mar 2026 10:22:02 +0800
Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20FastAPI=20application=20facto?=
=?UTF-8?q?ry=20and=20unit=20tests:=20Implemented=20a=20new=20app=5Ffactor?=
=?UTF-8?q?y=20module=20for=20creating=20FastAPI=20applications=20with=20c?=
=?UTF-8?q?ommon=20configurations=20and=20exception=20handling.=20Added=20?=
=?UTF-8?q?comprehensive=20unit=20tests=20to=20validate=20the=20functional?=
=?UTF-8?q?ity=20of=20the=20create=5Fapp=20and=20register=5Fexception=5Fha?=
=?UTF-8?q?ndlers=20functions.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/apps/app_factory.py | 108 +++
backend/apps/config_app.py | 42 +-
backend/apps/dify_app.py | 31 +-
backend/apps/northbound_base_app.py | 40 +-
backend/apps/runtime_app.py | 48 +-
backend/consts/const.py | 1 +
backend/consts/error_code.py | 219 ++++++
backend/consts/error_message.py | 148 ++++
backend/consts/exceptions.py | 115 ++-
backend/consts/model.py | 1 +
backend/database/db_models.py | 10 +-
backend/middleware/__init__.py | 1 +
backend/middleware/exception_handler.py | 176 +++++
backend/services/agent_service.py | 22 +-
backend/services/agent_version_service.py | 7 +-
backend/services/dify_service.py | 52 +-
docker/init.sql | 6 +-
...ngroup_permission_to_ag_tenant_agent_t.sql | 10 +
...tance_id_seq_and_agent_relation_id_seq.sql | 14 +
.../app/[locale]/agents/AgentVersionCard.tsx | 37 +-
.../components/agentConfig/ToolManagement.tsx | 48 +-
.../agentInfo/AgentGenerateDetail.tsx | 154 ++--
.../components/agentManage/AgentList.tsx | 28 +-
.../[locale]/space/components/AgentCard.tsx | 2 +-
frontend/app/[locale]/space/page.tsx | 4 +-
.../components/UserManageComp.tsx | 6 +-
frontend/const/errorCode.ts | 207 ++++++
frontend/const/errorMessage.ts | 212 ++++++
frontend/const/errorMessageI18n.ts | 238 ++++++
frontend/hooks/agent/useSaveGuard.ts | 1 +
frontend/hooks/auth/useAuthenticationState.ts | 6 +
frontend/hooks/useErrorHandler.ts | 167 +++++
frontend/hooks/useKnowledgeBaseSelector.ts | 18 +-
frontend/public/locales/en/common.json | 162 +++-
frontend/public/locales/zh/common.json | 177 ++++-
frontend/services/agentConfigService.ts | 4 +-
frontend/services/api.ts | 50 +-
frontend/services/knowledgeBaseService.ts | 55 +-
frontend/stores/agentConfigStore.ts | 60 +-
frontend/types/agentConfig.ts | 2 +
test/backend/app/test_app_factory.py | 701 ++++++++++++++++++
test/backend/app/test_config_app.py | 73 +-
test/backend/app/test_dify_app.py | 551 +++++++++++++-
test/backend/app/test_northbound_base_app.py | 23 +-
test/backend/consts/test_error_code.py | 353 +++++++++
test/backend/consts/test_error_message.py | 257 +++++++
test/backend/consts/test_exceptions.py | 195 +++++
.../middleware/test_exception_handler.py | 537 ++++++++++++++
test/backend/services/test_agent_service.py | 602 ++++++++++++++-
test/backend/services/test_dify_service.py | 204 ++++-
50 files changed, 5789 insertions(+), 396 deletions(-)
create mode 100644 backend/apps/app_factory.py
create mode 100644 backend/consts/error_code.py
create mode 100644 backend/consts/error_message.py
create mode 100644 backend/middleware/__init__.py
create mode 100644 backend/middleware/exception_handler.py
create mode 100644 docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql
create mode 100644 docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql
create mode 100644 frontend/const/errorCode.ts
create mode 100644 frontend/const/errorMessage.ts
create mode 100644 frontend/const/errorMessageI18n.ts
create mode 100644 frontend/hooks/useErrorHandler.ts
create mode 100644 test/backend/app/test_app_factory.py
create mode 100644 test/backend/consts/test_error_code.py
create mode 100644 test/backend/consts/test_error_message.py
create mode 100644 test/backend/consts/test_exceptions.py
create mode 100644 test/backend/middleware/test_exception_handler.py
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/const.py b/backend/consts/const.py
index f226d7443..2618d4568 100644
--- a/backend/consts/const.py
+++ b/backend/consts/const.py
@@ -71,6 +71,7 @@ class VectorDatabaseType(str, Enum):
# Permission constants used by list endpoints (e.g., /agent/list, /mcp/list).
PERMISSION_READ = "READ_ONLY"
PERMISSION_EDIT = "EDIT"
+PERMISSION_PRIVATE = "PRIVATE"
# Deployment Version Configuration
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/consts/model.py b/backend/consts/model.py
index 358e6ee3f..c9a5e3b8c 100644
--- a/backend/consts/model.py
+++ b/backend/consts/model.py
@@ -277,6 +277,7 @@ class AgentInfoRequest(BaseModel):
enabled_tool_ids: Optional[List[int]] = None
related_agent_ids: Optional[List[int]] = None
group_ids: Optional[List[int]] = None
+ ingroup_permission: Optional[str] = None
version_no: int = 0
diff --git a/backend/database/db_models.py b/backend/database/db_models.py
index a3ecb7120..36f475f53 100644
--- a/backend/database/db_models.py
+++ b/backend/database/db_models.py
@@ -226,6 +226,7 @@ class AgentInfo(TableBase):
group_ids = Column(String, doc="Agent group IDs list")
is_new = Column(Boolean, default=False, doc="Whether this agent is marked as new for the user")
current_version_no = Column(Integer, nullable=True, doc="Current published version number. NULL means no version published yet")
+ ingroup_permission = Column(String(30), doc="In-group permission: EDIT, READ_ONLY, PRIVATE")
class ToolInstance(TableBase):
@@ -236,7 +237,12 @@ class ToolInstance(TableBase):
__table_args__ = {"schema": SCHEMA}
tool_instance_id = Column(
- Integer, primary_key=True, nullable=False, doc="ID")
+ Integer,
+ Sequence("ag_tool_instance_t_tool_instance_id_seq", schema=SCHEMA),
+ primary_key=True,
+ nullable=False,
+ doc="ID"
+ )
tool_id = Column(Integer, doc="Tenant tool ID")
agent_id = Column(Integer, doc="Agent ID")
params = Column(JSON, doc="Parameter configuration")
@@ -351,7 +357,7 @@ class AgentRelation(TableBase):
__tablename__ = "ag_agent_relation_t"
__table_args__ = {"schema": SCHEMA}
- relation_id = Column(Integer, primary_key=True, autoincrement=True, nullable=False, doc="Relationship ID, primary key")
+ relation_id = Column(Integer, Sequence("ag_agent_relation_t_relation_id_seq", schema=SCHEMA), primary_key=True, nullable=False, doc="Relationship ID, primary key")
selected_agent_id = Column(Integer, primary_key=True, doc="Selected agent ID")
parent_agent_id = Column(Integer, doc="Parent agent ID")
tenant_id = Column(String(100), doc="Tenant ID")
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/agent_service.py b/backend/services/agent_service.py
index 4d15a3a9c..164b2965e 100644
--- a/backend/services/agent_service.py
+++ b/backend/services/agent_service.py
@@ -17,7 +17,7 @@
from agents.preprocess_manager import preprocess_manager
from services.agent_version_service import publish_version_impl
from consts.const import MEMORY_SEARCH_START_MSG, MEMORY_SEARCH_DONE_MSG, MEMORY_SEARCH_FAIL_MSG, TOOL_TYPE_MAPPING, \
- LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ
+ LANGUAGE, MESSAGE_ROLE, MODEL_CONFIG_MAPPING, CAN_EDIT_ALL_USER_ROLES, PERMISSION_EDIT, PERMISSION_READ, PERMISSION_PRIVATE
from consts.exceptions import MemoryPreparationException
from consts.model import (
AgentInfoRequest,
@@ -823,7 +823,8 @@ async def update_agent_info_impl(request: AgentInfoRequest, authorization: str =
"constraint_prompt": request.constraint_prompt,
"few_shots_prompt": request.few_shots_prompt,
"enabled": request.enabled if request.enabled is not None else True,
- "group_ids": convert_list_to_string(request.group_ids) if request.group_ids else user_group_ids
+ "group_ids": convert_list_to_string(request.group_ids) if request.group_ids else user_group_ids,
+ "ingroup_permission": request.ingroup_permission
}, tenant_id=tenant_id, user_id=user_id)
agent_id = created["agent_id"]
else:
@@ -1139,7 +1140,8 @@ async def import_agent_impl(
for sub_agent_id in managed_agents:
insert_related_agent(parent_agent_id=mapping_agent_id[need_import_agent_id],
child_agent_id=mapping_agent_id[sub_agent_id],
- tenant_id=tenant_id)
+ tenant_id=tenant_id,
+ user_id=user_id)
else:
# Current agent still has sub-agents that haven't been imported
agent_stack.append(need_import_agent_id)
@@ -1325,7 +1327,10 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]:
# Apply visibility filter for DEV/USER based on group overlap
if not can_edit_all:
agent_group_ids = set(convert_string_to_list(agent.get("group_ids")))
- if len(user_group_ids.intersection(agent_group_ids)) == 0 and user_id != agent.get("created_by"):
+ ingroup_permission = agent.get("ingroup_permission")
+ is_creator = str(agent.get("created_by")) == str(user_id)
+ # Hide agent if: no group overlap OR (ingroup_permission is PRIVATE AND user is not creator)
+ if len(user_group_ids.intersection(agent_group_ids)) == 0 or (ingroup_permission == PERMISSION_PRIVATE and not is_creator):
continue
# Use shared availability check function
@@ -1358,7 +1363,14 @@ async def list_all_agent_info_impl(tenant_id: str, user_id: str) -> list[dict]:
model_cache[model_id] = get_model_by_model_id(model_id, tenant_id)
model_info = model_cache.get(model_id)
- permission = PERMISSION_EDIT if can_edit_all or str(agent.get("created_by")) == str(user_id) else PERMISSION_READ
+ # Permission logic:
+ # - If creator or can_edit_all: PERMISSION_EDIT
+ # - Otherwise: use ingroup_permission, default to PERMISSION_READ if None
+ if can_edit_all or str(agent.get("created_by")) == str(user_id):
+ permission = PERMISSION_EDIT
+ else:
+ ingroup_permission = agent.get("ingroup_permission")
+ permission = ingroup_permission if ingroup_permission is not None else PERMISSION_READ
simple_agent_list.append({
"agent_id": agent["agent_id"],
diff --git a/backend/services/agent_version_service.py b/backend/services/agent_version_service.py
index 062a0402d..554b3a6d1 100644
--- a/backend/services/agent_version_service.py
+++ b/backend/services/agent_version_service.py
@@ -187,8 +187,8 @@ def get_version_detail_impl(
if key != 'current_version_no':
result[key] = value
- # Add tools
- result['tools'] = tools_snapshot
+ # Add tools (only enabled tools)
+ result['tools'] = [t for t in tools_snapshot if t.get('enabled', True)]
# Extract sub_agent_id_list from relations
result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_snapshot]
@@ -578,7 +578,8 @@ def _get_version_detail_or_draft(
if key != 'current_version_no':
result[key] = value
- result['tools'] = tools_draft
+ # Add tools (only enabled tools)
+ result['tools'] = [t for t in tools_draft if t.get('enabled', True)]
result['sub_agent_id_list'] = [r['selected_agent_id'] for r in relations_draft]
result['version'] = {
'version_name': 'Draft',
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/docker/init.sql b/docker/init.sql
index 9c3fac948..02e99632c 100644
--- a/docker/init.sql
+++ b/docker/init.sql
@@ -318,6 +318,7 @@ CREATE TABLE IF NOT EXISTS nexent.ag_tenant_agent_t (
provide_run_summary BOOLEAN DEFAULT FALSE,
version_no INTEGER DEFAULT 0 NOT NULL,
current_version_no INTEGER NULL,
+ ingroup_permission VARCHAR(30),
create_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(100),
@@ -371,6 +372,7 @@ COMMENT ON COLUMN nexent.ag_tenant_agent_t.delete_flag IS 'Whether it is deleted
COMMENT ON COLUMN nexent.ag_tenant_agent_t.is_new IS 'Whether this agent is marked as new for the user';
COMMENT ON COLUMN nexent.ag_tenant_agent_t.version_no IS 'Version number. 0 = draft/editing state, >=1 = published snapshot';
COMMENT ON COLUMN nexent.ag_tenant_agent_t.current_version_no IS 'Current published version number. NULL means no version published yet';
+COMMENT ON COLUMN nexent.ag_tenant_agent_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE';
-- Create index for is_new queries
CREATE INDEX IF NOT EXISTS idx_ag_tenant_agent_t_is_new
@@ -380,7 +382,7 @@ WHERE delete_flag = 'N';
-- Create the ag_tool_instance_t table in the nexent schema
CREATE TABLE IF NOT EXISTS nexent.ag_tool_instance_t (
- tool_instance_id INTEGER NOT NULL,
+ tool_instance_id SERIAL NOT NULL,
tool_id INTEGER,
agent_id INTEGER,
params JSON,
@@ -564,7 +566,7 @@ COMMENT ON COLUMN nexent.user_tenant_t.delete_flag IS 'Delete flag, Y/N';
-- Create the ag_agent_relation_t table in the nexent schema
CREATE TABLE IF NOT EXISTS nexent.ag_agent_relation_t (
- relation_id INTEGER NOT NULL,
+ relation_id SERIAL NOT NULL,
selected_agent_id INTEGER,
parent_agent_id INTEGER,
tenant_id VARCHAR(100),
diff --git a/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql b/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql
new file mode 100644
index 000000000..38ae17814
--- /dev/null
+++ b/docker/sql/v1.8.0.2_0227_add_ingroup_permission_to_ag_tenant_agent_t.sql
@@ -0,0 +1,10 @@
+-- Migration: Add ingroup_permission column to ag_tenant_agent_t table
+-- Date: 2025-03-02
+-- Description: Add ingroup_permission field to support in-group permission control for agents
+
+-- Add ingroup_permission column to ag_tenant_agent_t table
+ALTER TABLE nexent.ag_tenant_agent_t
+ADD COLUMN IF NOT EXISTS ingroup_permission VARCHAR(30) DEFAULT NULL;
+
+-- Add comment to the column
+COMMENT ON COLUMN nexent.ag_tenant_agent_t.ingroup_permission IS 'In-group permission: EDIT, READ_ONLY, PRIVATE';
diff --git a/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql b/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql
new file mode 100644
index 000000000..06fde6435
--- /dev/null
+++ b/docker/sql/v1.8.0.2_0302_add_tool_instance_id_seq_and_agent_relation_id_seq.sql
@@ -0,0 +1,14 @@
+-- Step 1: Create sequence for auto-increment
+CREATE SEQUENCE IF NOT EXISTS "nexent"."ag_tool_instance_t_tool_instance_id_seq"
+INCREMENT 1
+MINVALUE 1
+MAXVALUE 2147483647
+START 1
+CACHE 1;
+
+CREATE SEQUENCE IF NOT EXISTS "nexent"."ag_agent_relation_t_relation_id_seq"
+INCREMENT 1
+MINVALUE 1
+MAXVALUE 2147483647
+START 1
+CACHE 1;
diff --git a/frontend/app/[locale]/agents/AgentVersionCard.tsx b/frontend/app/[locale]/agents/AgentVersionCard.tsx
index aedf0b8eb..caf27b5f3 100644
--- a/frontend/app/[locale]/agents/AgentVersionCard.tsx
+++ b/frontend/app/[locale]/agents/AgentVersionCard.tsx
@@ -28,6 +28,7 @@ import {
DescriptionsProps,
Modal,
Dropdown,
+ Tooltip,
theme
} from "antd";
import { ExclamationCircleFilled } from '@ant-design/icons';
@@ -149,6 +150,13 @@ export function VersionCardItem({
const { tools: toolList } = useToolList();
const { agents: agentList } = useAgentList(user?.tenantId ?? null);
+ // Get current agent's permission from agent list
+ const currentAgent = useMemo(() => {
+ return agentList.find((a: Agent) => a.id === String(agentId));
+ }, [agentList, agentId]);
+
+ const isReadOnly = currentAgent?.permission === "READ_ONLY";
+
// Modal state
const [compareModalOpen, setCompareModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@@ -366,15 +374,28 @@ export function VersionCardItem({
items: [
{
key: 'edit',
- label: t("common.edit"),
+ label: isReadOnly ? (
+
+ {t("common.edit")}
+
+ ) : (
+ t("common.edit")
+ ),
icon: ,
+ disabled: isReadOnly,
onClick: () => setEditModalOpen(true)
},
{
key: 'rollback',
- label: t("agent.version.rollback"),
+ label: isReadOnly ? (
+
+ {t("agent.version.rollback")}
+
+ ) : (
+ t("agent.version.rollback")
+ ),
icon: ,
- disabled: isCurrentVersion || version.status.toLowerCase() === "disabled",
+ disabled: isReadOnly || isCurrentVersion || version.status.toLowerCase() === "disabled",
onClick: handleRollbackClick
},
{
@@ -382,9 +403,15 @@ export function VersionCardItem({
},
{
key: 'delete',
- label: t("common.delete"),
+ label: isReadOnly ? (
+
+ {t("common.delete")}
+
+ ) : (
+ t("common.delete")
+ ),
icon: ,
- disabled: isCurrentVersion,
+ disabled: isReadOnly || isCurrentVersion,
danger: true,
onClick: handleDeleteClick,
},
diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
index 91ce11d33..0cd276139 100644
--- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
+++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx
@@ -75,7 +75,15 @@ export default function ToolManagement({
const { t } = useTranslation("common");
const queryClient = useQueryClient();
- const editable = currentAgentId || isCreatingMode;
+ // Get current agent permission from store
+ const currentAgentPermission = useAgentConfigStore(
+ (state) => state.currentAgentPermission
+ );
+
+ // Check if current agent is read-only (only when agent is selected and permission is READ_ONLY)
+ const isReadOnly = !isCreatingMode && currentAgentId !== undefined && currentAgentPermission === "READ_ONLY";
+
+ const editable = (currentAgentId || isCreatingMode) && !isReadOnly;
// Get state from store
const originalSelectedTools = useAgentConfigStore(
@@ -423,8 +431,16 @@ export default function ToolManagement({
);
const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured);
const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured);
- const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding;
- return (
+ const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding || isReadOnly;
+ // Tooltip priority: permission > VLM > Embedding
+ const tooltipTitle = isReadOnly
+ ? t("agent.noEditPermission")
+ : isDisabledDueToVlm
+ ? t("toolPool.vlmDisabledTooltip")
+ : isDisabledDueToEmbedding
+ ? t("toolPool.embeddingDisabledTooltip")
+ : undefined;
+ const toolCard = (
);
+ return tooltipTitle ? (
+
+ {toolCard}
+
+ ) : (
+ toolCard
+ );
})}
),
@@ -513,8 +536,16 @@ export default function ToolManagement({
const isSelected = originalSelectedToolIdsSet.has(tool.id);
const isDisabledDueToVlm = isToolDisabledDueToVlm(tool.name, isVlmConfigured);
const isDisabledDueToEmbedding = isToolDisabledDueToEmbedding(tool.name, isEmbeddingConfigured);
- const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding;
- return (
+ const isDisabled = isDisabledDueToVlm || isDisabledDueToEmbedding || isReadOnly;
+ // Tooltip priority: permission > VLM > Embedding
+ const tooltipTitle = isReadOnly
+ ? t("agent.noEditPermission")
+ : isDisabledDueToVlm
+ ? t("toolPool.vlmDisabledTooltip")
+ : isDisabledDueToEmbedding
+ ? t("toolPool.embeddingDisabledTooltip")
+ : undefined;
+ const toolCard = (
);
+ return tooltipTitle ? (
+
+ {toolCard}
+
+ ) : (
+ toolCard
+ );
})}
)}
diff --git a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
index d3a3eab17..27129594d 100644
--- a/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
+++ b/frontend/app/[locale]/agents/components/agentInfo/AgentGenerateDetail.tsx
@@ -20,16 +20,11 @@ import type { TabsProps } from "antd";
import { Zap, Maximize2 } from "lucide-react";
import log from "@/lib/logger";
-import { EditableAgent } from "@/stores/agentConfigStore";
import { AgentProfileInfo, AgentBusinessInfo } from "@/types/agentConfig";
import { configService } from "@/services/configService";
import { ConfigStore } from "@/lib/config";
+import { useAgentList } from "@/hooks/agent/useAgentList";
import {
- checkAgentName,
- checkAgentDisplayName,
-} from "@/services/agentConfigService";
-import {
- NAME_CHECK_STATUS,
GENERATE_PROMPT_STREAM_TYPES,
} from "@/const/agentConfig";
import { generatePromptStream } from "@/services/promptService";
@@ -76,11 +71,24 @@ export default function AgentGenerateDetail({
const { data: tenantData } = useTenantList();
const tenantId = user?.tenantId ?? tenantData?.data?.[0]?.tenant_id ?? null;
const { data: groupData } = useGroupList(tenantId, 1, 100);
+
+ // Agent list for name uniqueness validation (use local data instead of API call)
+ const { agents: agentList } = useAgentList(tenantId);
const groups = groupData?.groups || [];
// State management
const [activeTab, setActiveTab] = useState("agent-info");
+ // Local state to track generated content (fix for stream data not syncing with form state)
+ const [generatedContent, setGeneratedContent] = useState({
+ dutyPrompt: "",
+ constraintPrompt: "",
+ fewShotsPrompt: "",
+ agentName: "",
+ agentDescription: "",
+ agentDisplayName: "",
+ });
+
// Modal states
const [expandModalOpen, setExpandModalOpen] = useState(false);
const [expandModalType, setExpandModalType] = useState<'duty' | 'constraint' | 'few-shots' | null>(null);
@@ -213,6 +221,7 @@ export default function AgentGenerateDetail({
mainAgentMaxStep: editedAgent.max_step || 5,
agentDescription: editedAgent.description || "",
group_ids: normalizeNumberArray(editedAgent.group_ids || []),
+ ingroup_permission: editedAgent.ingroup_permission || "READ_ONLY",
dutyPrompt: editedAgent.duty_prompt || "",
constraintPrompt: editedAgent.constraint_prompt || "",
fewShotsPrompt: editedAgent.few_shots_prompt || "",
@@ -250,7 +259,7 @@ export default function AgentGenerateDetail({
});
}
- }, [currentAgentId, defaultLlmModel?.id, isCreatingMode]);
+ }, [currentAgentId, defaultLlmModel?.id, isCreatingMode, editedAgent.ingroup_permission]);
// Default to selecting all groups when creating a new agent.
// Only applies when groups are loaded and no group is selected yet.
@@ -413,45 +422,38 @@ export default function AgentGenerateDetail({
}
};
- // Custom validator for agent name uniqueness
- const validateAgentNameUnique = async (_: any, value: string) => {
+ // Generic validator for agent field uniqueness - use local agent list instead of API call
+ const validateAgentFieldUnique = async (
+ _: any,
+ value: string,
+ fieldName: "name" | "display_name",
+ errorKey: "nameExists" | "displayNameExists"
+ ) => {
if (!value) return Promise.resolve();
- try {
- const result = await checkAgentName(value, currentAgentId || undefined);
- if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) {
- return Promise.reject(
- new Error(t("agent.error.nameExists", { name: value }))
- );
- }
- return Promise.resolve();
- } catch (error) {
+ // Check if field value already exists in local agent list (excluding current agent)
+ const isDuplicated = agentList?.some(
+ (agent: { name?: string; display_name?: string; id?: string | number }) =>
+ (agent as any)[fieldName] === value &&
+ Number(agent.id) !== currentAgentId
+ );
+
+ if (isDuplicated) {
return Promise.reject(
- new Error(t("agent.error.displayNameExists", value))
+ new Error(t(`agent.error.${errorKey}`, { [fieldName]: value }))
);
}
+ return Promise.resolve();
+ };
+
+ // Custom validator for agent name uniqueness
+ const validateAgentNameUnique = async (_: any, value: string) => {
+ return validateAgentFieldUnique(_, value, "name", "nameExists");
};
// Custom validator for agent display name uniqueness
const validateAgentDisplayNameUnique = async (_: any, value: string) => {
- if (!value) return Promise.resolve();
-
- try {
- const result = await checkAgentDisplayName(
- value,
- currentAgentId || undefined
- );
- if (result.status === NAME_CHECK_STATUS.EXISTS_IN_TENANT) {
- return Promise.reject(
- new Error(t("agent.error.displayNameExists", { displayName: value }))
- );
- }
- return Promise.resolve();
- } catch (error) {
- return Promise.reject(
- new Error(t("agent.error.displayNameExists", value))
- );
- }
+ return validateAgentFieldUnique(_, value, "display_name", "displayNameExists");
};
const handleGenerateAgent = async () => {
@@ -483,10 +485,10 @@ export default function AgentGenerateDetail({
sub_agent_ids: editedAgent.sub_agent_id_list,
tool_ids: Array.isArray(editedAgent.tools)
? editedAgent.tools.map((tool: any) =>
- typeof tool === "object" && tool.id !== undefined
- ? tool.id
- : tool
- )
+ typeof tool === "object" && tool.id !== undefined
+ ? tool.id
+ : tool
+ )
: [],
},
(data) => {
@@ -495,27 +497,50 @@ export default function AgentGenerateDetail({
switch (data.type) {
case GENERATE_PROMPT_STREAM_TYPES.DUTY:
form.setFieldsValue({ dutyPrompt: data.content });
+ setGeneratedContent((prev) => ({
+ ...prev,
+ dutyPrompt: data.content,
+ }));
break;
case GENERATE_PROMPT_STREAM_TYPES.CONSTRAINT:
form.setFieldsValue({ constraintPrompt: data.content });
+ setGeneratedContent((prev) => ({
+ ...prev,
+ constraintPrompt: data.content,
+ }));
break;
case GENERATE_PROMPT_STREAM_TYPES.FEW_SHOTS:
-
form.setFieldsValue({ fewShotsPrompt: data.content });
+ setGeneratedContent((prev) => ({
+ ...prev,
+ fewShotsPrompt: data.content,
+ }));
break;
case GENERATE_PROMPT_STREAM_TYPES.AGENT_VAR_NAME:
if (!form.getFieldValue("agentName")?.trim()) {
form.setFieldsValue({ agentName: data.content });
}
+ setGeneratedContent((prev) => ({
+ ...prev,
+ agentName: data.content,
+ }));
break;
case GENERATE_PROMPT_STREAM_TYPES.AGENT_DESCRIPTION:
form.setFieldsValue({ agentDescription: data.content });
+ setGeneratedContent((prev) => ({
+ ...prev,
+ agentDescription: data.content,
+ }));
break;
case GENERATE_PROMPT_STREAM_TYPES.AGENT_DISPLAY_NAME:
// Only update if current agent display name is empty
if (!form.getFieldValue("agentDisplayName")?.trim()) {
form.setFieldsValue({ agentDisplayName: data.content });
}
+ setGeneratedContent((prev) => ({
+ ...prev,
+ agentDisplayName: data.content,
+ }));
break;
}
},
@@ -526,22 +551,34 @@ export default function AgentGenerateDetail({
},
() => {
// After generation completes, get all form values and update parent component state
+ // Use generatedContent state as fallback to ensure we get the streamed data
const formValues = form.getFieldsValue();
const profileUpdates: AgentProfileInfo = {
- name: formValues.agentName,
- display_name: formValues.agentDisplayName,
+ name: generatedContent.agentName || formValues.agentName,
+ display_name: generatedContent.agentDisplayName || formValues.agentDisplayName,
author: formValues.agentAuthor,
model: formValues.mainAgentModel,
max_step: formValues.mainAgentMaxStep,
- description: formValues.agentDescription,
- duty_prompt: formValues.dutyPrompt,
- constraint_prompt: formValues.constraintPrompt,
- few_shots_prompt: formValues.fewShotsPrompt,
+ description: generatedContent.agentDescription || formValues.agentDescription,
+ duty_prompt: generatedContent.dutyPrompt || formValues.dutyPrompt,
+ constraint_prompt: generatedContent.constraintPrompt || formValues.constraintPrompt,
+ few_shots_prompt: generatedContent.fewShotsPrompt || formValues.fewShotsPrompt,
+ ingroup_permission: formValues.ingroup_permission || "READ_ONLY",
};
// Update profile info in global agent config store
updateProfileInfo(profileUpdates);
+ // Reset generated content state after updating
+ setGeneratedContent({
+ dutyPrompt: "",
+ constraintPrompt: "",
+ fewShotsPrompt: "",
+ agentName: "",
+ agentDescription: "",
+ agentDisplayName: "",
+ });
+
message.success(t("businessLogic.config.message.generateSuccess"));
setIsGenerating(false);
}
@@ -611,6 +648,7 @@ export default function AgentGenerateDetail({
},
{ validator: validateAgentNameUnique },
]}
+ validateTrigger={["onBlur"]}
className="mb-3"
>
+
+
+
+
+
-
+
}
onClick={(e) => {
@@ -522,6 +533,7 @@ export default function AgentList({
e.stopPropagation();
handleDeleteAgentWithConfirm(agent);
}}
+ disabled={agent.permission === "READ_ONLY"}
className="agent-action-button agent-action-button-red"
/>
diff --git a/frontend/app/[locale]/space/components/AgentCard.tsx b/frontend/app/[locale]/space/components/AgentCard.tsx
index 9b9cff7a0..5152b0b41 100644
--- a/frontend/app/[locale]/space/components/AgentCard.tsx
+++ b/frontend/app/[locale]/space/components/AgentCard.tsx
@@ -239,7 +239,7 @@ export default function AgentCard({ agent, onRefresh }: AgentCardProps) {
)}
-
+
{agent.description || t("space.noDescription", "No description")}
diff --git a/frontend/app/[locale]/space/page.tsx b/frontend/app/[locale]/space/page.tsx
index 9bc72eb90..58fdb06a7 100644
--- a/frontend/app/[locale]/space/page.tsx
+++ b/frontend/app/[locale]/space/page.tsx
@@ -139,14 +139,13 @@ export default function SpacePage() {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
- className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 pb-8"
+ className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-4 gap-4 pb-8"
>
{/* Create/Import agent card - only for admin */}
{/* Create new agent - top half */}
@@ -187,7 +186,6 @@ export default function SpacePage() {
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: 0.3 + (index + 1) * 0.05 }}
- className="aspect-[4/3]"
>
diff --git a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx
index 9823a9b53..ffa68f62b 100644
--- a/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx
+++ b/frontend/app/[locale]/tenant-resources/components/UserManageComp.tsx
@@ -283,12 +283,10 @@ function TenantList({
? "bg-blue-50 border border-blue-200"
: "hover:bg-gray-50"
}`}
+ onClick={() => onSelect(tenant.tenant_id)}
>
-
onSelect(tenant.tenant_id)}
- >
+
{tenant.tenant_name || t("tenantResources.tenants.unnamed")}
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/agent/useSaveGuard.ts b/frontend/hooks/agent/useSaveGuard.ts
index e41b4e0b1..a1f4cea35 100644
--- a/frontend/hooks/agent/useSaveGuard.ts
+++ b/frontend/hooks/agent/useSaveGuard.ts
@@ -68,6 +68,7 @@ export const useSaveGuard = () => {
business_logic_model_id: currentEditedAgent.business_logic_model_id ?? undefined,
enabled_tool_ids: enabledToolIds,
related_agent_ids: relatedAgentIds,
+ ingroup_permission: currentEditedAgent.ingroup_permission ?? "READ_ONLY",
});
if (result.success) {
diff --git a/frontend/hooks/auth/useAuthenticationState.ts b/frontend/hooks/auth/useAuthenticationState.ts
index 34430fe00..dc206c875 100644
--- a/frontend/hooks/auth/useAuthenticationState.ts
+++ b/frontend/hooks/auth/useAuthenticationState.ts
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
import { App } from "antd";
import { useDeployment } from "@/components/providers/deploymentProvider";
+import { useQueryClient } from "@tanstack/react-query";
import { authService } from "@/services/authService";
import { getSessionFromStorage, removeSessionFromStorage, checkSessionValid } from "@/lib/session";
import { Session, AuthenticationStateReturn, AuthenticationContextType } from "@/types/auth";
@@ -20,6 +21,7 @@ export function useAuthenticationState(): AuthenticationStateReturn {
const { t } = useTranslation("common");
const { message } = App.useApp();
const { isSpeedMode } = useDeployment();
+ const queryClient = useQueryClient();
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -172,6 +174,7 @@ export function useAuthenticationState(): AuthenticationStateReturn {
setSession(null);
setIsAuthenticated(false);
+ queryClient.clear();
if (!silent) {
message.success(t("auth.logoutSuccess"));
}
@@ -185,6 +188,7 @@ export function useAuthenticationState(): AuthenticationStateReturn {
setSession(null);
setIsAuthenticated(false);
+ queryClient.clear();
if (!silent) {
message.error(t("auth.logoutFailed"));
}
@@ -204,11 +208,13 @@ export function useAuthenticationState(): AuthenticationStateReturn {
clearLocalSession();
message.success(t("auth.revokeSuccess"));
+ queryClient.clear();
authEventUtils.emitLogout();
} catch (error: any) {
log.error("Revoke failed:", error?.message || error);
message.error(t("auth.revokeFailed"));
+ queryClient.clear();
} finally {
setIsLoading(false);
}
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/agentConfigService.ts b/frontend/services/agentConfigService.ts
index c2269199e..d08ce42a8 100644
--- a/frontend/services/agentConfigService.ts
+++ b/frontend/services/agentConfigService.ts
@@ -391,6 +391,7 @@ export interface UpdateAgentInfoPayload {
business_logic_model_id?: number;
enabled_tool_ids?: number[];
related_agent_ids?: number[];
+ ingroup_permission?: string;
}
export const updateAgentInfo = async (payload: UpdateAgentInfoPayload) => {
@@ -649,7 +650,7 @@ export const regenerateAgentNameBatch = async (payload: {
*/
export const searchAgentInfo = async (agentId: number, tenantId?: string, versionNo?: number) => {
try {
- const url = tenantId
+ const url = tenantId
? `${API_ENDPOINTS.agent.searchInfo}?tenant_id=${encodeURIComponent(tenantId)}`
: API_ENDPOINTS.agent.searchInfo;
const response = await fetch(url, {
@@ -689,6 +690,7 @@ export const searchAgentInfo = async (agentId: number, tenantId?: string, versio
unavailable_reasons: data.unavailable_reasons || [],
sub_agent_id_list: data.sub_agent_id_list || [], // Add sub_agent_id_list
group_ids: data.group_ids || [],
+ ingroup_permission: data.ingroup_permission || "READ_ONLY",
tools: data.tools
? data.tools.map((tool: any) => {
const params =
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/frontend/stores/agentConfigStore.ts b/frontend/stores/agentConfigStore.ts
index c0713db68..1cd323b76 100644
--- a/frontend/stores/agentConfigStore.ts
+++ b/frontend/stores/agentConfigStore.ts
@@ -37,6 +37,7 @@ export type EditableAgent = Pick<
| "business_logic_model_id"
| "sub_agent_id_list"
| "group_ids"
+ | "ingroup_permission"
>;
interface AgentConfigStoreState {
@@ -129,6 +130,7 @@ const emptyEditableAgent: EditableAgent = {
business_logic_model_id: 0,
sub_agent_id_list: [],
group_ids: [],
+ ingroup_permission: "READ_ONLY",
};
const toEditable = (agent: Agent | null): EditableAgent =>
@@ -151,6 +153,7 @@ const toEditable = (agent: Agent | null): EditableAgent =>
business_logic_model_id: agent.business_logic_model_id || 0,
sub_agent_id_list: agent.sub_agent_id_list || [],
group_ids: agent.group_ids || [],
+ ingroup_permission: agent.ingroup_permission || "READ_ONLY",
}
: { ...emptyEditableAgent };
@@ -189,7 +192,8 @@ const isProfileInfoDirty = (baselineAgent: EditableAgent | null, editedAgent: Ed
editedAgent.duty_prompt !== "" ||
editedAgent.constraint_prompt !== "" ||
editedAgent.few_shots_prompt !== "" ||
- normalizeArray(editedAgent.group_ids || []).length > 0
+ normalizeArray(editedAgent.group_ids || []).length > 0 ||
+ editedAgent.ingroup_permission !== "READ_ONLY"
);
}
return (
@@ -205,7 +209,8 @@ const isProfileInfoDirty = (baselineAgent: EditableAgent | null, editedAgent: Ed
baselineAgent.constraint_prompt !== editedAgent.constraint_prompt ||
baselineAgent.few_shots_prompt !== editedAgent.few_shots_prompt ||
JSON.stringify(normalizeArray(baselineAgent.group_ids ?? [])) !==
- JSON.stringify(normalizeArray(editedAgent.group_ids ?? []))
+ JSON.stringify(normalizeArray(editedAgent.group_ids ?? [])) ||
+ baselineAgent.ingroup_permission !== editedAgent.ingroup_permission
);
};
@@ -213,7 +218,47 @@ const isToolsDirty = (baselineAgent: EditableAgent | null, editedAgent: Editable
if (!baselineAgent) {
return editedAgent.tools.length > 0;
}
- return JSON.stringify(baselineAgent.tools) !== JSON.stringify(editedAgent.tools);
+
+ // Compare tools by ID and their initParams to avoid false positives from object reference differences
+ const baselineTools = baselineAgent.tools;
+ const editedTools = editedAgent.tools;
+
+ // First check if the count is different
+ if (baselineTools.length !== editedTools.length) {
+ return true;
+ }
+
+ // Sort by ID and compare key properties to handle different orderings
+ const sortedBaseline = [...baselineTools].sort((a, b) => Number(a.id) - Number(b.id));
+ const sortedEdited = [...editedTools].sort((a, b) => Number(a.id) - Number(b.id));
+
+ for (let i = 0; i < sortedBaseline.length; i++) {
+ const baseTool = sortedBaseline[i];
+ const editTool = sortedEdited[i];
+
+ // Check if ID is different
+ if (Number(baseTool.id) !== Number(editTool.id)) {
+ return true;
+ }
+
+ // Compare initParams if they exist
+ const baseParams = baseTool.initParams || [];
+ const editParams = editTool.initParams || [];
+
+ if (baseParams.length !== editParams.length) {
+ return true;
+ }
+
+ // Compare each param's name and value
+ for (const baseParam of baseParams) {
+ const editParam = editParams.find(p => p.name === baseParam.name);
+ if (!editParam || baseParam.value !== editParam.value) {
+ return true;
+ }
+ }
+ }
+
+ return false;
};
const isSubAgentIdsDirty = (baselineAgent: EditableAgent | null, editedAgent: EditableAgent): boolean => {
@@ -259,11 +304,10 @@ export const useAgentConfigStore = create((set, get) => (
updateTools: (tools) => {
set((state) => {
const editedAgent = { ...state.editedAgent, tools: [...tools] };
- // If there are already unsaved changes, keep it true and skip recalculation.
- // Only when state is clean do we need to check whether tools changed.
- const hasUnsavedChanges = state.hasUnsavedChanges
- ? true
- : isToolsDirty(state.baselineAgent, editedAgent);
+ // Always recalculate hasUnsavedChanges to correctly handle:
+ // 1. Selecting a tool -> hasUnsavedChanges = true
+ // 2. Deselecting it back to original -> hasUnsavedChanges = false
+ const hasUnsavedChanges = isToolsDirty(state.baselineAgent, editedAgent);
return {
editedAgent,
hasUnsavedChanges,
diff --git a/frontend/types/agentConfig.ts b/frontend/types/agentConfig.ts
index e258ffc37..4c53a2629 100644
--- a/frontend/types/agentConfig.ts
+++ b/frontend/types/agentConfig.ts
@@ -24,6 +24,7 @@ export type AgentProfileInfo = Partial<
| "constraint_prompt"
| "few_shots_prompt"
| "group_ids"
+ | "ingroup_permission"
>
>;
@@ -51,6 +52,7 @@ export interface Agent {
is_new?: boolean;
sub_agent_id_list?: number[];
group_ids?: number[];
+ ingroup_permission?: "EDIT" | "READ_ONLY" | "PRIVATE";
/**
* Per-agent permission returned by /agent/list.
* EDIT: editable, READ_ONLY: read-only.
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_agent_service.py b/test/backend/services/test_agent_service.py
index 5f8caf9e9..790def658 100644
--- a/test/backend/services/test_agent_service.py
+++ b/test/backend/services/test_agent_service.py
@@ -2494,14 +2494,14 @@ def convert_side_effect(x):
result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
# Should see:
- # - Agent 1: created by current_user, even though groups don't overlap (new logic)
# - Agent 3: groups overlap (1 is in both user's groups and agent's groups)
# Should NOT see:
+ # - Agent 1: created by current_user but groups don't overlap (no group overlap hides it regardless of creator)
# - Agent 2: not created by current_user AND groups don't overlap
- assert len(result) == 2
+ assert len(result) == 1
agent_ids = [a["agent_id"] for a in result]
- assert 1 in agent_ids, "Agent 1 should be visible because user is the creator"
assert 3 in agent_ids, "Agent 3 should be visible because groups overlap"
+ assert 1 not in agent_ids, "Agent 1 should be filtered out (no group overlap, even though user is creator)"
assert 2 not in agent_ids, "Agent 2 should be filtered out (not creator and no group overlap)"
@@ -2644,7 +2644,7 @@ async def test_list_all_agent_info_impl_group_query_error_for_user_role(
mock_get_user_tenant.return_value = {"user_role": "USER"}
# Simulate exception when querying group IDs - this should trigger lines 1274-1278
mock_query_groups.side_effect = Exception("Database connection error")
-
+
# Mock convert_string_to_list to handle comma-separated values
def convert_side_effect(x):
if not x or (isinstance(x, str) and x.strip() == ""):
@@ -2657,7 +2657,7 @@ def convert_side_effect(x):
result.append(int(stripped))
return result
mock_convert_list.side_effect = convert_side_effect
-
+
# Mock check_agent_availability to return (is_available, unavailable_reasons)
mock_check_availability.return_value = (True, [])
mock_get_model.return_value = None
@@ -3370,6 +3370,7 @@ async def test_import_agent_impl_imports_all_agents_and_links_relations(
parent_agent_id=202,
child_agent_id=101,
tenant_id="test_tenant",
+ user_id="test_user",
)
@@ -6535,8 +6536,8 @@ async def fake_import_agent_by_agent_id(import_agent_info, tenant_id, user_id, s
relationships = []
- def fake_insert_related_agent(parent_agent_id, child_agent_id, tenant_id):
- relationships.append((parent_agent_id, child_agent_id, tenant_id))
+ def fake_insert_related_agent(parent_agent_id, child_agent_id, tenant_id, user_id):
+ relationships.append((parent_agent_id, child_agent_id, tenant_id, user_id))
async def fake_update_tool_list(tenant_id, user_id):
return None
@@ -6563,7 +6564,7 @@ async def fake_update_tool_list(tenant_id, user_id):
# Child (2) must be imported before parent (1)
assert imported_ids == [2, 1]
# Relationship should be created between new IDs 101 (child) and 100 (parent)
- assert relationships == [(100 + 1, 100 + 2, "tenant1")]
+ assert relationships == [(100 + 1, 100 + 2, "tenant1", "user1")]
# =====================================================================
@@ -7867,4 +7868,589 @@ async def test_clear_agent_new_mark_impl_with_special_characters():
"clear_agent_new_mark_impl called for agent_id=789, tenant_id=tenant-with-dashes_and_underscores, user_id=user@domain.com, affected_rows=1"
)
+# Tests for ingroup_permission and group_ids functionality
+@patch('backend.services.agent_service.create_or_update_tool_by_tool_info')
+@patch('backend.services.agent_service.query_tool_instances_by_id')
+@patch('backend.services.agent_service.query_all_tools')
+@patch('backend.services.agent_service.create_agent')
+@patch('backend.services.agent_service.get_current_user_info')
+@patch('backend.services.agent_service.convert_list_to_string')
+@patch('backend.services.agent_service._get_user_group_ids')
+@pytest.mark.asyncio
+async def test_update_agent_info_impl_create_agent_with_ingroup_permission(
+ mock_get_user_group_ids,
+ mock_convert_list_to_string,
+ mock_get_current_user_info,
+ mock_create_agent,
+ mock_query_all_tools,
+ mock_query_tool_instances_by_id,
+ mock_create_or_update_tool
+):
+ """Test creating agent with ingroup_permission set."""
+ from consts.const import PERMISSION_READ, PERMISSION_EDIT, PERMISSION_PRIVATE
+
+ mock_get_current_user_info.return_value = ("test_user", "test_tenant", "en")
+ mock_get_user_group_ids.return_value = "1,2,3"
+ mock_convert_list_to_string.return_value = "1,2"
+ mock_create_agent.return_value = {"agent_id": 123}
+
+ request = MagicMock()
+ request.agent_id = None
+ request.name = "Test Agent"
+ request.display_name = "Test Display"
+ request.description = "Test description"
+ request.business_description = None
+ request.author = None
+ request.model_id = None
+ request.model_name = None
+ request.business_logic_model_id = None
+ request.business_logic_model_name = None
+ request.max_steps = None
+ request.provide_run_summary = None
+ request.duty_prompt = None
+ request.constraint_prompt = None
+ request.few_shots_prompt = None
+ request.enabled = True
+ request.enabled_tool_ids = None
+ request.related_agent_ids = None
+ request.group_ids = [1, 2]
+ request.ingroup_permission = PERMISSION_READ
+
+ result = await update_agent_info_impl(request, authorization="Bearer token")
+
+ assert result["agent_id"] == 123
+ call_args = mock_create_agent.call_args[1]["agent_info"]
+ assert call_args["ingroup_permission"] == PERMISSION_READ
+ assert call_args["group_ids"] == "1,2"
+
+
+@patch('backend.services.agent_service.create_or_update_tool_by_tool_info')
+@patch('backend.services.agent_service.query_tool_instances_by_id')
+@patch('backend.services.agent_service.query_all_tools')
+@patch('backend.services.agent_service.create_agent')
+@patch('backend.services.agent_service.get_current_user_info')
+@patch('backend.services.agent_service._get_user_group_ids')
+@pytest.mark.asyncio
+async def test_update_agent_info_impl_create_agent_with_ingroup_permission_none(
+ mock_get_user_group_ids,
+ mock_get_current_user_info,
+ mock_create_agent,
+ mock_query_all_tools,
+ mock_query_tool_instances_by_id,
+ mock_create_or_update_tool
+):
+ """Test creating agent with ingroup_permission None."""
+ mock_get_current_user_info.return_value = ("test_user", "test_tenant", "en")
+ mock_get_user_group_ids.return_value = "1,2,3"
+ mock_create_agent.return_value = {"agent_id": 456}
+
+ request = MagicMock()
+ request.agent_id = None
+ request.name = "Test Agent"
+ request.display_name = "Test Display"
+ request.description = "Test description"
+ request.business_description = None
+ request.author = None
+ request.model_id = None
+ request.model_name = None
+ request.business_logic_model_id = None
+ request.business_logic_model_name = None
+ request.max_steps = None
+ request.provide_run_summary = None
+ request.duty_prompt = None
+ request.constraint_prompt = None
+ request.few_shots_prompt = None
+ request.enabled = True
+ request.enabled_tool_ids = None
+ request.related_agent_ids = None
+ request.group_ids = None
+ request.ingroup_permission = None
+
+ result = await update_agent_info_impl(request, authorization="Bearer token")
+
+ assert result["agent_id"] == 456
+ call_args = mock_create_agent.call_args[1]["agent_info"]
+ assert call_args["ingroup_permission"] is None
+ assert call_args["group_ids"] == "1,2,3" # Should use user's groups
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_creator_with_private_permission_no_group_overlap(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that creators cannot see their own agents if no group overlap, even with PRIVATE permission."""
+ from consts.const import PERMISSION_PRIVATE
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent with PRIVATE permission, created by current_user, but no group overlap",
+ "enabled": True,
+ "group_ids": "5,6", # No overlap with user's groups [1, 2]
+ "ingroup_permission": PERMISSION_PRIVATE,
+ "created_by": "current_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ # Creator cannot see their own agent if no group overlap (no group overlap hides it regardless of creator)
+ assert len(result) == 0
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_creator_with_private_permission_with_group_overlap(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that creators can see their own agents with PRIVATE permission if there is group overlap."""
+ from consts.const import PERMISSION_PRIVATE
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent with PRIVATE permission, created by current_user, with group overlap",
+ "enabled": True,
+ "group_ids": "1,6", # Overlaps with user's group 1
+ "ingroup_permission": PERMISSION_PRIVATE,
+ "created_by": "current_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ # Creator can see their own agent with PRIVATE permission if there is group overlap
+ assert len(result) == 1
+ assert result[0]["agent_id"] == 1
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_non_creator_with_private_permission_hidden(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that non-creators cannot see agents with PRIVATE permission even with group overlap."""
+ from consts.const import PERMISSION_PRIVATE
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent with PRIVATE permission, not created by current_user",
+ "enabled": True,
+ "group_ids": "1,2", # Overlaps with user's groups [1, 2]
+ "ingroup_permission": PERMISSION_PRIVATE,
+ "created_by": "other_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ # Non-creator should NOT see agent with PRIVATE permission even with group overlap
+ assert len(result) == 0
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_permission_assignment_creator_gets_edit(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that creators get PERMISSION_EDIT regardless of ingroup_permission."""
+ from consts.const import PERMISSION_READ, PERMISSION_EDIT
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent created by current_user",
+ "enabled": True,
+ "group_ids": "1,2",
+ "ingroup_permission": PERMISSION_READ, # Even with READ permission
+ "created_by": "current_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ assert len(result) == 1
+ assert result[0]["permission"] == PERMISSION_EDIT # Creator gets EDIT
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_permission_assignment_non_creator_uses_ingroup_permission(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that non-creators use ingroup_permission when set."""
+ from consts.const import PERMISSION_READ, PERMISSION_EDIT
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent not created by current_user",
+ "enabled": True,
+ "group_ids": "1,2",
+ "ingroup_permission": PERMISSION_EDIT, # Set to EDIT
+ "created_by": "other_user",
+ "create_time": 1,
+ },
+ {
+ "agent_id": 2,
+ "name": "Agent 2",
+ "display_name": "Display Agent 2",
+ "description": "Agent with READ permission",
+ "enabled": True,
+ "group_ids": "1,2",
+ "ingroup_permission": PERMISSION_READ, # Set to READ
+ "created_by": "other_user",
+ "create_time": 2,
+ },
+ {
+ "agent_id": 3,
+ "name": "Agent 3",
+ "display_name": "Display Agent 3",
+ "description": "Agent with None permission",
+ "enabled": True,
+ "group_ids": "1,2",
+ "ingroup_permission": None, # None should default to READ
+ "created_by": "other_user",
+ "create_time": 3,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ assert len(result) == 3
+ agent1 = next(a for a in result if a["agent_id"] == 1)
+ agent2 = next(a for a in result if a["agent_id"] == 2)
+ agent3 = next(a for a in result if a["agent_id"] == 3)
+ assert agent1["permission"] == PERMISSION_EDIT
+ assert agent2["permission"] == PERMISSION_READ
+ assert agent3["permission"] == PERMISSION_READ # None defaults to READ
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_admin_gets_edit_permission(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that admin users (can_edit_all) get PERMISSION_EDIT regardless of ingroup_permission."""
+ from consts.const import PERMISSION_READ, PERMISSION_EDIT
+
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent with READ permission",
+ "enabled": True,
+ "group_ids": "1,2",
+ "ingroup_permission": PERMISSION_READ,
+ "created_by": "other_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "ADMIN"} # Admin role
+ mock_query_groups.return_value = []
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="admin_user")
+
+ assert len(result) == 1
+ assert result[0]["permission"] == PERMISSION_EDIT # Admin gets EDIT
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_non_creator_no_group_overlap_hidden(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that non-creators without group overlap are hidden."""
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent not created by current_user, no group overlap",
+ "enabled": True,
+ "group_ids": "5,6", # No overlap with user's groups [1, 2]
+ "ingroup_permission": None,
+ "created_by": "other_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ # Non-creator without group overlap should be hidden (no group overlap hides it)
+ assert len(result) == 0
+
+
+@pytest.mark.asyncio
+@patch("backend.services.agent_service.get_model_by_model_id")
+@patch("backend.services.agent_service.check_agent_availability")
+@patch("backend.services.agent_service.convert_string_to_list")
+@patch("backend.services.agent_service.get_user_tenant_by_user_id")
+@patch("backend.services.agent_service.query_group_ids_by_user")
+@patch("backend.services.agent_service.query_all_agent_info_by_tenant_id")
+async def test_list_all_agent_info_impl_creator_no_group_overlap_hidden(
+ mock_query_agents,
+ mock_query_groups,
+ mock_get_user_tenant,
+ mock_convert_list,
+ mock_check_availability,
+ mock_get_model,
+):
+ """Test that creators cannot see their own agents if no group overlap."""
+ mock_agents = [
+ {
+ "agent_id": 1,
+ "name": "Agent 1",
+ "display_name": "Display Agent 1",
+ "description": "Agent created by current_user, but no group overlap",
+ "enabled": True,
+ "group_ids": "5,6", # No overlap with user's groups [1, 2]
+ "ingroup_permission": None,
+ "created_by": "current_user",
+ "create_time": 1,
+ },
+ ]
+
+ mock_query_agents.return_value = mock_agents
+ mock_get_user_tenant.return_value = {"user_role": "USER"}
+ mock_query_groups.return_value = [1, 2]
+
+ def convert_side_effect(x):
+ if not x or (isinstance(x, str) and x.strip() == ""):
+ return []
+ parts = str(x).split(",")
+ result = []
+ for part in parts:
+ stripped = part.strip()
+ if stripped and stripped.isdigit():
+ result.append(int(stripped))
+ return result
+ mock_convert_list.side_effect = convert_side_effect
+
+ mock_check_availability.return_value = (True, [])
+ mock_get_model.return_value = None
+
+ result = await list_all_agent_info_impl(tenant_id="test_tenant", user_id="current_user")
+
+ # Creator cannot see their own agent if no group overlap (no group overlap hides it regardless of creator)
+ assert len(result) == 0
+
# Deprecated tests for mark_agents_as_new_impl have been removed as the API is cleaned up.
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