Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh 2026-03-25 12:41:58 +08:00
commit 4b62a5e29d
No known key found for this signature in database
5 changed files with 257 additions and 1139 deletions

View File

@ -942,7 +942,9 @@ class AccountTrialAppRecord(Base):
class ExporleBanner(TypeBase):
__tablename__ = "exporle_banners"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="exporler_banner_pkey"),)
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv4_string, init=False)
id: Mapped[str] = mapped_column(
StringUUID, insert_default=gen_uuidv4_string, default_factory=gen_uuidv4_string, init=False
)
content: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False)
link: Mapped[str] = mapped_column(String(255), nullable=False)
sort: Mapped[int] = mapped_column(sa.Integer, nullable=False)
@ -1863,7 +1865,9 @@ class AppAnnotationHitHistory(TypeBase):
sa.Index("app_annotation_hit_histories_message_idx", "message_id"),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()), init=False)
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
annotation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
source: Mapped[str] = mapped_column(LongText, nullable=False)

View File

@ -1245,3 +1245,51 @@ class TestAppService:
assert paginated_apps is not None
assert paginated_apps.total == 1
assert all("50%" in app.name for app in paginated_apps.items)
def test_get_app_code_by_id_not_found(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Test get_app_code_by_id raises ValueError when site is missing."""
from uuid import uuid4
from services.app_service import AppService
with pytest.raises(ValueError, match="not found"):
AppService.get_app_code_by_id(str(uuid4()))
def test_get_app_id_by_code_not_found(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Test get_app_id_by_code raises ValueError when code does not exist."""
from services.app_service import AppService
with pytest.raises(ValueError, match="not found"):
AppService.get_app_id_by_code("nonexistent-code")
def test_get_app_meta_returns_empty_when_workflow_missing(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Test get_app_meta returns empty tool_icons when workflow is None."""
from types import SimpleNamespace
from services.app_service import AppService
app_service = AppService()
workflow_app = SimpleNamespace(mode="workflow", workflow=None)
meta = app_service.get_app_meta(workflow_app)
assert meta == {"tool_icons": {}}
def test_get_app_meta_returns_empty_when_model_config_missing(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Test get_app_meta returns empty tool_icons when app_model_config is None."""
from types import SimpleNamespace
from services.app_service import AppService
app_service = AppService()
chat_app = SimpleNamespace(mode="chat", app_model_config=None)
meta = app_service.get_app_meta(chat_app)
assert meta == {"tool_icons": {}}

View File

@ -1,12 +1,24 @@
from __future__ import annotations
from unittest.mock import Mock, patch
import pytest
from faker import Faker
from sqlalchemy.orm import Session
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.__base.tool import Tool
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ApiProviderSchemaType, ToolProviderType
from core.tools.entities.tool_entities import (
ApiProviderSchemaType,
ToolDescription,
ToolEntity,
ToolIdentity,
ToolParameter,
ToolProviderEntity,
ToolProviderIdentity,
ToolProviderType,
)
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
from services.plugin.plugin_service import PluginService
from services.tools.tools_transform_service import ToolTransformService
@ -786,3 +798,192 @@ class TestToolTransformService:
assert result is not None
assert result == mock_controller
mock_from_db.assert_called_once_with(provider)
def _mock_tool(*, base_params, runtime_params):
"""Helper to build a Mock tool with real entity objects.
Tool is abstract and requires runtime behaviour (fork_tool_runtime,
get_runtime_parameters), so it stays as a Mock. Everything else uses
real Pydantic instances.
"""
entity = ToolEntity(
identity=ToolIdentity(
author="test_author",
name="test_tool",
label=I18nObject(en_US="Test Tool"),
provider="test_provider",
),
parameters=base_params or [],
description=ToolDescription(
human=I18nObject(en_US="Test description"),
llm="Test description for LLM",
),
output_schema={},
)
mock_tool = Mock(spec=Tool)
mock_tool.entity = entity
mock_tool.get_runtime_parameters.return_value = runtime_params
mock_tool.fork_tool_runtime.return_value = mock_tool
return mock_tool
def _param(name, *, form=ToolParameter.ToolParameterForm.FORM, label=None):
return ToolParameter(
name=name,
label=I18nObject(en_US=label or name),
human_description=I18nObject(en_US=name),
type=ToolParameter.ToolParameterType.STRING,
form=form,
)
class TestConvertToolEntityToApiEntity:
"""Tests for ToolTransformService.convert_tool_entity_to_api_entity."""
def test_parameter_override(self):
base = [_param("param1", label="Base 1"), _param("param2", label="Base 2")]
runtime = [_param("param1", label="Runtime 1")]
tool = _mock_tool(base_params=base, runtime_params=runtime)
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert isinstance(result, ToolApiEntity)
assert len(result.parameters) == 2
assert next(p for p in result.parameters if p.name == "param1").label.en_US == "Runtime 1"
assert next(p for p in result.parameters if p.name == "param2").label.en_US == "Base 2"
def test_additional_runtime_parameters(self):
base = [_param("param1", label="Base 1")]
runtime = [_param("param1", label="Runtime 1"), _param("runtime_only", label="Runtime Only")]
tool = _mock_tool(base_params=base, runtime_params=runtime)
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert len(result.parameters) == 2
names = [p.name for p in result.parameters]
assert "param1" in names
assert "runtime_only" in names
def test_non_form_runtime_parameters_excluded(self):
base = [_param("param1")]
runtime = [
_param("param1", label="Runtime 1"),
_param("llm_param", form=ToolParameter.ToolParameterForm.LLM),
]
tool = _mock_tool(base_params=base, runtime_params=runtime)
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert len(result.parameters) == 1
assert result.parameters[0].name == "param1"
def test_empty_parameters(self):
tool = _mock_tool(base_params=[], runtime_params=[])
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert isinstance(result, ToolApiEntity)
assert len(result.parameters) == 0
def test_none_parameters(self):
tool = _mock_tool(base_params=None, runtime_params=[])
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert isinstance(result, ToolApiEntity)
assert len(result.parameters) == 0
def test_parameter_order_preserved(self):
base = [_param("p1", label="B1"), _param("p2", label="B2"), _param("p3", label="B3")]
runtime = [_param("p2", label="R2"), _param("p4", label="R4")]
tool = _mock_tool(base_params=base, runtime_params=runtime)
result = ToolTransformService.convert_tool_entity_to_api_entity(tool, "t", None)
assert [p.name for p in result.parameters] == ["p1", "p2", "p3", "p4"]
assert result.parameters[1].label.en_US == "R2"
class TestWorkflowProviderToUserProvider:
"""Tests for ToolTransformService.workflow_provider_to_user_provider."""
@staticmethod
def _make_controller(provider_id="provider_123", **identity_overrides):
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
defaults = {
"author": "test_author",
"name": "test_workflow_tool",
"description": I18nObject(en_US="Test description"),
"icon": '{"type": "emoji", "content": "🔧"}',
"icon_dark": None,
"label": I18nObject(en_US="Test Workflow Tool"),
}
defaults.update(identity_overrides)
identity = ToolProviderIdentity(**defaults)
entity = ToolProviderEntity(identity=identity)
return WorkflowToolProviderController(entity=entity, provider_id=provider_id)
def test_with_workflow_app_id(self):
ctrl = self._make_controller()
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=ctrl,
labels=["l1", "l2"],
workflow_app_id="app_123",
)
assert isinstance(result, ToolProviderApiEntity)
assert result.id == "provider_123"
assert result.type == ToolProviderType.WORKFLOW
assert result.workflow_app_id == "app_123"
assert result.labels == ["l1", "l2"]
assert result.is_team_authorization is True
def test_without_workflow_app_id(self):
ctrl = self._make_controller()
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=ctrl,
labels=["l1"],
)
assert result.workflow_app_id is None
def test_workflow_app_id_none_explicit(self):
ctrl = self._make_controller()
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=ctrl,
labels=None,
workflow_app_id=None,
)
assert result.workflow_app_id is None
assert result.labels == []
def test_preserves_other_fields(self):
ctrl = self._make_controller(
"provider_456",
author="another_author",
name="another_workflow_tool",
description=I18nObject(en_US="Another desc", zh_Hans="Another desc"),
icon='{"type": "emoji", "content": "⚙️"}',
icon_dark='{"type": "emoji", "content": "🔧"}',
label=I18nObject(en_US="Another Tool", zh_Hans="Another Tool"),
)
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=ctrl,
labels=["automation"],
workflow_app_id="app_456",
)
assert result.id == "provider_456"
assert result.author == "another_author"
assert result.name == "another_workflow_tool"
assert result.type == ToolProviderType.WORKFLOW
assert result.workflow_app_id == "app_456"
assert result.is_team_authorization is True
assert result.allow_delete is True

