mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
510 lines
20 KiB
Python
510 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from types import SimpleNamespace
|
|
from typing import cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from models import Account
|
|
from models.model import App, AppMode, AppModelConfig
|
|
from models.workflow import Workflow
|
|
from services.agent.errors import AgentNameConflictError
|
|
from services.app_service import AppService, CreateAppParams
|
|
|
|
|
|
class TestCreateAppTransactionBoundary:
|
|
def test_commits_database_state_before_external_side_effects(self) -> None:
|
|
session = MagicMock()
|
|
account = MagicMock(spec=Account, id="account-1", current_tenant_id="tenant-1")
|
|
phase_events: list[str] = []
|
|
session.commit.side_effect = lambda: phase_events.append("commit")
|
|
|
|
with (
|
|
patch(
|
|
"services.app_service.app_was_created.send",
|
|
side_effect=lambda *_args, **_kwargs: phase_events.append("signal"),
|
|
),
|
|
patch(
|
|
"services.app_service.enterprise_rbac_service.try_sync_creator_access_policy_member_bindings",
|
|
side_effect=lambda *_args: phase_events.append("external"),
|
|
),
|
|
patch(
|
|
"services.app_service.FeatureService.get_system_features",
|
|
return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
|
),
|
|
patch("services.app_service.dify_config.BILLING_ENABLED", False),
|
|
):
|
|
AppService().create_app(
|
|
"tenant-1",
|
|
CreateAppParams(name="Workflow", mode=AppMode.WORKFLOW.value),
|
|
account,
|
|
session=session,
|
|
)
|
|
|
|
assert phase_events == ["commit", "signal", "commit", "external"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"update_status",
|
|
[AppService.update_app_site_status, AppService.update_app_api_status],
|
|
)
|
|
def test_app_status_updates_commit_before_signal(update_status: Callable[..., App]) -> None:
|
|
app = cast(App, SimpleNamespace(enable_site=False, enable_api=False))
|
|
session = MagicMock()
|
|
phase_events: list[str] = []
|
|
session.commit.side_effect = lambda: phase_events.append("commit")
|
|
|
|
with (
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-1")),
|
|
patch("services.app_service.app_was_updated.send", side_effect=lambda *_args: phase_events.append("signal")),
|
|
):
|
|
update_status(AppService(), app, True, session=session)
|
|
|
|
assert phase_events == ["commit", "signal"]
|
|
|
|
|
|
class TestOpenapiVisibilityHelpers:
|
|
"""Coverage for the session-injected, openapi-visibility-scoped
|
|
``AppService`` getters used by ``/openapi/v1/apps*``. These helpers
|
|
centralise the "row exists + status normal + openapi-visibility
|
|
gate passes" check so the controller can stay free of SQL.
|
|
"""
|
|
|
|
def test_get_app_by_id_is_plain_session_get(self):
|
|
"""``get_app_by_id`` must NOT apply status / visibility filters
|
|
— callers (e.g. the openapi auth pipeline) need to differentiate
|
|
404 (missing) from 403 (``enable_api`` off) and would lose that
|
|
signal if the helper coalesced both into ``None``.
|
|
"""
|
|
mock_session = MagicMock()
|
|
sentinel_app = MagicMock(spec=App)
|
|
sentinel_app.status = "archived" # explicitly NOT "normal"
|
|
mock_session.get.return_value = sentinel_app
|
|
|
|
assert AppService.get_app_by_id("app-uuid", mock_session) is sentinel_app
|
|
mock_session.get.assert_called_once_with(App, "app-uuid")
|
|
|
|
def test_get_app_by_id_returns_none_when_missing(self):
|
|
mock_session = MagicMock()
|
|
mock_session.get.return_value = None
|
|
|
|
assert AppService.get_app_by_id("missing", mock_session) is None
|
|
|
|
def test_get_visible_app_by_id_returns_app_when_visible(self):
|
|
mock_session = MagicMock()
|
|
app = MagicMock(spec=App)
|
|
app.status = "normal"
|
|
mock_session.get.return_value = app
|
|
|
|
with patch("services.app_service.is_openapi_visible", return_value=True):
|
|
assert AppService.get_visible_app_by_id("app-uuid", mock_session) is app
|
|
|
|
mock_session.get.assert_called_once_with(App, "app-uuid")
|
|
|
|
def test_get_visible_app_by_id_returns_none_when_row_missing(self):
|
|
mock_session = MagicMock()
|
|
mock_session.get.return_value = None
|
|
|
|
assert AppService.get_visible_app_by_id("missing", mock_session) is None
|
|
|
|
def test_get_visible_app_by_id_returns_none_when_status_not_normal(self):
|
|
"""Soft-deleted/archived rows must not surface on the openapi
|
|
surface — the helper hides them by returning ``None``.
|
|
"""
|
|
mock_session = MagicMock()
|
|
app = MagicMock(spec=App)
|
|
app.status = "archived"
|
|
mock_session.get.return_value = app
|
|
|
|
with patch("services.app_service.is_openapi_visible", return_value=True):
|
|
assert AppService.get_visible_app_by_id("app-uuid", mock_session) is None
|
|
|
|
def test_get_visible_app_by_id_returns_none_when_visibility_gate_rejects(self):
|
|
"""``is_openapi_visible`` is the per-row counterpart to
|
|
``apply_openapi_gate`` — when it returns False the helper must
|
|
treat the row as invisible (not "found but unauthorized").
|
|
"""
|
|
mock_session = MagicMock()
|
|
app = MagicMock(spec=App)
|
|
app.status = "normal"
|
|
mock_session.get.return_value = app
|
|
|
|
with patch("services.app_service.is_openapi_visible", return_value=False):
|
|
assert AppService.get_visible_app_by_id("app-uuid", mock_session) is None
|
|
|
|
def test_find_visible_apps_by_name_returns_scalars_through_visibility_gate(self):
|
|
"""Tenant-scoped name lookup. The helper passes the SELECT through
|
|
``apply_openapi_gate`` and materialises ``.scalars()`` into a list
|
|
so the controller can branch on length (404 / single / 409).
|
|
"""
|
|
mock_session = MagicMock()
|
|
rows = [MagicMock(spec=App), MagicMock(spec=App)]
|
|
mock_session.execute.return_value.scalars.return_value = iter(rows)
|
|
|
|
with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q) as gate:
|
|
out = AppService.find_visible_apps_by_name(name="my-app", tenant_id="tenant-1", session=mock_session)
|
|
|
|
assert out == rows
|
|
# Visibility gate must wrap the SELECT exactly once.
|
|
gate.assert_called_once()
|
|
mock_session.execute.assert_called_once()
|
|
|
|
def test_find_visible_apps_by_name_returns_empty_list_on_no_match(self):
|
|
mock_session = MagicMock()
|
|
mock_session.execute.return_value.scalars.return_value = iter([])
|
|
|
|
with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q):
|
|
out = AppService.find_visible_apps_by_name(name="nope", tenant_id="tenant-1", session=mock_session)
|
|
|
|
assert out == []
|
|
|
|
def test_find_visible_apps_by_ids_short_circuits_on_empty_input(self):
|
|
"""Empty id list must not emit ``WHERE id IN ()`` — Postgres
|
|
rejects empty IN lists and the call is a guaranteed no-op
|
|
anyway. The helper returns ``[]`` without touching the session.
|
|
"""
|
|
mock_session = MagicMock()
|
|
|
|
assert AppService.find_visible_apps_by_ids([], mock_session) == []
|
|
mock_session.execute.assert_not_called()
|
|
|
|
def test_find_visible_apps_by_ids_passes_through_visibility_gate(self):
|
|
"""Bulk fetch routes through ``apply_openapi_gate`` exactly once
|
|
and materialises the scalar rows. **No** status filter is
|
|
applied here — the EE permitted-external pipeline filters
|
|
non-normal hits in Python so its page count stays anchored.
|
|
"""
|
|
mock_session = MagicMock()
|
|
rows = [MagicMock(spec=App), MagicMock(spec=App)]
|
|
mock_session.execute.return_value.scalars.return_value.all.return_value = rows
|
|
|
|
with patch("services.app_service.apply_openapi_gate", side_effect=lambda q: q) as gate:
|
|
out = AppService.find_visible_apps_by_ids(["a", "b"], mock_session)
|
|
|
|
assert out == rows
|
|
gate.assert_called_once()
|
|
mock_session.execute.assert_called_once()
|
|
|
|
|
|
class TestAppMeta:
|
|
def test_loads_workflow_with_caller_session(self):
|
|
session = MagicMock()
|
|
session.get.return_value = SimpleNamespace(graph_dict={"nodes": []})
|
|
app = cast(App, SimpleNamespace(mode=AppMode.WORKFLOW, workflow_id="workflow-1"))
|
|
|
|
assert AppService().get_app_meta(app, session=session) == {"tool_icons": {}}
|
|
|
|
session.get.assert_called_once_with(Workflow, "workflow-1")
|
|
|
|
def test_loads_app_model_config_with_caller_session(self):
|
|
session = MagicMock()
|
|
session.get.return_value = SimpleNamespace(agent_mode_dict={"tools": []})
|
|
app = cast(App, SimpleNamespace(mode=AppMode.CHAT, app_model_config_id="config-1"))
|
|
|
|
assert AppService().get_app_meta(app, session=session) == {"tool_icons": {}}
|
|
|
|
session.get.assert_called_once_with(AppModelConfig, "config-1")
|
|
|
|
|
|
class TestGetApp:
|
|
def test_legacy_agent_detection_uses_caller_session(self):
|
|
session = MagicMock()
|
|
app = MagicMock(spec=App)
|
|
app.mode = AppMode.CHAT
|
|
app.is_agent_with_session.return_value = False
|
|
account = MagicMock(spec=Account)
|
|
account.current_tenant_id = "tenant-1"
|
|
|
|
with patch("services.app_service.current_user", account):
|
|
assert AppService().get_app(app, session=session) is app
|
|
|
|
app.is_agent_with_session.assert_called_once_with(session=session)
|
|
app.app_model_config_with_session.assert_not_called()
|
|
|
|
def test_agent_model_config_uses_caller_session(self):
|
|
session = MagicMock()
|
|
app = MagicMock(spec=App)
|
|
app.mode = AppMode.AGENT_CHAT
|
|
app.app_model_config_with_session.return_value = None
|
|
account = MagicMock(spec=Account)
|
|
account.current_tenant_id = "tenant-1"
|
|
|
|
with patch("services.app_service.current_user", account):
|
|
assert AppService().get_app(app, session=session) is app
|
|
|
|
app.is_agent_with_session.assert_not_called()
|
|
app.app_model_config_with_session.assert_called_once_with(session=session)
|
|
|
|
|
|
class TestAgentAppType:
|
|
"""S1: new ``AppMode.AGENT`` app type wiring."""
|
|
|
|
def test_agent_mode_enum_and_template_exist(self):
|
|
from constants.model_template import default_app_templates
|
|
from models.model import AppMode
|
|
|
|
assert AppMode.AGENT.value == "agent"
|
|
assert AppMode.AGENT in default_app_templates
|
|
# Runtime config comes from the Agent Soul, so no model_config is seeded.
|
|
assert "model_config" not in default_app_templates[AppMode.AGENT]
|
|
assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT
|
|
|
|
def test_create_app_params_accepts_agent_mode(self):
|
|
from services.app_service import CreateAppParams
|
|
|
|
params = CreateAppParams(name="Iris", mode="agent")
|
|
assert params.mode == "agent"
|
|
|
|
def test_bound_agent_id_is_none_for_non_agent_app(self):
|
|
"""Non-agent apps short-circuit without touching the DB."""
|
|
from models.model import App, AppMode
|
|
|
|
app = App()
|
|
app.mode = AppMode.CHAT
|
|
assert app.bound_agent_id is None
|
|
|
|
def test_update_agent_app_syncs_backing_agent_identity(self):
|
|
from models.agent import AgentIconType
|
|
from models.model import AppMode, IconType
|
|
from services.app_service import AppService
|
|
|
|
app = SimpleNamespace(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
mode=AppMode.AGENT,
|
|
name="Old",
|
|
description="old",
|
|
role="draft",
|
|
icon_type=IconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
use_icon_as_answer_icon=False,
|
|
max_active_requests=None,
|
|
created_by="account-1",
|
|
)
|
|
backing_agent = SimpleNamespace(
|
|
name="Old",
|
|
description="old",
|
|
role="draft",
|
|
icon_type=AgentIconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
updated_by=None,
|
|
updated_at=None,
|
|
)
|
|
|
|
with (
|
|
patch("services.app_service.db") as mock_db,
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-2")),
|
|
):
|
|
mock_db.session.scalar.return_value = backing_agent
|
|
updated_app = AppService().update_app(
|
|
app, # type: ignore[arg-type]
|
|
{
|
|
"name": "Iris",
|
|
"description": "agent app",
|
|
"role": "research assistant",
|
|
"icon_type": "image",
|
|
"icon": "file-id",
|
|
"icon_background": "#123456",
|
|
"use_icon_as_answer_icon": False,
|
|
"max_active_requests": 0,
|
|
},
|
|
session=mock_db.session,
|
|
)
|
|
|
|
assert updated_app.name == "Iris"
|
|
assert backing_agent.name == "Iris"
|
|
assert backing_agent.description == "agent app"
|
|
assert backing_agent.role == "research assistant"
|
|
assert backing_agent.icon_type == AgentIconType.IMAGE
|
|
assert backing_agent.icon == "file-id"
|
|
assert backing_agent.icon_background == "#123456"
|
|
assert backing_agent.updated_by == "account-2"
|
|
assert backing_agent.updated_at == updated_app.updated_at
|
|
|
|
def test_update_agent_app_preserves_role_when_args_omit_it(self):
|
|
from models.agent import AgentIconType
|
|
from models.model import AppMode, IconType
|
|
from services.app_service import AppService
|
|
|
|
app = SimpleNamespace(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
mode=AppMode.AGENT,
|
|
name="Old",
|
|
description="old",
|
|
role="draft",
|
|
icon_type=IconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
use_icon_as_answer_icon=False,
|
|
max_active_requests=None,
|
|
created_by="account-1",
|
|
)
|
|
backing_agent = SimpleNamespace(
|
|
name="Old",
|
|
description="old",
|
|
role="research assistant",
|
|
icon_type=AgentIconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
updated_by=None,
|
|
updated_at=None,
|
|
)
|
|
|
|
with (
|
|
patch("services.app_service.db") as mock_db,
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-2")),
|
|
):
|
|
mock_db.session.scalar.return_value = backing_agent
|
|
AppService().update_app(
|
|
app, # type: ignore[arg-type]
|
|
{
|
|
"name": "Iris",
|
|
"description": "agent app",
|
|
"icon_type": "image",
|
|
"icon": "file-id",
|
|
"icon_background": "#123456",
|
|
"use_icon_as_answer_icon": False,
|
|
"max_active_requests": 0,
|
|
},
|
|
session=mock_db.session,
|
|
)
|
|
|
|
assert backing_agent.role == "research assistant"
|
|
|
|
def test_update_agent_app_clears_role_when_args_set_empty_string(self):
|
|
from models.agent import AgentIconType
|
|
from models.model import AppMode, IconType
|
|
from services.app_service import AppService
|
|
|
|
app = SimpleNamespace(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
mode=AppMode.AGENT,
|
|
name="Old",
|
|
description="old",
|
|
role="draft",
|
|
icon_type=IconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
use_icon_as_answer_icon=False,
|
|
max_active_requests=None,
|
|
created_by="account-1",
|
|
)
|
|
backing_agent = SimpleNamespace(
|
|
name="Old",
|
|
description="old",
|
|
role="research assistant",
|
|
icon_type=AgentIconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
updated_by=None,
|
|
updated_at=None,
|
|
)
|
|
|
|
with (
|
|
patch("services.app_service.db") as mock_db,
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-2")),
|
|
):
|
|
mock_db.session.scalar.return_value = backing_agent
|
|
AppService().update_app(
|
|
app, # type: ignore[arg-type]
|
|
{
|
|
"name": "Iris",
|
|
"description": "agent app",
|
|
"role": "",
|
|
"icon_type": "image",
|
|
"icon": "file-id",
|
|
"icon_background": "#123456",
|
|
"use_icon_as_answer_icon": False,
|
|
"max_active_requests": 0,
|
|
},
|
|
session=mock_db.session,
|
|
)
|
|
|
|
assert backing_agent.role == ""
|
|
|
|
def test_update_agent_app_duplicate_name_rolls_back_and_raises_conflict(self):
|
|
from models.agent import AgentIconType
|
|
from models.model import AppMode, IconType
|
|
from services.app_service import AppService
|
|
|
|
app = SimpleNamespace(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
mode=AppMode.AGENT,
|
|
name="Old",
|
|
description="old",
|
|
role="draft",
|
|
icon_type=IconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
use_icon_as_answer_icon=False,
|
|
max_active_requests=None,
|
|
created_by="account-1",
|
|
)
|
|
backing_agent = SimpleNamespace(
|
|
name="Old",
|
|
description="old",
|
|
role="research assistant",
|
|
icon_type=AgentIconType.EMOJI,
|
|
icon="robot",
|
|
icon_background="#fff",
|
|
updated_by=None,
|
|
updated_at=None,
|
|
)
|
|
|
|
with (
|
|
patch("services.app_service.db") as mock_db,
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-2")),
|
|
):
|
|
mock_db.session.scalar.return_value = backing_agent
|
|
mock_db.session.commit.side_effect = IntegrityError("duplicate", None, None)
|
|
with pytest.raises(AgentNameConflictError):
|
|
AppService().update_app(
|
|
app, # type: ignore[arg-type]
|
|
{
|
|
"name": "Existing Agent",
|
|
"description": "agent app",
|
|
"role": "research assistant",
|
|
"icon_type": "emoji",
|
|
"icon": "robot",
|
|
"icon_background": "#fff",
|
|
"use_icon_as_answer_icon": False,
|
|
"max_active_requests": 0,
|
|
},
|
|
session=mock_db.session,
|
|
)
|
|
|
|
mock_db.session.rollback.assert_called_once()
|
|
|
|
def test_delete_agent_app_archives_backing_agent(self):
|
|
from models.agent import AgentStatus
|
|
from models.model import AppMode
|
|
from services.app_service import AppService
|
|
|
|
app = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
backing_agent = SimpleNamespace(status=AgentStatus.ACTIVE, archived_by=None, archived_at=None)
|
|
|
|
with (
|
|
patch("services.app_service.db") as mock_db,
|
|
patch("services.app_service.current_user", SimpleNamespace(id="account-2")),
|
|
patch("services.app_service.BillingService"),
|
|
patch("services.app_service.EnterpriseService"),
|
|
patch("services.app_service.FeatureService"),
|
|
patch("services.app_service.dify_config"),
|
|
patch("services.app_service.remove_app_and_related_data_task"),
|
|
):
|
|
mock_db.session.scalar.return_value = backing_agent
|
|
AppService().delete_app(app, session=mock_db.session) # type: ignore[arg-type]
|
|
|
|
assert backing_agent.status == AgentStatus.ARCHIVED
|
|
assert backing_agent.archived_by == "account-2"
|
|
assert backing_agent.archived_at is not None
|
|
mock_db.session.delete.assert_called_once_with(app)
|