View File

@ -1,683 +0,0 @@
"""Unit tests for services.app_service."""
import json
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, patch
import pytest
from core.errors.error import ProviderTokenNotInitError
from models import Account, Tenant
from models.model import App, AppMode, IconType
from services.app_service import AppService
@pytest.fixture
def service() -> AppService:
"""Provide AppService instance."""
return AppService()
@pytest.fixture
def account() -> Account:
"""Create account object for create_app tests."""
tenant = Tenant(name="Tenant")
tenant.id = "tenant-1"
result = Account(name="Account User", email="account@example.com")
result.id = "acc-1"
result._current_tenant = tenant
return result
@pytest.fixture
def default_args() -> dict:
"""Create default create_app args."""
return {
"name": "Test App",
"mode": AppMode.CHAT.value,
"icon": "🤖",
"icon_background": "#FFFFFF",
}
@pytest.fixture
def app_template() -> dict:
"""Create basic app template for create_app tests."""
return {
AppMode.CHAT: {
"app": {},
"model_config": {
"model": {
"provider": "provider-a",
"name": "model-a",
"mode": "chat",
"completion_params": {},
}
},
}
}
def _make_current_user() -> Account:
user = Account(name="Tester", email="tester@example.com")
user.id = "user-1"
tenant = Tenant(name="Tenant")
tenant.id = "tenant-1"
user._current_tenant = tenant
return user
class TestAppServicePagination:
"""Test suite for get_paginate_apps."""
def test_get_paginate_apps_should_return_none_when_tag_filter_empty(self, service: AppService) -> None:
"""Test pagination returns None when tag filter has no targets."""
# Arrange
args = {"mode": "chat", "page": 1, "limit": 20, "tag_ids": ["tag-1"]}
with patch("services.app_service.TagService.get_target_ids_by_tag_ids", return_value=[]):
# Act
result = service.get_paginate_apps("user-1", "tenant-1", args)
# Assert
assert result is None
def test_get_paginate_apps_should_delegate_to_db_paginate(self, service: AppService) -> None:
"""Test pagination delegates to db.paginate when filters are valid."""
# Arrange
args = {
"mode": "workflow",
"is_created_by_me": True,
"name": "My_App%",
"tag_ids": ["tag-1"],
"page": 2,
"limit": 10,
}
expected_pagination = MagicMock()
with (
patch("services.app_service.TagService.get_target_ids_by_tag_ids", return_value=["app-1"]),
patch("libs.helper.escape_like_pattern", return_value="escaped"),
patch("services.app_service.db") as mock_db,
):
mock_db.paginate.return_value = expected_pagination
# Act
result = service.get_paginate_apps("user-1", "tenant-1", args)
# Assert
assert result is expected_pagination
mock_db.paginate.assert_called_once()
class TestAppServiceCreate:
"""Test suite for create_app."""
def test_create_app_should_create_with_matching_default_model(
self,
service: AppService,
account: Account,
default_args: dict,
app_template: dict,
) -> None:
"""Test create_app uses matching default model and persists app config."""
# Arrange
app_instance = SimpleNamespace(id="app-1", tenant_id="tenant-1")
app_model_config = SimpleNamespace(id="cfg-1")
model_instance = SimpleNamespace(
model_name="model-a",
provider="provider-a",
model_type_instance=MagicMock(),
credentials={"k": "v"},
)
with (
patch("services.app_service.default_app_templates", app_template),
patch("services.app_service.App", return_value=app_instance),
patch("services.app_service.AppModelConfig", return_value=app_model_config),
patch("services.app_service.ModelManager") as mock_model_manager,
patch("services.app_service.db") as mock_db,
patch("services.app_service.app_was_created") as mock_event,
patch("services.app_service.FeatureService.get_system_features") as mock_features,
patch("services.app_service.BillingService") as mock_billing,
patch("services.app_service.dify_config") as mock_config,
):
manager = mock_model_manager.return_value
manager.get_default_model_instance.return_value = model_instance
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))
mock_config.BILLING_ENABLED = True
# Act
result = service.create_app("tenant-1", default_args, account)
# Assert
assert result is app_instance
assert app_instance.app_model_config_id == "cfg-1"
mock_db.session.add.assert_any_call(app_instance)
mock_db.session.add.assert_any_call(app_model_config)
assert mock_db.session.flush.call_count == 2
mock_db.session.commit.assert_called_once()
mock_event.send.assert_called_once_with(app_instance, account=account)
mock_billing.clean_billing_info_cache.assert_called_once_with("tenant-1")
def test_create_app_should_raise_when_model_schema_missing(
self,
service: AppService,
account: Account,
default_args: dict,
app_template: dict,
) -> None:
"""Test create_app raises ValueError when non-matching model has no schema."""
# Arrange
app_instance = SimpleNamespace(id="app-1")
model_instance = SimpleNamespace(
model_name="model-b",
provider="provider-b",
model_type_instance=MagicMock(),
credentials={"k": "v"},
)
model_instance.model_type_instance.get_model_schema.return_value = None
with (
patch("services.app_service.default_app_templates", app_template),
patch("services.app_service.App", return_value=app_instance),
patch("services.app_service.ModelManager") as mock_model_manager,
patch("services.app_service.db") as mock_db,
):
manager = mock_model_manager.return_value
manager.get_default_model_instance.return_value = model_instance
# Act & Assert
with pytest.raises(ValueError, match="model schema not found"):
service.create_app("tenant-1", default_args, account)
mock_db.session.commit.assert_not_called()
def test_create_app_should_fallback_to_default_provider_when_model_missing(
self,
service: AppService,
account: Account,
default_args: dict,
app_template: dict,
) -> None:
"""Test create_app falls back to provider/model name when no default model instance is available."""
# Arrange
app_instance = SimpleNamespace(id="app-1", tenant_id="tenant-1")
app_model_config = SimpleNamespace(id="cfg-1")
with (
patch("services.app_service.default_app_templates", app_template),
patch("services.app_service.App", return_value=app_instance),
patch("services.app_service.AppModelConfig", return_value=app_model_config),
patch("services.app_service.ModelManager") as mock_model_manager,
patch("services.app_service.db") as mock_db,
patch("services.app_service.app_was_created") as mock_event,
patch("services.app_service.FeatureService.get_system_features") as mock_features,
patch("services.app_service.EnterpriseService") as mock_enterprise,
patch("services.app_service.dify_config") as mock_config,
):
manager = mock_model_manager.return_value
manager.get_default_model_instance.side_effect = ProviderTokenNotInitError("not ready")
manager.get_default_provider_model_name.return_value = ("fallback-provider", "fallback-model")
mock_features.return_value = SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True))
mock_config.BILLING_ENABLED = False
# Act
result = service.create_app("tenant-1", default_args, account)
# Assert
assert result is app_instance
mock_event.send.assert_called_once_with(app_instance, account=account)
mock_db.session.commit.assert_called_once()
mock_enterprise.WebAppAuth.update_app_access_mode.assert_called_once_with("app-1", "private")
def test_create_app_should_log_and_fallback_on_unexpected_model_error(
self,
service: AppService,
account: Account,
default_args: dict,
app_template: dict,
) -> None:
"""Test unexpected model manager errors are logged and fallback provider is used."""
# Arrange
app_instance = SimpleNamespace(id="app-1", tenant_id="tenant-1")
app_model_config = SimpleNamespace(id="cfg-1")
with (
patch("services.app_service.default_app_templates", app_template),
patch("services.app_service.App", return_value=app_instance),
patch("services.app_service.AppModelConfig", return_value=app_model_config),
patch("services.app_service.ModelManager") as mock_model_manager,
patch("services.app_service.db"),
patch("services.app_service.app_was_created"),
patch(
"services.app_service.FeatureService.get_system_features",
return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
),
patch("services.app_service.dify_config", new=SimpleNamespace(BILLING_ENABLED=False)),
patch("services.app_service.logger") as mock_logger,
):
manager = mock_model_manager.return_value
manager.get_default_model_instance.side_effect = RuntimeError("boom")
manager.get_default_provider_model_name.return_value = ("fallback-provider", "fallback-model")
# Act
result = service.create_app("tenant-1", default_args, account)
# Assert
assert result is app_instance
mock_logger.exception.assert_called_once()
class TestAppServiceGetAndUpdate:
"""Test suite for app retrieval and update methods."""
def test_get_app_should_return_original_when_not_agent_app(self, service: AppService) -> None:
"""Test get_app returns original app for non-agent modes."""
# Arrange
app = MagicMock()
app.mode = AppMode.CHAT
app.is_agent = False
with patch("services.app_service.current_user", _make_current_user()):
# Act
result = service.get_app(app)
# Assert
assert result is app
def test_get_app_should_return_original_when_model_config_missing(self, service: AppService) -> None:
"""Test get_app returns app when agent mode has no model config."""
# Arrange
app = MagicMock()
app.id = "app-1"
app.mode = AppMode.AGENT_CHAT
app.is_agent = False
app.app_model_config = None
with patch("services.app_service.current_user", _make_current_user()):
# Act
result = service.get_app(app)
# Assert
assert result is app
def test_get_app_should_mask_tool_parameters_for_agent_tools(self, service: AppService) -> None:
"""Test get_app decrypts and masks secret tool parameters."""
# Arrange
tool = {
"provider_type": "builtin",
"provider_id": "provider-1",
"tool_name": "tool-a",
"tool_parameters": {"secret": "encrypted"},
"extra": True,
}
model_config = MagicMock()
model_config.agent_mode_dict = {"tools": [tool, {"skip": True}]}
app = MagicMock()
app.id = "app-1"
app.mode = AppMode.AGENT_CHAT
app.is_agent = False
app.app_model_config = model_config
manager = MagicMock()
manager.decrypt_tool_parameters.return_value = {"secret": "decrypted"}
manager.mask_tool_parameters.return_value = {"secret": "***"}
with (
patch("services.app_service.current_user", _make_current_user()),
patch("services.app_service.ToolManager.get_agent_tool_runtime", return_value=MagicMock()),
patch("services.app_service.ToolParameterConfigurationManager", return_value=manager),
):
# Act
result = service.get_app(app)
# Assert
assert result.app_model_config is model_config
assert tool["tool_parameters"] == {"secret": "***"}
assert json.loads(model_config.agent_mode)["tools"][0]["tool_parameters"] == {"secret": "***"}
def test_get_app_should_continue_when_tool_parameter_masking_fails(self, service: AppService) -> None:
"""Test get_app logs and continues when masking fails."""
# Arrange
tool = {
"provider_type": "builtin",
"provider_id": "provider-1",
"tool_name": "tool-a",
"tool_parameters": {"secret": "encrypted"},
"extra": True,
}
model_config = MagicMock()
model_config.agent_mode_dict = {"tools": [tool]}
app = MagicMock()
app.id = "app-1"
app.mode = AppMode.AGENT_CHAT
app.is_agent = False
app.app_model_config = model_config
with (
patch("services.app_service.current_user", _make_current_user()),
patch("services.app_service.ToolManager.get_agent_tool_runtime", side_effect=RuntimeError("mask-failed")),
patch("services.app_service.logger") as mock_logger,
):
# Act
result = service.get_app(app)
# Assert
assert result.app_model_config is model_config
mock_logger.exception.assert_called_once()
def test_update_methods_should_mutate_app_and_commit(self, service: AppService) -> None:
"""Test update methods set fields and commit changes."""
# Arrange
app = cast(
App,
SimpleNamespace(
name="old",
description="old",
icon_type="emoji",
icon="a",
icon_background="#111",
enable_site=True,
enable_api=True,
),
)
args = {
"name": "new",
"description": "new-desc",
"icon_type": "image",
"icon": "new-icon",
"icon_background": "#222",
"use_icon_as_answer_icon": True,
"max_active_requests": 5,
}
user = SimpleNamespace(id="user-1")
with (
patch("services.app_service.current_user", user),
patch("services.app_service.db") as mock_db,
patch("services.app_service.naive_utc_now", return_value="now"),
):
# Act
updated = service.update_app(app, args)
renamed = service.update_app_name(app, "rename")
iconed = service.update_app_icon(app, "icon-2", "#333")
site_same = service.update_app_site_status(app, app.enable_site)
api_same = service.update_app_api_status(app, app.enable_api)
site_changed = service.update_app_site_status(app, False)
api_changed = service.update_app_api_status(app, False)
# Assert
assert updated is app
assert updated.icon_type == IconType.IMAGE
assert renamed is app
assert iconed is app
assert site_same is app
assert api_same is app
assert site_changed is app
assert api_changed is app
assert mock_db.session.commit.call_count >= 5
def test_update_app_should_preserve_icon_type_when_not_provided(self, service: AppService) -> None:
"""Test update_app keeps the existing icon_type when the payload omits it."""
# Arrange
app = cast(
App,
SimpleNamespace(
name="old",
description="old",
icon_type=IconType.EMOJI,
icon="a",
icon_background="#111",
use_icon_as_answer_icon=False,
max_active_requests=1,
),
)
args = {
"name": "new",
"description": "new-desc",
"icon_type": None,
"icon": "new-icon",
"icon_background": "#222",
"use_icon_as_answer_icon": True,
"max_active_requests": 5,
}
user = SimpleNamespace(id="user-1")
with (
patch("services.app_service.current_user", user),
patch("services.app_service.db") as mock_db,
patch("services.app_service.naive_utc_now", return_value="now"),
):
# Act
updated = service.update_app(app, args)
# Assert
assert updated is app
assert updated.icon_type == IconType.EMOJI
mock_db.session.commit.assert_called_once()
def test_update_app_should_reject_empty_icon_type(self, service: AppService) -> None:
"""Test update_app rejects an explicit empty icon_type."""
app = cast(
App,
SimpleNamespace(
name="old",
description="old",
icon_type=IconType.EMOJI,
icon="a",
icon_background="#111",
use_icon_as_answer_icon=False,
max_active_requests=1,
),
)
args = {
"name": "new",
"description": "new-desc",
"icon_type": "",
"icon": "new-icon",
"icon_background": "#222",
"use_icon_as_answer_icon": True,
"max_active_requests": 5,
}
user = SimpleNamespace(id="user-1")
with (
patch("services.app_service.current_user", user),
patch("services.app_service.db") as mock_db,
):
with pytest.raises(ValueError):
service.update_app(app, args)
mock_db.session.commit.assert_not_called()
class TestAppServiceDeleteAndMeta:
"""Test suite for delete and metadata methods."""
def test_delete_app_should_cleanup_and_enqueue_task(self, service: AppService) -> None:
"""Test delete_app removes app, runs cleanup, and triggers async deletion task."""
# Arrange
app = cast(App, SimpleNamespace(id="app-1", tenant_id="tenant-1"))
with (
patch("services.app_service.db") as mock_db,
patch(
"services.app_service.FeatureService.get_system_features",
return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)),
),
patch("services.app_service.EnterpriseService") as mock_enterprise,
patch(
"services.app_service.dify_config",
new=SimpleNamespace(BILLING_ENABLED=True, CONSOLE_API_URL="https://console.example"),
),
patch("services.app_service.BillingService") as mock_billing,
patch("services.app_service.remove_app_and_related_data_task") as mock_task,
):
# Act
service.delete_app(app)
# Assert
mock_db.session.delete.assert_called_once_with(app)
mock_db.session.commit.assert_called_once()
mock_enterprise.WebAppAuth.cleanup_webapp.assert_called_once_with("app-1")
mock_billing.clean_billing_info_cache.assert_called_once_with("tenant-1")
mock_task.delay.assert_called_once_with(tenant_id="tenant-1", app_id="app-1")
def test_get_app_meta_should_handle_workflow_and_tool_provider_icons(self, service: AppService) -> None:
"""Test get_app_meta extracts builtin and API tool icons from workflow graph."""
# Arrange
workflow = SimpleNamespace(
graph_dict={
"nodes": [
{
"data": {
"type": "tool",
"provider_type": "builtin",
"provider_id": "builtin-provider",
"tool_name": "tool_builtin",
}
},
{
"data": {
"type": "tool",
"provider_type": "api",
"provider_id": "api-provider-id",
"tool_name": "tool_api",
}
},
]
}
)
app = cast(
App,
SimpleNamespace(
mode=AppMode.WORKFLOW.value,
workflow=workflow,
app_model_config=None,
tenant_id="tenant-1",
icon_type="emoji",
icon_background="#fff",
),
)
provider = SimpleNamespace(icon=json.dumps({"background": "#000", "content": "A"}))
with (
patch("services.app_service.dify_config", new=SimpleNamespace(CONSOLE_API_URL="https://console.example")),
patch("services.app_service.db") as mock_db,
):
query = MagicMock()
query.where.return_value = query
query.first.return_value = provider
mock_db.session.query.return_value = query
# Act
meta = service.get_app_meta(app)
# Assert
assert meta["tool_icons"]["tool_builtin"].endswith("/builtin-provider/icon")
assert meta["tool_icons"]["tool_api"] == {"background": "#000", "content": "A"}
def test_get_app_meta_should_use_default_api_icon_on_lookup_error(self, service: AppService) -> None:
"""Test get_app_meta falls back to default icon when API provider lookup fails."""
# Arrange
app_model_config = SimpleNamespace(
agent_mode_dict={
"tools": [{"provider_type": "api", "provider_id": "x", "tool_name": "t", "tool_parameters": {}}]
}
)
app = cast(App, SimpleNamespace(mode=AppMode.CHAT.value, app_model_config=app_model_config, workflow=None))
with (
patch("services.app_service.dify_config", new=SimpleNamespace(CONSOLE_API_URL="https://console.example")),
patch("services.app_service.db") as mock_db,
):
query = MagicMock()
query.where.return_value = query
query.first.return_value = None
mock_db.session.query.return_value = query
# Act
meta = service.get_app_meta(app)
# Assert
assert meta["tool_icons"]["t"] == {"background": "#252525", "content": "\ud83d\ude01"}
def test_get_app_meta_should_return_empty_when_required_data_missing(self, service: AppService) -> None:
"""Test get_app_meta returns empty metadata when workflow/model config is absent."""
# Arrange
workflow_app = cast(App, SimpleNamespace(mode=AppMode.WORKFLOW.value, workflow=None))
chat_app = cast(App, SimpleNamespace(mode=AppMode.CHAT.value, app_model_config=None))
# Act
workflow_meta = service.get_app_meta(workflow_app)
chat_meta = service.get_app_meta(chat_app)
# Assert
assert workflow_meta == {"tool_icons": {}}
assert chat_meta == {"tool_icons": {}}
class TestAppServiceCodeLookup:
"""Test suite for app code lookup methods."""
def test_get_app_code_by_id_should_raise_when_site_missing(self) -> None:
"""Test get_app_code_by_id raises when site is missing."""
# Arrange
with patch("services.app_service.db") as mock_db:
query = MagicMock()
query.where.return_value = query
query.first.return_value = None
mock_db.session.query.return_value = query
# Act & Assert
with pytest.raises(ValueError, match="not found"):
AppService.get_app_code_by_id("app-1")
def test_get_app_code_by_id_should_return_code(self) -> None:
"""Test get_app_code_by_id returns site code."""
# Arrange
site = SimpleNamespace(code="code-1")
with patch("services.app_service.db") as mock_db:
query = MagicMock()
query.where.return_value = query
query.first.return_value = site
mock_db.session.query.return_value = query
# Act
result = AppService.get_app_code_by_id("app-1")
# Assert
assert result == "code-1"
def test_get_app_id_by_code_should_raise_when_site_missing(self) -> None:
"""Test get_app_id_by_code raises when code does not exist."""
# Arrange
with patch("services.app_service.db") as mock_db:
query = MagicMock()
query.where.return_value = query
query.first.return_value = None
mock_db.session.query.return_value = query
# Act & Assert
with pytest.raises(ValueError, match="not found"):
AppService.get_app_id_by_code("missing")
def test_get_app_id_by_code_should_return_app_id(self) -> None:
"""Test get_app_id_by_code returns linked app id."""
# Arrange
site = SimpleNamespace(app_id="app-1")
with patch("services.app_service.db") as mock_db:
query = MagicMock()
query.where.return_value = query
query.first.return_value = site
mock_db.session.query.return_value = query
# Act
result = AppService.get_app_id_by_code("code-1")
# Assert
assert result == "app-1"

View File

@ -1,452 +0,0 @@
from unittest.mock import Mock
from core.tools.__base.tool import Tool
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolParameter, ToolProviderType
from services.tools.tools_transform_service import ToolTransformService
class TestToolTransformService:
"""Test cases for ToolTransformService.convert_tool_entity_to_api_entity method"""
def test_convert_tool_with_parameter_override(self):
"""Test that runtime parameters correctly override base parameters"""
# Create mock base parameters
base_param1 = Mock(spec=ToolParameter)
base_param1.name = "param1"
base_param1.form = ToolParameter.ToolParameterForm.FORM
base_param1.type = "string"
base_param1.label = "Base Param 1"
base_param2 = Mock(spec=ToolParameter)
base_param2.name = "param2"
base_param2.form = ToolParameter.ToolParameterForm.FORM
base_param2.type = "string"
base_param2.label = "Base Param 2"
# Create mock runtime parameters that override base parameters
runtime_param1 = Mock(spec=ToolParameter)
runtime_param1.name = "param1"
runtime_param1.form = ToolParameter.ToolParameterForm.FORM
runtime_param1.type = "string"
runtime_param1.label = "Runtime Param 1" # Different label to verify override
# Create mock tool
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = [base_param1, base_param2]
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = [runtime_param1]
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.author == "test_author"
assert result.name == "test_tool"
assert result.parameters is not None
assert len(result.parameters) == 2
# Find the overridden parameter
overridden_param = next((p for p in result.parameters if p.name == "param1"), None)
assert overridden_param is not None
assert overridden_param.label == "Runtime Param 1" # Should be runtime version
# Find the non-overridden parameter
original_param = next((p for p in result.parameters if p.name == "param2"), None)
assert original_param is not None
assert original_param.label == "Base Param 2" # Should be base version
def test_convert_tool_with_additional_runtime_parameters(self):
"""Test that additional runtime parameters are added to the final list"""
# Create mock base parameters
base_param1 = Mock(spec=ToolParameter)
base_param1.name = "param1"
base_param1.form = ToolParameter.ToolParameterForm.FORM
base_param1.type = "string"
base_param1.label = "Base Param 1"
# Create mock runtime parameters - one that overrides and one that's new
runtime_param1 = Mock(spec=ToolParameter)
runtime_param1.name = "param1"
runtime_param1.form = ToolParameter.ToolParameterForm.FORM
runtime_param1.type = "string"
runtime_param1.label = "Runtime Param 1"
runtime_param2 = Mock(spec=ToolParameter)
runtime_param2.name = "runtime_only"
runtime_param2.form = ToolParameter.ToolParameterForm.FORM
runtime_param2.type = "string"
runtime_param2.label = "Runtime Only Param"
# Create mock tool
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = [base_param1]
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = [runtime_param1, runtime_param2]
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.parameters is not None
assert len(result.parameters) == 2
# Check that both parameters are present
param_names = [p.name for p in result.parameters]
assert "param1" in param_names
assert "runtime_only" in param_names
# Verify the overridden parameter has runtime version
overridden_param = next((p for p in result.parameters if p.name == "param1"), None)
assert overridden_param is not None
assert overridden_param.label == "Runtime Param 1"
# Verify the new runtime parameter is included
new_param = next((p for p in result.parameters if p.name == "runtime_only"), None)
assert new_param is not None
assert new_param.label == "Runtime Only Param"
def test_convert_tool_with_non_form_runtime_parameters(self):
"""Test that non-FORM runtime parameters are not added as new parameters"""
# Create mock base parameters
base_param1 = Mock(spec=ToolParameter)
base_param1.name = "param1"
base_param1.form = ToolParameter.ToolParameterForm.FORM
base_param1.type = "string"
base_param1.label = "Base Param 1"
# Create mock runtime parameters with different forms
runtime_param1 = Mock(spec=ToolParameter)
runtime_param1.name = "param1"
runtime_param1.form = ToolParameter.ToolParameterForm.FORM
runtime_param1.type = "string"
runtime_param1.label = "Runtime Param 1"
runtime_param2 = Mock(spec=ToolParameter)
runtime_param2.name = "llm_param"
runtime_param2.form = ToolParameter.ToolParameterForm.LLM
runtime_param2.type = "string"
runtime_param2.label = "LLM Param"
# Create mock tool
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = [base_param1]
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = [runtime_param1, runtime_param2]
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.parameters is not None
assert len(result.parameters) == 1 # Only the FORM parameter should be present
# Check that only the FORM parameter is present
param_names = [p.name for p in result.parameters]
assert "param1" in param_names
assert "llm_param" not in param_names
def test_convert_tool_with_empty_parameters(self):
"""Test conversion with empty base and runtime parameters"""
# Create mock tool with no parameters
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = []
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = []
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.parameters is not None
assert len(result.parameters) == 0
def test_convert_tool_with_none_parameters(self):
"""Test conversion when base parameters is None"""
# Create mock tool with None parameters
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = None
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = []
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.parameters is not None
assert len(result.parameters) == 0
def test_convert_tool_parameter_order_preserved(self):
"""Test that parameter order is preserved correctly"""
# Create mock base parameters in specific order
base_param1 = Mock(spec=ToolParameter)
base_param1.name = "param1"
base_param1.form = ToolParameter.ToolParameterForm.FORM
base_param1.type = "string"
base_param1.label = "Base Param 1"
base_param2 = Mock(spec=ToolParameter)
base_param2.name = "param2"
base_param2.form = ToolParameter.ToolParameterForm.FORM
base_param2.type = "string"
base_param2.label = "Base Param 2"
base_param3 = Mock(spec=ToolParameter)
base_param3.name = "param3"
base_param3.form = ToolParameter.ToolParameterForm.FORM
base_param3.type = "string"
base_param3.label = "Base Param 3"
# Create runtime parameter that overrides middle parameter
runtime_param2 = Mock(spec=ToolParameter)
runtime_param2.name = "param2"
runtime_param2.form = ToolParameter.ToolParameterForm.FORM
runtime_param2.type = "string"
runtime_param2.label = "Runtime Param 2"
# Create new runtime parameter
runtime_param4 = Mock(spec=ToolParameter)
runtime_param4.name = "param4"
runtime_param4.form = ToolParameter.ToolParameterForm.FORM
runtime_param4.type = "string"
runtime_param4.label = "Runtime Param 4"
# Create mock tool
mock_tool = Mock(spec=Tool)
mock_tool.entity = Mock()
mock_tool.entity.parameters = [base_param1, base_param2, base_param3]
mock_tool.entity.identity = Mock()
mock_tool.entity.identity.author = "test_author"
mock_tool.entity.identity.name = "test_tool"
mock_tool.entity.identity.label = I18nObject(en_US="Test Tool")
mock_tool.entity.description = Mock()
mock_tool.entity.description.human = I18nObject(en_US="Test description")
mock_tool.entity.output_schema = {}
mock_tool.get_runtime_parameters.return_value = [runtime_param2, runtime_param4]
# Mock fork_tool_runtime to return the same tool
mock_tool.fork_tool_runtime.return_value = mock_tool
# Call the method
result = ToolTransformService.convert_tool_entity_to_api_entity(mock_tool, "test_tenant", None)
# Verify the result
assert isinstance(result, ToolApiEntity)
assert result.parameters is not None
assert len(result.parameters) == 4
# Check that order is maintained: base parameters first, then new runtime parameters
param_names = [p.name for p in result.parameters]
assert param_names == ["param1", "param2", "param3", "param4"]
# Verify that param2 was overridden with runtime version
param2 = result.parameters[1]
assert param2.name == "param2"
assert param2.label == "Runtime Param 2"
class TestWorkflowProviderToUserProvider:
"""Test cases for ToolTransformService.workflow_provider_to_user_provider method"""
def test_workflow_provider_to_user_provider_with_workflow_app_id(self):
"""Test that workflow_provider_to_user_provider correctly sets workflow_app_id."""
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
# Create mock workflow tool provider controller
workflow_app_id = "app_123"
provider_id = "provider_123"
mock_controller = Mock(spec=WorkflowToolProviderController)
mock_controller.provider_id = provider_id
mock_controller.entity = Mock()
mock_controller.entity.identity = Mock()
mock_controller.entity.identity.author = "test_author"
mock_controller.entity.identity.name = "test_workflow_tool"
mock_controller.entity.identity.description = I18nObject(en_US="Test description")
mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"}
mock_controller.entity.identity.icon_dark = None
mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool")
# Call the method
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=mock_controller,
labels=["label1", "label2"],
workflow_app_id=workflow_app_id,
)
# Verify the result
assert isinstance(result, ToolProviderApiEntity)
assert result.id == provider_id
assert result.author == "test_author"
assert result.name == "test_workflow_tool"
assert result.type == ToolProviderType.WORKFLOW
assert result.workflow_app_id == workflow_app_id
assert result.labels == ["label1", "label2"]
assert result.is_team_authorization is True
assert result.plugin_id is None
assert result.plugin_unique_identifier is None
assert result.tools == []
def test_workflow_provider_to_user_provider_without_workflow_app_id(self):
"""Test that workflow_provider_to_user_provider works when workflow_app_id is not provided."""
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
# Create mock workflow tool provider controller
provider_id = "provider_123"
mock_controller = Mock(spec=WorkflowToolProviderController)
mock_controller.provider_id = provider_id
mock_controller.entity = Mock()
mock_controller.entity.identity = Mock()
mock_controller.entity.identity.author = "test_author"
mock_controller.entity.identity.name = "test_workflow_tool"
mock_controller.entity.identity.description = I18nObject(en_US="Test description")
mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"}
mock_controller.entity.identity.icon_dark = None
mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool")
# Call the method without workflow_app_id
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=mock_controller,
labels=["label1"],
)
# Verify the result
assert isinstance(result, ToolProviderApiEntity)
assert result.id == provider_id
assert result.workflow_app_id is None
assert result.labels == ["label1"]
def test_workflow_provider_to_user_provider_workflow_app_id_none(self):
"""Test that workflow_provider_to_user_provider handles None workflow_app_id explicitly."""
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
# Create mock workflow tool provider controller
provider_id = "provider_123"
mock_controller = Mock(spec=WorkflowToolProviderController)
mock_controller.provider_id = provider_id
mock_controller.entity = Mock()
mock_controller.entity.identity = Mock()
mock_controller.entity.identity.author = "test_author"
mock_controller.entity.identity.name = "test_workflow_tool"
mock_controller.entity.identity.description = I18nObject(en_US="Test description")
mock_controller.entity.identity.icon = {"type": "emoji", "content": "🔧"}
mock_controller.entity.identity.icon_dark = None
mock_controller.entity.identity.label = I18nObject(en_US="Test Workflow Tool")
# Call the method with explicit None values
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=mock_controller,
labels=None,
workflow_app_id=None,
)
# Verify the result
assert isinstance(result, ToolProviderApiEntity)
assert result.id == provider_id
assert result.workflow_app_id is None
assert result.labels == []
def test_workflow_provider_to_user_provider_preserves_other_fields(self):
"""Test that workflow_provider_to_user_provider preserves all other entity fields."""
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
# Create mock workflow tool provider controller with various fields
workflow_app_id = "app_456"
provider_id = "provider_456"
mock_controller = Mock(spec=WorkflowToolProviderController)
mock_controller.provider_id = provider_id
mock_controller.entity = Mock()
mock_controller.entity.identity = Mock()
mock_controller.entity.identity.author = "another_author"
mock_controller.entity.identity.name = "another_workflow_tool"
mock_controller.entity.identity.description = I18nObject(
en_US="Another description", zh_Hans="Another description"
)
mock_controller.entity.identity.icon = {"type": "emoji", "content": "⚙️"}
mock_controller.entity.identity.icon_dark = {"type": "emoji", "content": "🔧"}
mock_controller.entity.identity.label = I18nObject(
en_US="Another Workflow Tool", zh_Hans="Another Workflow Tool"
)
# Call the method
result = ToolTransformService.workflow_provider_to_user_provider(
provider_controller=mock_controller,
labels=["automation", "workflow"],
workflow_app_id=workflow_app_id,
)
# Verify all fields are preserved correctly
assert isinstance(result, ToolProviderApiEntity)
assert result.id == provider_id
assert result.author == "another_author"
assert result.name == "another_workflow_tool"
assert result.description.en_US == "Another description"
assert result.description.zh_Hans == "Another description"
assert result.icon == {"type": "emoji", "content": "⚙️"}
assert result.icon_dark == {"type": "emoji", "content": "🔧"}
assert result.label.en_US == "Another Workflow Tool"
assert result.label.zh_Hans == "Another Workflow Tool"
assert result.type == ToolProviderType.WORKFLOW
assert result.workflow_app_id == workflow_app_id
assert result.labels == ["automation", "workflow"]
assert result.masked_credentials == {}
assert result.is_team_authorization is True
assert result.allow_delete is True
assert result.plugin_id is None
assert result.plugin_unique_identifier is None
assert result.tools == []