test: migrate console app sessions to SQLite (#40081)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Asuka Minato
2026-08-07 03:46:27 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 6530f2bcdb
commit 1375dc3864
15 changed files with 654 additions and 415 deletions
@@ -1,3 +1,4 @@
from datetime import datetime
from inspect import getsource, unwrap
from types import SimpleNamespace
from typing import Any, cast
@@ -5,6 +6,7 @@ from unittest.mock import MagicMock, Mock, call
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.console import console_ns
@@ -53,10 +55,73 @@ from controllers.console.app.message import (
AgentMessageFeedbackApi,
AgentMessageSuggestedQuestionApi,
)
from models.agent import AgentConfigDraftType
from core.app.entities.app_invoke_entities import InvokeFrom
from models.agent import Agent, AgentConfigDraftType, AgentScope, AgentSource, AgentStatus
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation, Message
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
def _persist_conversation_message(
session: Session,
*,
app_id: str,
conversation_id: str,
message_id: str,
created_at: datetime,
) -> tuple[Conversation, Message]:
conversation = session.get(Conversation, conversation_id)
if conversation is None:
conversation = Conversation(
app_id=app_id,
app_model_config_id=None,
model_provider=None,
override_model_configs=None,
model_id=None,
mode=AppMode.CHAT,
name="Conversation",
inputs={},
introduction="",
system_instruction="",
system_instruction_tokens=0,
status="normal",
invoke_from=InvokeFrom.DEBUGGER,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="00000000-0000-0000-0000-000000000021",
)
conversation.id = conversation_id
session.add(conversation)
session.flush()
message = Message(
app_id=app_id,
conversation_id=conversation.id,
inputs={},
query="query",
message={},
message_tokens=0,
message_unit_price=0,
message_price_unit=0,
answer="answer",
answer_tokens=0,
answer_unit_price=0,
answer_price_unit=0,
provider_response_latency=0,
total_price=0,
currency="USD",
invoke_from=InvokeFrom.DEBUGGER,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="00000000-0000-0000-0000-000000000021",
app_mode=AppMode.CHAT,
created_at=created_at,
)
message.id = message_id
session.add(message)
session.flush()
return conversation, message
def _version_response(version_id: str = "version-1") -> dict:
return {
"id": version_id,
@@ -430,18 +495,25 @@ def test_agent_app_create_omits_optional_role_as_empty_string(
def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
agent = SimpleNamespace(
id=agent_id,
app_id="app-1",
backing_app_id=None,
tenant_id = "00000000-0000-0000-0000-000000000002"
app_id = "00000000-0000-0000-0000-000000000003"
app_model = _app_detail_obj(id=app_id, tenant_id=tenant_id, bound_agent_id=agent_id)
agent = Agent(
tenant_id=tenant_id,
name="Resolved agent",
description="",
role="Resolved role",
debug_conversation_id="debug-conversation-detail",
active_config_snapshot_id=None,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
app_id=app_id,
status=AgentStatus.ACTIVE,
)
agent.id = agent_id
sqlite_session.add(agent)
sqlite_session.flush()
captured: dict[str, object] = {}
monkeypatch.setattr(roster_controller.AgentRosterService, "get_agent_app_model", lambda _self, **kwargs: app_model)
monkeypatch.setattr(roster_controller, "_resolve_agent_runtime_app_model", lambda _session, **kwargs: app_model)
@@ -472,17 +544,16 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
def update_app(self, app_obj: object, args: dict[str, object], *, session: object) -> object:
captured["update"] = {"app": app_obj, "args": args}
return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id)
return _app_detail_obj(id=app_id, tenant_id=tenant_id, name=args["name"], bound_agent_id=agent_id)
def delete_app(self, app_obj: object, *, session: object) -> None:
captured["delete"] = app_obj
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
session = Mock()
session.scalar.return_value = agent
detail = unwrap(AgentAppApi.get)(AgentAppApi(), session, "tenant-1", SimpleNamespace(id=account_id), agent_id)
session = sqlite_session
detail = unwrap(AgentAppApi.get)(AgentAppApi(), session, tenant_id, SimpleNamespace(id=account_id), agent_id)
assert detail["id"] == agent_id
assert detail["app_id"] == "app-1"
assert detail["app_id"] == app_id
assert detail["debug_conversation_id"] == "debug-conversation-detail"
assert detail["debug_conversation_has_messages"] is True
assert detail["debug_conversation_message_count"] == 2
@@ -495,10 +566,10 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
"/console/api/agent/00000000-0000-0000-0000-000000000001",
json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"},
):
updated = unwrap(AgentAppApi.put)(AgentAppApi(), session, "tenant-1", SimpleNamespace(id=account_id), agent_id)
updated = unwrap(AgentAppApi.put)(AgentAppApi(), session, tenant_id, SimpleNamespace(id=account_id), agent_id)
assert updated["name"] == "Renamed"
assert updated["id"] == agent_id
assert updated["app_id"] == "app-1"
assert updated["app_id"] == app_id
assert updated["debug_conversation_id"] == "debug-conversation-detail"
assert updated["debug_conversation_has_messages"] is True
assert updated["debug_conversation_message_count"] == 2
@@ -508,7 +579,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
update_call = cast(dict[str, object], captured["update"])
assert update_call["app"] is app_model
assert cast(dict[str, object], update_call["args"])["role"] == "Reviewer"
deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), session, "tenant-1", agent_id)
deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), session, tenant_id, agent_id)
assert (deleted, status) == ("", 204)
assert captured["delete"] is app_model
@@ -735,7 +806,9 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
}
def test_agent_api_status_and_key_routes_resolve_backing_app(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_api_status_and_key_routes_resolve_backing_app(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
api_key_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(
@@ -747,7 +820,6 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(app: Flask, monkeyp
api_rph=0,
)
captured: dict[str, object] = {}
session = MagicMock()
resolve_app = Mock(return_value=app_model)
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", resolve_app)
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 1)
@@ -792,34 +864,42 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(app: Flask, monkeyp
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable", json={"enable_api": True}
):
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), session, "tenant-1", agent_id)
enabled = unwrap(AgentApiStatusApi.post)(AgentApiStatusApi(), unbound_session, "tenant-1", agent_id)
assert enabled["enabled"] is True
assert captured["enable"] == {"app": app_model, "enable_api": True}
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), session, "tenant-1", agent_id)
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), unbound_session, "tenant-1", agent_id)
assert keys == {"data": []}
assert captured["list_keys"] == {"session": session, "resource_id": "app-1", "tenant_id": "tenant-1"}
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), session, "tenant-1", agent_id)
assert captured["list_keys"] == {
"session": unbound_session,
"resource_id": "app-1",
"tenant_id": "tenant-1",
}
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), unbound_session, "tenant-1", agent_id)
assert status == 201
assert created["id"] == api_key_id
assert created["token"] == "app-test-token"
assert captured["create_key"] == {"session": session, "resource_id": "app-1", "tenant_id": "tenant-1"}
assert captured["create_key"] == {
"session": unbound_session,
"resource_id": "app-1",
"tenant_id": "tenant-1",
}
current_user = SimpleNamespace(id="account-1", is_admin_or_owner=True)
deleted, delete_status = unwrap(AgentApiKeyApi.delete)(
AgentApiKeyApi(), session, "tenant-1", current_user, agent_id, api_key_id
AgentApiKeyApi(), unbound_session, "tenant-1", current_user, agent_id, api_key_id
)
assert (deleted, delete_status) == ("", 204)
assert captured["delete_key"] == {
"session": session,
"session": unbound_session,
"resource_id": "app-1",
"api_key_id": api_key_id,
"tenant_id": "tenant-1",
"current_user": current_user,
}
assert resolve_app.call_args_list == [
call(session, tenant_id="tenant-1", agent_id=agent_id),
call(session, tenant_id="tenant-1", agent_id=agent_id),
call(session, tenant_id="tenant-1", agent_id=agent_id),
call(session, tenant_id="tenant-1", agent_id=agent_id),
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
]
@@ -1327,7 +1407,7 @@ def test_agent_composer_routes_resolve_app_from_agent_id(
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
@@ -1358,22 +1438,21 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
)
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
session = Mock()
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
assert unwrap(AgentChatMessageApi.post)(
AgentChatMessageApi(), session, "tenant-1", SimpleNamespace(id=account_id), agent_id
AgentChatMessageApi(), unbound_session, "tenant-1", SimpleNamespace(id=account_id), agent_id
) == {"result": "generated"}
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
assert captured["resolve_session"] is session
assert captured["resolve_session"] is unbound_session
create_call = cast(dict[str, object], captured["create"])
assert create_call["session"] is session
assert create_call["session"] is unbound_session
assert create_call["app_model"] is app_model
assert cast(SimpleNamespace, create_call["current_user"]).id == account_id
assert unwrap(AgentChatMessageStopApi.post)(
AgentChatMessageStopApi(), session, "tenant-1", account_id, agent_id, "task-1"
AgentChatMessageStopApi(), unbound_session, "tenant-1", account_id, agent_id, "task-1"
) == ({"result": "success"}, 200)
assert captured["stop_resolve"] == {
"session": session,
"session": unbound_session,
"tenant_id": "tenant-1",
"agent_id": agent_id,
}
@@ -1424,7 +1503,7 @@ def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
@@ -1444,14 +1523,13 @@ def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
lambda _self, **kwargs: resolve_agent_app_model(**kwargs),
)
monkeypatch.setattr(completion_controller, "_create_build_chat_finalization_message", create_finalization_message)
session = Mock()
with app.test_request_context():
assert unwrap(AgentBuildChatFinalizeApi.post)(
AgentBuildChatFinalizeApi(), session, "tenant-1", SimpleNamespace(id=account_id), agent_id
AgentBuildChatFinalizeApi(), unbound_session, "tenant-1", SimpleNamespace(id=account_id), agent_id
) == {"result": "generated"}
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
finalize_call = cast(dict[str, object], captured["finalize"])
assert finalize_call["session"] is session
assert finalize_call["session"] is unbound_session
assert finalize_call["app_model"] is app_model
assert finalize_call["current_tenant_id"] == "tenant-1"
assert finalize_call["agent_id"] == agent_id
@@ -1459,7 +1537,7 @@ def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
) -> None:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
captured: dict[str, object] = {}
@@ -1478,18 +1556,17 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
completion_controller, "_resolve_current_user_agent_debug_conversation_id", resolve_debug_conversation
)
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
session = Mock()
with app.test_request_context(headers={"X-Trace-Id": "trace-1"}):
result = completion_controller._create_build_chat_finalization_message(
current_tenant_id="tenant-1",
current_user=SimpleNamespace(id=account_id),
app_model=app_model,
agent_id="agent-1",
session=session,
session=unbound_session,
)
assert result == ({"result": "success"}, 200)
assert captured["resolve_debug_conversation"] == {
"session": session,
"session": unbound_session,
"current_tenant_id": "tenant-1",
"current_user": SimpleNamespace(id=account_id),
"app_model": app_model,
@@ -1576,6 +1653,7 @@ def test_agent_chat_helper_resolves_scoped_conversation_and_forces_streaming(
payload_extra: dict[str, str | None],
expected_draft_type: AgentConfigDraftType,
expected_start_new: bool,
unbound_session: Session,
) -> None:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
current_user = SimpleNamespace(id=account_id)
@@ -1603,7 +1681,7 @@ def test_agent_chat_helper_resolves_scoped_conversation_and_forces_streaming(
headers={"X-Trace-Id": "trace-1"},
):
result = completion_controller._create_chat_message(
current_user=current_user, app_model=app_model, session=Mock()
current_user=current_user, app_model=app_model, session=unbound_session
)
assert result == {"response": {"answer": "ok"}}
assert captured["app_model"] is app_model
@@ -1620,7 +1698,7 @@ def test_agent_chat_helper_resolves_scoped_conversation_and_forces_streaming(
def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
) -> None:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
current_user = SimpleNamespace(id=account_id)
@@ -1653,7 +1731,7 @@ def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
result = completion_controller._create_chat_message(
current_user=current_user,
app_model=app_model,
session=Mock(),
session=unbound_session,
)
assert result == {"response": {"answer": "ok"}}
@@ -1677,6 +1755,7 @@ def test_agent_chat_helper_rejects_foreign_debug_conversation_before_generation(
account_id: str,
payload_extra: dict[str, str],
expected_draft_type: AgentConfigDraftType,
unbound_session: Session,
) -> None:
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent")
generate = MagicMock()
@@ -1702,7 +1781,7 @@ def test_agent_chat_helper_rejects_foreign_debug_conversation_before_generation(
current_user=SimpleNamespace(id=account_id),
app_model=app_model,
agent_id="agent-1",
session=Mock(),
session=unbound_session,
)
resolve_debug_conversation.assert_called_once()
@@ -1835,25 +1914,30 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app
],
)
def test_agent_chat_helper_maps_generation_errors(
app: Flask, monkeypatch: pytest.MonkeyPatch, error: Exception, expected: type[Exception]
app: Flask,
monkeypatch: pytest.MonkeyPatch,
error: Exception,
expected: type[Exception],
unbound_session: Session,
) -> None:
app_model = SimpleNamespace(id="app-1", mode="chat")
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error))
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
with pytest.raises(expected):
completion_controller._create_chat_message(
current_user=SimpleNamespace(id="account-1"), app_model=app_model, session=Mock()
current_user=SimpleNamespace(id="account-1"), app_model=app_model, session=unbound_session
)
def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_chat_message_routes_resolve_app_from_agent_id(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
message_id = "00000000-0000-0000-0000-000000000002"
app_model = SimpleNamespace(id="app-1", mode="agent")
current_user = SimpleNamespace(id="account-1")
captured: dict[str, object] = {}
resolver_calls: list[dict[str, object]] = []
session = Mock()
def resolve_agent_app_model(**kwargs: object) -> object:
resolver_calls.append(kwargs)
@@ -1881,50 +1965,68 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp
monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions)
monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail)
assert unwrap(AgentChatMessageListApi.get)(
AgentChatMessageListApi(), session, "tenant-1", current_user, agent_id
AgentChatMessageListApi(), unbound_session, "tenant-1", current_user, agent_id
) == {"data": []}
list_call = cast(dict[str, object], captured["list"])
assert list_call["session"] is session
assert list_call["session"] is unbound_session
assert list_call["app_model"] is app_model
with app.test_request_context(json={"message_id": message_id, "rating": "like"}):
assert unwrap(AgentMessageFeedbackApi.post)(
AgentMessageFeedbackApi(), session, "tenant-1", current_user, agent_id
AgentMessageFeedbackApi(), unbound_session, "tenant-1", current_user, agent_id
) == {"result": "success"}
feedback_call = cast(dict[str, object], captured["feedback"])
assert feedback_call["session"] is session
assert feedback_call["session"] is unbound_session
assert feedback_call["app_model"] is app_model
assert feedback_call["current_user"] is current_user
assert unwrap(AgentMessageSuggestedQuestionApi.get)(
AgentMessageSuggestedQuestionApi(), session, "tenant-1", current_user, agent_id, message_id
AgentMessageSuggestedQuestionApi(), unbound_session, "tenant-1", current_user, agent_id, message_id
) == {"data": ["next"]}
suggested_call = cast(dict[str, object], captured["suggested"])
assert suggested_call["session"] is session
assert suggested_call["session"] is unbound_session
assert suggested_call["app_model"] is app_model
assert suggested_call["current_user"] is current_user
assert suggested_call["message_id"] == message_id
assert unwrap(AgentMessageApi.get)(AgentMessageApi(), session, "tenant-1", agent_id, message_id) == {
assert unwrap(AgentMessageApi.get)(AgentMessageApi(), unbound_session, "tenant-1", agent_id, message_id) == {
"id": message_id
}
detail_call = cast(dict[str, object], captured["detail"])
assert detail_call == {"session": session, "app_model": app_model, "message_id": message_id}
assert detail_call == {"session": unbound_session, "app_model": app_model, "message_id": message_id}
assert resolver_calls == [
{"session": session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": unbound_session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": unbound_session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": unbound_session, "tenant_id": "tenant-1", "agent_id": agent_id},
{"session": unbound_session, "tenant_id": "tenant-1", "agent_id": agent_id},
]
def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_chat_messages_supports_first_id_pagination(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
app_id = "00000000-0000-0000-0000-000000000001"
conversation_id = "00000000-0000-0000-0000-000000000010"
first_message_id = "00000000-0000-0000-0000-000000000011"
older_message_id = "00000000-0000-0000-0000-000000000012"
conversation = SimpleNamespace(id=conversation_id)
first_message = SimpleNamespace(id=first_message_id, created_at=2)
older_message = SimpleNamespace(id=older_message_id, created_at=1)
scalar_values = iter([conversation, first_message, True])
scalars_result = SimpleNamespace(all=lambda: [older_message])
session = SimpleNamespace(scalar=lambda _stmt: next(scalar_values), scalars=lambda _stmt: scalars_result)
_persist_conversation_message(
sqlite_session,
app_id=app_id,
conversation_id=conversation_id,
message_id="00000000-0000-0000-0000-000000000013",
created_at=datetime(2025, 1, 1),
)
_persist_conversation_message(
sqlite_session,
app_id=app_id,
conversation_id=conversation_id,
message_id=older_message_id,
created_at=datetime(2025, 1, 2),
)
_persist_conversation_message(
sqlite_session,
app_id=app_id,
conversation_id=conversation_id,
message_id=first_message_id,
created_at=datetime(2025, 1, 3),
)
class FakeMessagePaginationResponse:
@classmethod
@@ -1943,20 +2045,27 @@ def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch
f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}&first_id={first_message_id}&limit=1"
):
result = message_controller._list_chat_messages(
session=session, app_model=SimpleNamespace(id="app-1", mode="chat")
session=sqlite_session, app_model=SimpleNamespace(id=app_id, mode="chat")
)
assert result == {"data": [older_message_id], "limit": 1, "has_more": True}
def test_list_agent_chat_messages_uses_current_user_conversation(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_agent_chat_messages_uses_current_user_conversation(
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
app_id = "00000000-0000-0000-0000-000000000001"
conversation_id = "00000000-0000-0000-0000-000000000010"
message_id = "00000000-0000-0000-0000-000000000011"
conversation = SimpleNamespace(id=conversation_id)
message = SimpleNamespace(id=message_id, created_at=1)
conversation, _ = _persist_conversation_message(
sqlite_session,
app_id=app_id,
conversation_id=conversation_id,
message_id=message_id,
created_at=datetime(2025, 1, 1),
)
current_user = SimpleNamespace(id="account-1")
app_model = SimpleNamespace(id="app-1", mode="agent")
app_model = SimpleNamespace(id=app_id, mode="agent")
captured: dict[str, object] = {}
session = SimpleNamespace(scalar=lambda _stmt: False, scalars=lambda _stmt: SimpleNamespace(all=lambda: [message]))
class FakeMessagePaginationResponse:
@classmethod
@@ -1977,13 +2086,17 @@ def test_list_agent_chat_messages_uses_current_user_conversation(app: Flask, mon
monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None)
monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse)
with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"):
result = message_controller._list_chat_messages(session=session, app_model=app_model, current_user=current_user)
result = message_controller._list_chat_messages(
session=sqlite_session, app_model=app_model, current_user=current_user
)
assert result == {"data": [message_id], "limit": 20, "has_more": False}
assert captured.pop("session") is session
assert captured.pop("session") is sqlite_session
assert captured == {"app_model": app_model, "conversation_id": conversation_id, "user": current_user}
def test_list_agent_chat_messages_rejects_foreign_conversation(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_list_agent_chat_messages_rejects_foreign_conversation(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
conversation_id = "00000000-0000-0000-0000-000000000010"
monkeypatch.setattr(
message_controller.ConversationService,
@@ -1993,32 +2106,33 @@ def test_list_agent_chat_messages_rejects_foreign_conversation(app: Flask, monke
with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"):
with pytest.raises(NotFound):
message_controller._list_chat_messages(
session=Mock(),
session=unbound_session,
app_model=SimpleNamespace(id="app-1", mode="agent"),
current_user=SimpleNamespace(id="account-1"),
)
def test_update_message_feedback_rejects_empty_rating_without_existing_feedback(
app: Flask,
app: Flask, sqlite_session: Session
) -> None:
app_id = "00000000-0000-0000-0000-000000000001"
message_id = "00000000-0000-0000-0000-000000000002"
message = SimpleNamespace(
id=message_id,
app_id="app-1",
admin_feedback_with_session=MagicMock(return_value=None),
_, message = _persist_conversation_message(
sqlite_session,
app_id=app_id,
conversation_id="00000000-0000-0000-0000-000000000010",
message_id=message_id,
created_at=datetime(2025, 1, 1),
)
session = MagicMock()
session.scalar.return_value = message
with app.test_request_context(json={"message_id": message_id, "rating": None}):
with pytest.raises(ValueError, match="rating cannot be None"):
message_controller._update_message_feedback(
session=session,
session=sqlite_session,
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
app_model=SimpleNamespace(id=app_id),
)
message.admin_feedback_with_session.assert_called_once_with(session=session)
assert message.admin_feedback_with_session(session=sqlite_session) is None
@pytest.mark.parametrize(
@@ -2041,12 +2155,10 @@ def test_update_message_feedback_rejects_empty_rating_without_existing_feedback(
],
)
def test_get_message_suggested_questions_maps_service_errors(
monkeypatch: pytest.MonkeyPatch, error: Exception, expected: type[Exception]
monkeypatch: pytest.MonkeyPatch, error: Exception, expected: type[Exception], unbound_session: Session
) -> None:
session = Mock()
def raise_error(**kwargs: object) -> None:
assert kwargs["session"] is session
assert kwargs["session"] is unbound_session
raise error
monkeypatch.setattr(
@@ -2056,7 +2168,7 @@ def test_get_message_suggested_questions_maps_service_errors(
)
with pytest.raises(expected):
message_controller._get_message_suggested_questions(
session=session,
session=unbound_session,
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
message_id="00000000-0000-0000-0000-000000000002",
@@ -2,12 +2,15 @@ from unittest.mock import MagicMock
from uuid import UUID
import pytest
from sqlalchemy.orm import Session
from controllers.console.agent import app_helpers
def test_resolve_agent_app_model_reuses_caller_session(monkeypatch: pytest.MonkeyPatch) -> None:
session = MagicMock()
def test_resolve_agent_app_model_reuses_caller_session(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
session = unbound_session
app = MagicMock()
service = MagicMock()
service.get_agent_app_model.return_value = app
@@ -28,8 +31,10 @@ def test_resolve_agent_app_model_reuses_caller_session(monkeypatch: pytest.Monke
)
def test_resolve_agent_runtime_app_model_reuses_caller_session(monkeypatch: pytest.MonkeyPatch) -> None:
session = MagicMock()
def test_resolve_agent_runtime_app_model_reuses_caller_session(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
session = unbound_session
app = MagicMock()
service = MagicMock()
service.get_agent_runtime_app_model.return_value = app
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock
import pytest
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
from sqlalchemy.orm import Session
from controllers.console import agent_app_sandbox as module
from models.model import App, AppMode, IconType
@@ -159,9 +160,9 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
module._handle(RuntimeError("boom"))
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
service = _AgentAppService()
session = MagicMock()
session = unbound_session
account = SimpleNamespace(id="account-1")
resolver = MagicMock(return_value=_app_model())
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
@@ -201,7 +202,9 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat
assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
def test_agent_app_sandbox_resource_returns_normalized_errors(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
class FailingService:
def get_info(self, **kwargs):
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
@@ -210,7 +213,7 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytes
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
session = MagicMock()
session = unbound_session
account = SimpleNamespace(id="account-1")
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
monkeypatch.setattr(
@@ -11,6 +11,8 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, PropertyMock, patch
from flask import Flask
from sqlalchemy import event
from sqlalchemy.orm import Session
from controllers.console.app import agent_config_inspector as inspector
from controllers.console.app.agent_config_inspector import (
@@ -46,8 +48,8 @@ _APP = SimpleNamespace(
_USER = SimpleNamespace(id="acct-1")
def test_resolve_bound_agent_uses_injected_session():
session = MagicMock()
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
session = unbound_session
resolver = MagicMock(return_value="agent-1")
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
result = inspector._resolve_agent_id(session, app_model, None)
@@ -100,8 +102,10 @@ def test_manifest_resolves_workflow_node_agent_and_normal_draft():
assert config_service.return_value.manifest.call_args.kwargs["config_version_kind"].value == "draft"
def test_normal_draft_resolution_commits_created_draft_before_service_session() -> None:
session = MagicMock()
def test_normal_draft_resolution_commits_created_draft_before_service_session(sqlite_session: Session) -> None:
session = sqlite_session
commits: list[str] = []
event.listen(session, "after_commit", lambda _session: commits.append("commit"))
with patch(f"{_MOD}.AgentComposerService") as composer:
composer.load_agent_composer.return_value = {"draft": {"id": "draft-1"}}
version_id, version_kind = inspector._resolve_console_version(
@@ -114,7 +118,7 @@ def test_normal_draft_resolution_commits_created_draft_before_service_session()
)
assert version_id == "draft-1"
assert version_kind.value == "draft"
session.commit.assert_called_once()
assert commits == ["commit"]
def test_skill_inspect_by_agent_returns_strict_json_response():
@@ -12,6 +12,7 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import agent_drive_inspector as inspector
from controllers.console.app.agent_drive_inspector import (
@@ -43,18 +44,17 @@ _APP = SimpleNamespace(
)
def test_resolve_bound_agent_uses_injected_session():
session = MagicMock()
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
resolver = MagicMock(return_value="agent-1")
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
result = inspector._resolve_agent_id(session, app_model, None)
result = inspector._resolve_agent_id(unbound_session, app_model, None)
assert result == "agent-1"
resolver.assert_called_once_with(session=session)
assert resolver.call_args.kwargs["session"] is session
resolver.assert_called_once_with(session=unbound_session)
assert resolver.call_args.kwargs["session"] is unbound_session
def test_list_filters_value_pointers_out_of_console_payload():
def test_list_filters_value_pointers_out_of_console_payload(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?prefix=pdf-toolkit/"):
with patch(f"{_MOD}.AgentDriveService") as drive:
@@ -69,15 +69,14 @@ def test_list_filters_value_pointers_out_of_console_payload():
"created_at": 1718000000,
}
]
body = raw(AgentDriveListApi(), MagicMock(), _APP)
body = raw(AgentDriveListApi(), unbound_session, _APP)
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
assert drive.return_value.manifest.call_args.kwargs["prefix"] == "pdf-toolkit/"
def test_list_by_agent_filters_value_pointers_out_of_console_payload():
def test_list_by_agent_filters_value_pointers_out_of_console_payload(unbound_session: Session):
raw = _raw(AgentDriveListByAgentApi.get)
session = MagicMock()
with app.test_request_context("/?prefix=pdf-toolkit/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
@@ -94,28 +93,27 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
"created_at": 1718000000,
}
]
body = raw(AgentDriveListByAgentApi(), session, "tenant-1", "agent-1")
body = raw(AgentDriveListByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["items"][0]["key"] == "pdf-toolkit/SKILL.md"
assert "file_id" not in body["items"][0]
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
assert drive.return_value.manifest.call_args.kwargs["session"] is session
assert drive.return_value.manifest.call_args.kwargs["session"] is unbound_session
def test_list_resolves_workflow_node_binding_agent():
def test_list_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.manifest.return_value = []
raw(AgentDriveListApi(), MagicMock(), _APP)
raw(AgentDriveListApi(), unbound_session, _APP)
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "wf-agent-9"
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_skill_list_by_agent_calls_service():
def test_skill_list_by_agent_calls_service(unbound_session: Session):
raw = _raw(AgentDriveSkillListByAgentApi.get)
session = MagicMock()
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
@@ -134,27 +132,26 @@ def test_skill_list_by_agent_calls_service():
"created_at": 1718000000,
}
]
body = raw(AgentDriveSkillListByAgentApi(), session, "tenant-1", "agent-1")
body = raw(AgentDriveSkillListByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["items"][0]["path"] == "pdf-toolkit"
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
assert drive.return_value.list_skills.call_args.kwargs["session"] is session
assert drive.return_value.list_skills.call_args.kwargs["session"] is unbound_session
def test_skill_list_resolves_workflow_node_binding_agent():
def test_skill_list_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveSkillListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.list_skills.return_value = []
body = raw(AgentDriveSkillListApi(), MagicMock(), _APP)
body = raw(AgentDriveSkillListApi(), unbound_session, _APP)
assert body == {"items": []}
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_skill_inspect_by_agent_returns_strict_json_response():
def test_skill_inspect_by_agent_returns_strict_json_response(unbound_session: Session):
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
session = MagicMock()
payload = {
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
@@ -191,14 +188,14 @@ def test_skill_inspect_by_agent_returns_strict_json_response():
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectByAgentApi(), session, "tenant-1", "agent-1", "pdf-toolkit")
response = raw(AgentDriveSkillInspectByAgentApi(), unbound_session, "tenant-1", "agent-1", "pdf-toolkit")
assert response.status_code == 200
assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
assert drive.return_value.inspect_skill.call_args.kwargs["session"] is session
assert drive.return_value.inspect_skill.call_args.kwargs["session"] is unbound_session
def test_skill_inspect_resolves_workflow_node_binding_agent():
def test_skill_inspect_resolves_workflow_node_binding_agent(unbound_session: Session):
raw = _raw(AgentDriveSkillInspectApi.get)
payload = {
"path": "pdf-toolkit",
@@ -220,24 +217,23 @@ def test_skill_inspect_resolves_workflow_node_binding_agent():
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectApi(), MagicMock(), _APP, "pdf-toolkit")
response = raw(AgentDriveSkillInspectApi(), unbound_session, _APP, "pdf-toolkit")
assert response.get_json()["path"] == "pdf-toolkit"
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_list_400_when_no_agent_bound():
def test_list_400_when_no_agent_bound(unbound_session: Session):
raw = _raw(AgentDriveListApi.get)
resolver = MagicMock(return_value=None)
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
session = MagicMock()
with app.test_request_context("/"):
body, status = raw(AgentDriveListApi(), session, app_without_agent)
body, status = raw(AgentDriveListApi(), unbound_session, app_without_agent)
assert status == 400
assert body["code"] == "agent_not_bound"
resolver.assert_called_once_with(session=session)
resolver.assert_called_once_with(session=unbound_session)
def test_preview_passes_through_and_maps_errors():
def test_preview_passes_through_and_maps_errors(unbound_session: Session):
raw = _raw(AgentDrivePreviewApi.get)
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
@@ -248,21 +244,20 @@ def test_preview_passes_through_and_maps_errors():
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewApi(), MagicMock(), _APP)
body = raw(AgentDrivePreviewApi(), unbound_session, _APP)
assert body["text"] == "# hi"
with app.test_request_context("/?key=ghost/SKILL.md"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewApi(), MagicMock(), _APP)
body, status = raw(AgentDrivePreviewApi(), unbound_session, _APP)
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_preview_by_agent_passes_through_and_maps_errors():
def test_preview_by_agent_passes_through_and_maps_errors(unbound_session: Session):
raw = _raw(AgentDrivePreviewByAgentApi.get)
session = MagicMock()
with app.test_request_context("/?key=pdf-toolkit/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
@@ -275,10 +270,10 @@ def test_preview_by_agent_passes_through_and_maps_errors():
"binary": False,
"text": "# hi",
}
body = raw(AgentDrivePreviewByAgentApi(), session, "tenant-1", "agent-1")
body = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body["text"] == "# hi"
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.preview.call_args.kwargs["session"] is session
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.preview.call_args.kwargs["session"] is unbound_session
with app.test_request_context("/?key=ghost/SKILL.md"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP),
@@ -287,30 +282,29 @@ def test_preview_by_agent_passes_through_and_maps_errors():
drive.return_value.preview.side_effect = AgentDriveError(
"drive_key_not_found", "no drive entry", status_code=404
)
body, status = raw(AgentDrivePreviewByAgentApi(), session, "tenant-1", "agent-1")
body, status = raw(AgentDrivePreviewByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert status == 404
assert body["code"] == "drive_key_not_found"
def test_download_returns_signed_url_json():
def test_download_returns_signed_url_json(unbound_session: Session):
raw = _raw(AgentDriveDownloadApi.get)
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadApi(), MagicMock(), _APP)
body = raw(AgentDriveDownloadApi(), unbound_session, _APP)
assert body == {"url": "https://signed.example/zip"}
def test_download_by_agent_returns_signed_url_json():
def test_download_by_agent_returns_signed_url_json(unbound_session: Session):
raw = _raw(AgentDriveDownloadByAgentApi.get)
session = MagicMock()
with app.test_request_context("/?key=pdf-toolkit/.DIFY-SKILL-FULL.zip"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.download_url.return_value = "https://signed.example/zip"
body = raw(AgentDriveDownloadByAgentApi(), session, "tenant-1", "agent-1")
body = raw(AgentDriveDownloadByAgentApi(), unbound_session, "tenant-1", "agent-1")
assert body == {"url": "https://signed.example/zip"}
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.download_url.call_args.kwargs["session"] is session
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.download_url.call_args.kwargs["session"] is unbound_session
@@ -8,12 +8,15 @@ bare Flask request context with the services mocked — covering request handlin
from __future__ import annotations
import io
from datetime import UTC, datetime
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from controllers.console.app import agent as agent_controller
from controllers.console.app.agent import (
@@ -23,12 +26,16 @@ from controllers.console.app.agent import (
AgentSkillUploadApi,
AgentSkillUploadByAgentApi,
)
from models.model import AppMode
from extensions.storage.storage_type import StorageType
from models.enums import CreatorUserRole
from models.model import AppMode, UploadFile
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import AgentDriveError
_MOD = "controllers.console.app.agent"
app = Flask(__name__)
_TENANT_ID = "00000000-0000-0000-0000-000000000010"
_UPLOAD_FILE_ID = "0fa6f9bc-3416-4476-8857-a13129704dd9"
def _raw(method):
@@ -43,30 +50,49 @@ def _file_ctx(*, files: dict[str, bytes] | None = None):
_USER = SimpleNamespace(id="user-1")
_APP = SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
tenant_id=_TENANT_ID,
mode=AppMode.AGENT,
bound_agent_id_with_session=lambda *, session: "agent-1",
)
_WORKFLOW_APP = SimpleNamespace(
id="app-1",
tenant_id="tenant-1",
tenant_id=_TENANT_ID,
mode=AppMode.WORKFLOW,
bound_agent_id_with_session=lambda *, session: None,
)
def test_resolve_bound_agent_uses_injected_session():
session = MagicMock()
def _persist_upload(session: Session, *, name: str = "sample.pdf") -> UploadFile:
upload = UploadFile(
tenant_id=_TENANT_ID,
storage_type=StorageType.LOCAL,
key=f"uploads/{name}",
name=name,
size=5,
extension="pdf",
mime_type="application/pdf",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
created_at=datetime.now(UTC),
used=False,
)
upload.id = _UPLOAD_FILE_ID
session.add(upload)
session.commit()
return upload
def test_resolve_bound_agent_uses_injected_session(unbound_session: Session):
resolver = MagicMock(return_value="agent-1")
app_model = SimpleNamespace(bound_agent_id_with_session=resolver)
result = agent_controller._resolve_agent_id(session, app_model, None)
result = agent_controller._resolve_agent_id(unbound_session, app_model, None)
assert result == "agent-1"
resolver.assert_called_once_with(session=session)
assert resolver.call_args.kwargs["session"] is session
resolver.assert_called_once_with(session=unbound_session)
assert resolver.call_args.kwargs["session"] is unbound_session
def test_upload_standardizes_into_drive_and_returns_skill_ref():
def test_upload_standardizes_into_drive_and_returns_skill_ref(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"zip-bytes"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
@@ -74,61 +100,59 @@ def test_upload_standardizes_into_drive_and_returns_skill_ref():
"skill": {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"},
"manifest": {"name": "Skill A"},
}
body, status = raw(AgentSkillUploadApi(), MagicMock(), _USER, _APP)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 201
assert body["skill"] == {"path": "skill-a", "skill_md_key": "skill-a/SKILL.md"}
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_upload_by_agent_resolves_app_and_standardizes_into_drive():
def test_upload_by_agent_resolves_app_and_standardizes_into_drive(unbound_session: Session):
raw = _raw(AgentSkillUploadByAgentApi.post)
with _file_ctx(files={"file": b"zip-bytes"}):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillStandardizeService") as svc,
):
session = MagicMock()
svc.return_value.standardize.return_value = {"skill": {"path": "skill-a"}, "manifest": {}}
body, status = raw(AgentSkillUploadByAgentApi(), session, "tenant-1", _USER, "agent-1")
body, status = raw(AgentSkillUploadByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
assert status == 201
assert body["skill"] == {"path": "skill-a"}
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "agent-1"
def test_upload_no_file_is_400():
def test_upload_no_file_is_400(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={}):
body, status = raw(AgentSkillUploadApi(), MagicMock(), _USER, _APP)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 400
assert body["code"] == "no_file"
def test_upload_maps_package_error():
def test_upload_maps_package_error(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"bad"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
svc.return_value.standardize.side_effect = SkillPackageError(
"missing_skill_md", "no SKILL.md", status_code=400
)
body, status = raw(AgentSkillUploadApi(), MagicMock(), _USER, _APP)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 400
assert body["code"] == "missing_skill_md"
def test_upload_no_bound_agent_is_400():
def test_upload_no_bound_agent_is_400(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
resolver = MagicMock(return_value=None)
app_without_agent = SimpleNamespace(bound_agent_id_with_session=resolver)
session = MagicMock()
with _file_ctx(files={"file": b"zip"}):
body, status = raw(AgentSkillUploadApi(), session, _USER, app_without_agent)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, app_without_agent)
assert status == 400
assert body["code"] == "agent_not_bound"
resolver.assert_called_once_with(session=session)
resolver.assert_called_once_with(session=unbound_session)
def test_upload_resolves_workflow_node_agent():
def test_upload_resolves_workflow_node_agent(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with app.test_request_context(
"/?node_id=agent-node-1", method="POST", data={"file": (io.BytesIO(b"zip"), "skill.zip")}
@@ -136,18 +160,18 @@ def test_upload_resolves_workflow_node_agent():
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillStandardizeService") as svc:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.standardize.return_value = {"skill": {"path": "s"}, "manifest": {}}
body, status = raw(AgentSkillUploadApi(), MagicMock(), _USER, _WORKFLOW_APP)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _WORKFLOW_APP)
assert status == 201
assert body["skill"] == {"path": "s"}
assert svc.return_value.standardize.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_upload_maps_drive_error():
def test_upload_maps_drive_error(unbound_session: Session):
raw = _raw(AgentSkillUploadApi.post)
with _file_ctx(files={"file": b"zip"}):
with patch(f"{_MOD}.SkillStandardizeService") as svc:
svc.return_value.standardize.side_effect = AgentDriveError("source_not_found", "nope", status_code=404)
body, status = raw(AgentSkillUploadApi(), MagicMock(), _USER, _APP)
body, status = raw(AgentSkillUploadApi(), unbound_session, _USER, _APP)
assert status == 404
assert body["code"] == "source_not_found"
@@ -156,86 +180,81 @@ def _json_ctx(payload: dict | None = None, *, method: str = "POST", query_string
return app.test_request_context(f"/?{query_string}", method=method, json=payload or {})
def test_files_commit_validates_upload_and_returns_drive_ref():
def test_files_commit_validates_upload_and_returns_drive_ref(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
upload = SimpleNamespace(id="uf-1", name="sample qna.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
upload = _persist_upload(sqlite_session, name="sample qna.pdf")
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
with patch(f"{_MOD}.console_ns") as ns, patch(f"{_MOD}.AgentDriveService") as drive:
session = MagicMock()
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
session.scalar.return_value = upload
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
drive.return_value.commit.return_value = [
{"key": "files/sample qna.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesApi(), session, _USER, _APP)
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
assert status == 201
assert body["file"]["drive_key"] == "files/sample qna.pdf"
assert body["file"]["file_id"] == "uf-1"
assert body["file"]["file_id"] == upload.id
item = drive.return_value.commit.call_args.kwargs["items"][0]
assert item.value_owned_by_drive is True
assert item.file_ref.kind == "upload_file"
def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id():
def test_files_by_agent_commit_uses_agent_route_and_ignores_node_id(sqlite_session: Session):
raw = _raw(AgentDriveFilesByAgentApi.post)
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=ignored"):
_persist_upload(sqlite_session)
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.AgentDriveService") as drive,
):
session = MagicMock()
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
session.scalar.return_value = upload
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesByAgentApi(), session, "tenant-1", _USER, "agent-1")
body, status = raw(AgentDriveFilesByAgentApi(), sqlite_session, "tenant-1", _USER, "agent-1")
assert status == 201
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=sqlite_session, tenant_id="tenant-1", agent_id="agent-1")
def test_files_commit_404_when_upload_not_in_tenant():
def test_files_commit_404_when_upload_not_in_tenant(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}):
other_upload = _persist_upload(sqlite_session)
other_upload.tenant_id = str(uuid4())
sqlite_session.commit()
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}):
with patch(f"{_MOD}.console_ns") as ns:
session = MagicMock()
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
session.scalar.return_value = None
body, status = raw(AgentDriveFilesApi(), session, _USER, _APP)
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _APP)
assert status == 404
assert body["code"] == "upload_file_not_found"
def test_files_commit_resolves_workflow_node_agent():
def test_files_commit_resolves_workflow_node_agent(sqlite_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.post)
upload = SimpleNamespace(id="uf-1", name="sample.pdf")
with _json_ctx({"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}, query_string="node_id=agent-node-1"):
_persist_upload(sqlite_session)
with _json_ctx({"upload_file_id": _UPLOAD_FILE_ID}, query_string="node_id=agent-node-1"):
with (
patch(f"{_MOD}.console_ns") as ns,
patch(f"{_MOD}.AgentDriveService") as drive,
patch(f"{_MOD}.AgentComposerService") as composer,
):
session = MagicMock()
ns.payload = {"upload_file_id": "0fa6f9bc-3416-4476-8857-a13129704dd9"}
session.scalar.return_value = upload
ns.payload = {"upload_file_id": _UPLOAD_FILE_ID}
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
drive.return_value.commit.return_value = [
{"key": "files/sample.pdf", "size": 5, "mime_type": "application/pdf"}
]
body, status = raw(AgentDriveFilesApi(), session, _USER, _WORKFLOW_APP)
body, status = raw(AgentDriveFilesApi(), sqlite_session, _USER, _WORKFLOW_APP)
assert status == 201
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_files_delete_updates_soul_then_drive():
def test_files_delete_updates_soul_then_drive(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
@@ -245,26 +264,25 @@ def test_files_delete_updates_soul_then_drive():
drive.return_value.commit.side_effect = lambda **kw: (
calls.append("drive") or [{"key": "files/sample.pdf", "removed": True}]
)
body = raw(AgentDriveFilesApi(), MagicMock(), _USER, _APP)
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
assert calls == ["drive"]
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id():
def test_files_by_agent_delete_uses_agent_route_and_ignores_node_id(unbound_session: Session):
raw = _raw(AgentDriveFilesByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="key=files/sample.pdf&node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
session = MagicMock()
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
body = raw(AgentDriveFilesByAgentApi(), session, "tenant-1", _USER, "agent-1")
body = raw(AgentDriveFilesByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1")
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
def test_files_delete_resolves_workflow_node_agent():
def test_files_delete_resolves_workflow_node_agent(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
@@ -272,12 +290,12 @@ def test_files_delete_resolves_workflow_node_agent():
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.AgentDriveService") as drive:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
drive.return_value.commit.return_value = [{"key": "files/sample.pdf", "removed": True}]
body = raw(AgentDriveFilesApi(), MagicMock(), _USER, _WORKFLOW_APP)
body = raw(AgentDriveFilesApi(), unbound_session, _USER, _WORKFLOW_APP)
assert body == {"result": "success", "removed_keys": ["files/sample.pdf"]}
assert drive.return_value.commit.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_files_delete_survives_drive_failure():
def test_files_delete_survives_drive_failure(unbound_session: Session):
from controllers.console.app.agent import AgentDriveFilesApi
raw = _raw(AgentDriveFilesApi.delete)
@@ -285,10 +303,10 @@ def test_files_delete_survives_drive_failure():
with patch(f"{_MOD}.AgentDriveService") as drive:
drive.return_value.commit.side_effect = RuntimeError("storage down")
with pytest.raises(RuntimeError, match="storage down"):
raw(AgentDriveFilesApi(), MagicMock(), _USER, _APP)
raw(AgentDriveFilesApi(), unbound_session, _USER, _APP)
def test_skill_delete_uses_slug_prefix_and_is_idempotent():
def test_skill_delete_uses_slug_prefix_and_is_idempotent(unbound_session: Session):
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
@@ -298,38 +316,37 @@ def test_skill_delete_uses_slug_prefix_and_is_idempotent():
{"key": "tender-analyzer/SKILL.md", "removed": True},
{"key": "tender-analyzer/.DIFY-SKILL-FULL.zip", "removed": True},
]
body = raw(AgentSkillApi(), MagicMock(), _USER, _APP, "tender-analyzer")
body = raw(AgentSkillApi(), unbound_session, _USER, _APP, "tender-analyzer")
assert body == {
"result": "success",
"removed_keys": ["tender-analyzer/SKILL.md", "tender-analyzer/.DIFY-SKILL-FULL.zip"],
}
def test_skill_delete_by_agent_uses_agent_route():
def test_skill_delete_by_agent_uses_agent_route(unbound_session: Session):
raw = _raw(AgentSkillByAgentApi.delete)
with _json_ctx(method="DELETE", query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
session = MagicMock()
drive.return_value.commit.return_value = [{"key": "tender-analyzer/SKILL.md", "removed": True}]
body = raw(AgentSkillByAgentApi(), session, "tenant-1", _USER, "agent-1", "tender-analyzer")
body = raw(AgentSkillByAgentApi(), unbound_session, "tenant-1", _USER, "agent-1", "tender-analyzer")
assert body == {"result": "success", "removed_keys": ["tender-analyzer/SKILL.md"]}
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
def test_skill_delete_rejects_path_like_slug():
def test_skill_delete_rejects_path_like_slug(unbound_session: Session):
from controllers.console.app.agent import AgentSkillApi
raw = _raw(AgentSkillApi.delete)
with _json_ctx(method="DELETE"):
body, status = raw(AgentSkillApi(), MagicMock(), _USER, _APP, "a/b")
body, status = raw(AgentSkillApi(), unbound_session, _USER, _APP, "a/b")
assert status == 400
assert body["code"] == "drive_key_invalid"
def test_infer_tools_returns_draft_suggestions():
def test_infer_tools_returns_draft_suggestions(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
@@ -340,27 +357,32 @@ def test_infer_tools_returns_draft_suggestions():
"cli_tools": [{"name": "ffmpeg", "inferred_from": "audio-transcribe"}],
"reason": None,
}
body = raw(AgentSkillInferToolsApi(), MagicMock(), _APP, "audio-transcribe")
body = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
assert body["inferable"] is True
assert svc.return_value.infer.call_args.kwargs["slug"] == "audio-transcribe"
def test_infer_tools_by_agent_uses_agent_route():
def test_infer_tools_by_agent_uses_agent_route(unbound_session: Session):
raw = _raw(AgentSkillInferToolsByAgentApi.post)
with _json_ctx(query_string="node_id=ignored"):
with (
patch(f"{_MOD}.resolve_agent_runtime_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.SkillToolInferenceService") as svc,
):
session = MagicMock()
svc.return_value.infer.return_value = {"inferable": True, "cli_tools": [], "reason": None}
body = raw(AgentSkillInferToolsByAgentApi(), session, "tenant-1", "agent-1", "audio-transcribe")
body = raw(
AgentSkillInferToolsByAgentApi(),
unbound_session,
"tenant-1",
"agent-1",
"audio-transcribe",
)
assert body["inferable"] is True
resolve_app.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1")
resolve_app.assert_called_once_with(session=unbound_session, tenant_id="tenant-1", agent_id="agent-1")
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "agent-1"
def test_infer_tools_resolves_workflow_node_agent():
def test_infer_tools_resolves_workflow_node_agent(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
@@ -368,12 +390,12 @@ def test_infer_tools_resolves_workflow_node_agent():
with patch(f"{_MOD}.AgentComposerService") as composer, patch(f"{_MOD}.SkillToolInferenceService") as svc:
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-1"
svc.return_value.infer.return_value = {"inferable": False, "cli_tools": [], "reason": "none"}
body = raw(AgentSkillInferToolsApi(), MagicMock(), _WORKFLOW_APP, "audio-transcribe")
body = raw(AgentSkillInferToolsApi(), unbound_session, _WORKFLOW_APP, "audio-transcribe")
assert body["inferable"] is False
assert svc.return_value.infer.call_args.kwargs["agent_id"] == "wf-agent-1"
def test_infer_tools_maps_inference_errors():
def test_infer_tools_maps_inference_errors(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
from services.agent.skill_tool_inference_service import SkillToolInferenceError
@@ -383,19 +405,19 @@ def test_infer_tools_maps_inference_errors():
svc.return_value.infer.side_effect = SkillToolInferenceError(
"default_model_not_configured", "no model", status_code=400
)
body, status = raw(AgentSkillInferToolsApi(), MagicMock(), _APP, "audio-transcribe")
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "audio-transcribe")
assert status == 400
assert body["code"] == "default_model_not_configured"
def test_infer_tools_rejects_path_like_slug_and_unbound_app():
def test_infer_tools_rejects_path_like_slug_and_unbound_app(unbound_session: Session):
from controllers.console.app.agent import AgentSkillInferToolsApi
raw = _raw(AgentSkillInferToolsApi.post)
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), MagicMock(), _APP, "a/b")
body, status = raw(AgentSkillInferToolsApi(), unbound_session, _APP, "a/b")
assert (status, body["code"]) == (400, "drive_key_invalid")
app_without_agent = SimpleNamespace(bound_agent_id_with_session=MagicMock(return_value=None))
with _json_ctx():
body, status = raw(AgentSkillInferToolsApi(), MagicMock(), app_without_agent, "x")
body, status = raw(AgentSkillInferToolsApi(), unbound_session, app_without_agent, "x")
assert (status, body["code"]) == (400, "agent_not_bound")
@@ -2,18 +2,33 @@ from __future__ import annotations
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound
from controllers.console.app import annotation as annotation_module
from models.model import App, AppMode, IconType
from services.app_ref_service import AnnotationRef, AppRef
def _app_model() -> SimpleNamespace:
return SimpleNamespace(id="app-1", tenant_id="tenant-1", status="normal")
def _persist_app(session: Session) -> App:
app = App(
id="app-1",
tenant_id="tenant-1",
name="Annotation app",
mode=AppMode.CHAT,
icon_type=IconType.EMOJI,
icon="chat",
icon_background="#ffffff",
enable_site=False,
enable_api=True,
)
session.add(app)
session.commit()
return app
def _annotation_model(annotation_id: str = "ann-1") -> SimpleNamespace:
@@ -109,27 +124,25 @@ def test_annotation_file_payload_valid():
assert payload.message_id == "550e8400-e29b-41d4-a716-446655440000"
def test_get_app_ref_raises_not_found_when_app_is_not_in_current_tenant():
session = MagicMock()
session.scalar.return_value = None
def test_get_app_ref_raises_not_found_when_app_is_not_in_current_tenant(sqlite_session: Session):
_persist_app(sqlite_session)
with (
patch.object(
annotation_module,
"current_account_with_tenant",
return_value=(SimpleNamespace(id="account-1"), "tenant-1"),
return_value=(SimpleNamespace(id="account-1"), "tenant-2"),
),
):
with pytest.raises(NotFound):
annotation_module._get_app_ref(session, "app-1")
annotation_module._get_app_ref(sqlite_session, "app-1")
class TestConsoleAnnotationRefBoundaries:
def test_batch_delete_uses_app_ref(self, app: Flask):
def test_batch_delete_uses_app_ref(self, app: Flask, sqlite_session: Session):
api = annotation_module.AnnotationApi()
handler = unwrap(api.delete)
delete_mock = Mock()
session = MagicMock()
session.scalar.return_value = _app_model()
_persist_app(sqlite_session)
with (
app.test_request_context("/?annotation_id=ann-1&annotation_id=ann-2", method="DELETE"),
@@ -140,19 +153,18 @@ class TestConsoleAnnotationRefBoundaries:
),
patch.object(annotation_module.AppAnnotationService, "delete_app_annotations_in_batch", delete_mock),
):
response, status = handler(api, session, "app-1")
response, status = handler(api, sqlite_session, "app-1")
assert response == ""
assert status == 204
delete_mock.assert_called_once_with(AppRef("tenant-1", "app-1"), ["ann-1", "ann-2"], session)
delete_mock.assert_called_once_with(AppRef("tenant-1", "app-1"), ["ann-1", "ann-2"], sqlite_session)
def test_update_uses_annotation_ref(self, app: Flask):
def test_update_uses_annotation_ref(self, app: Flask, sqlite_session: Session):
api = annotation_module.AnnotationUpdateDeleteApi()
handler = unwrap(api.post)
update_mock = Mock(return_value=_annotation_model())
payload = {"question": "updated"}
session = MagicMock()
session.scalar.return_value = _app_model()
_persist_app(sqlite_session)
with (
app.test_request_context("/annotations/ann-1", method="POST", json=payload),
@@ -164,19 +176,18 @@ class TestConsoleAnnotationRefBoundaries:
),
patch.object(annotation_module.AppAnnotationService, "update_app_annotation_directly", update_mock),
):
response = handler(api, session, "app-1", "ann-1")
response = handler(api, sqlite_session, "app-1", "ann-1")
assert response["question"] == "q"
update_mock.assert_called_once()
assert update_mock.call_args.args[1] == AnnotationRef(AppRef("tenant-1", "app-1"), "ann-1")
assert update_mock.call_args.args[2] is session
assert update_mock.call_args.args[2] is sqlite_session
def test_delete_uses_annotation_ref(self, app: Flask):
def test_delete_uses_annotation_ref(self, app: Flask, sqlite_session: Session):
api = annotation_module.AnnotationUpdateDeleteApi()
handler = unwrap(api.delete)
delete_mock = Mock()
session = MagicMock()
session.scalar.return_value = _app_model()
_persist_app(sqlite_session)
with (
app.test_request_context("/annotations/ann-1", method="DELETE"),
@@ -187,15 +198,15 @@ class TestConsoleAnnotationRefBoundaries:
),
patch.object(annotation_module.AppAnnotationService, "delete_app_annotation", delete_mock),
):
response, status = handler(api, session, "app-1", "ann-1")
response, status = handler(api, sqlite_session, "app-1", "ann-1")
assert response == ""
assert status == 204
delete_mock.assert_called_once()
assert delete_mock.call_args.args[0] == AnnotationRef(AppRef("tenant-1", "app-1"), "ann-1")
assert delete_mock.call_args.args[1] is session
assert delete_mock.call_args.args[1] is sqlite_session
def test_hit_history_uses_annotation_ref(self, app: Flask):
def test_hit_history_uses_annotation_ref(self, app: Flask, sqlite_session: Session):
api = annotation_module.AnnotationHitHistoryListApi()
handler = unwrap(api.get)
history = SimpleNamespace(
@@ -208,8 +219,7 @@ class TestConsoleAnnotationRefBoundaries:
created_at=None,
)
hit_history_mock = Mock(return_value=([history], 1))
session = MagicMock()
session.scalar.return_value = _app_model()
_persist_app(sqlite_session)
with (
app.test_request_context("/hit-histories?page=2&limit=5", method="GET"),
@@ -220,7 +230,9 @@ class TestConsoleAnnotationRefBoundaries:
),
patch.object(annotation_module.AppAnnotationService, "get_annotation_hit_histories", hit_history_mock),
):
response = handler(api, session, "app-1", "ann-1")
response = handler(api, sqlite_session, "app-1", "ann-1")
assert response["total"] == 1
hit_history_mock.assert_called_once_with(AnnotationRef(AppRef("tenant-1", "app-1"), "ann-1"), 2, 5, session)
hit_history_mock.assert_called_once_with(
AnnotationRef(AppRef("tenant-1", "app-1"), "ann-1"), 2, 5, sqlite_session
)
@@ -209,7 +209,9 @@ class TestCompletionEndpoints:
class TestAppEndpoints:
def test_app_put_should_preserve_icon_type_when_payload_omits_it(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
def test_app_put_should_preserve_icon_type_when_payload_omits_it(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
):
api = app_module.AppApi()
method = unwrap(api.put)
payload = {
@@ -230,7 +232,7 @@ class TestAppEndpoints:
app.test_request_context("/console/api/apps/app-1", method="PUT", json=payload),
patch.object(type(console_ns), "payload", payload),
):
response = method(api, MagicMock(spec=Session), app_model=_make_app(icon_type=app_module.IconType.EMOJI))
response = method(api, unbound_session, app_model=_make_app(icon_type=app_module.IconType.EMOJI))
assert response == {"id": "app-1"}
assert app_service.update_app.call_args.args[1]["icon_type"] is None
@@ -247,7 +249,9 @@ class TestAppEndpoints:
}
)
def test_app_icon_post_should_forward_icon_type(self, app: Flask, monkeypatch: pytest.MonkeyPatch):
def test_app_icon_post_should_forward_icon_type(
self, app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
):
api = app_module.AppIconApi()
method = unwrap(api.post)
payload = {
@@ -267,7 +271,7 @@ class TestAppEndpoints:
app.test_request_context("/console/api/apps/app-1/icon", method="POST", json=payload),
patch.object(type(console_ns), "payload", payload),
):
response = method(api, MagicMock(spec=Session), app_model=_make_app())
response = method(api, unbound_session, app_model=_make_app())
assert response == {"id": "app-1"}
assert app_service.update_app_icon.call_args.args[1:] == (
@@ -1,6 +1,7 @@
from __future__ import annotations
import builtins
import json
import sys
from datetime import datetime
from importlib import util
@@ -12,9 +13,13 @@ import pytest
from flask import Flask
from flask.views import MethodView
from pydantic import ValidationError
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from werkzeug.datastructures import MultiDict
from configs import dify_config
from models.model import App, AppMode, IconType
from models.workflow import Workflow, WorkflowType
# kombu references MethodView as a global when importing celery/kombu pools.
if not hasattr(builtins, "MethodView"):
@@ -326,7 +331,7 @@ def test_app_list_query_accepts_single_repeated_tag_id(app_module):
assert query.tag_ids == [tag_id]
def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.MonkeyPatch):
def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.MonkeyPatch, unbound_session: Session):
payload = {"name": "Iris", "mode": "agent", "description": "Agent app"}
app_service = MagicMock()
monkeypatch.setattr(app_module, "AppService", lambda: app_service)
@@ -334,7 +339,7 @@ def test_create_app_endpoint_rejects_agent_mode(app_module, monkeypatch: pytest.
app_module.console_ns.payload = payload
try:
with pytest.raises(ValidationError):
_unwrap(app_module.AppListApi().post)(MagicMock(), "tenant-1", SimpleNamespace(id="account-1"))
_unwrap(app_module.AppListApi().post)(unbound_session, "tenant-1", SimpleNamespace(id="account-1"))
finally:
app_module.console_ns.payload = None
@@ -439,8 +444,9 @@ def test_app_detail_with_site_includes_nested_serialization(app_models):
assert "role" not in serialized
def test_app_response_view_uses_the_caller_session_for_query_backed_fields(app_module, monkeypatch):
session = MagicMock()
def test_app_response_view_uses_the_caller_session_for_query_backed_fields(
app_module, monkeypatch, unbound_session: Session
):
app_obj = MagicMock()
app_model_config = SimpleNamespace(app_id="app-1")
app_obj.desc_or_prompt_with_session.return_value = "Description"
@@ -455,7 +461,7 @@ def test_app_response_view_uses_the_caller_session_for_query_backed_fields(app_m
load_annotation_reply = MagicMock(return_value={"enabled": False})
monkeypatch.setattr("services.app_service.load_annotation_reply_config", load_annotation_reply)
view = app_module.AppResponseView(app_obj, session=session)
view = app_module.AppResponseView(app_obj, session=unbound_session)
site = view.site
workflow = view.workflow
model_config = view.app_model_config
@@ -483,8 +489,8 @@ def test_app_response_view_uses_the_caller_session_for_query_backed_fields(app_m
app_obj.tags_with_session,
app_obj.author_name_with_session,
):
method.assert_called_once_with(session=session)
load_annotation_reply.assert_called_once_with(session, "app-1")
method.assert_called_once_with(session=unbound_session)
load_annotation_reply.assert_called_once_with(unbound_session, "app-1")
def test_app_pagination_aliases_per_page_and_has_next(app_models):
@@ -529,7 +535,11 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
def test_app_list_uses_injected_session_for_draft_workflows(
app: Flask, app_module: ModuleType, monkeypatch: pytest.MonkeyPatch
app: Flask,
app_module: ModuleType,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
unbound_session: Session,
) -> None:
api = app_module.AppListApi()
method = _unwrap(api.get)
@@ -541,15 +551,20 @@ def test_app_list_uses_injected_session_for_draft_workflows(
mode_compatible_with_agent="workflow",
)
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
workflow = SimpleNamespace(
workflow = Workflow(
id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph=json.dumps({"nodes": [{"id": "trigger-1", "data": {"type": "trigger-webhook"}}], "edges": []}),
features=json.dumps({}),
created_by="user-1",
environment_variables=[],
conversation_variables=[],
)
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
scoped_session = MagicMock()
scoped_session.execute.side_effect = AssertionError("db.session should not be used")
sqlite_session.add(workflow)
sqlite_session.commit()
monkeypatch.setattr(
app_module,
@@ -578,20 +593,18 @@ def test_app_list_uses_injected_session_for_draft_workflows(
"get",
get_permissions,
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=unbound_session))
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
response, status = method("tenant-1", "user-1", session)
response, status = method("tenant-1", "user-1", sqlite_session)
assert status == 200
assert response["data"][0]["has_draft_trigger"] is True
session.execute.assert_called_once()
scoped_session.execute.assert_not_called()
get_permissions.assert_called_once_with("tenant-1", "user-1", session=session)
get_permissions.assert_called_once_with("tenant-1", "user-1", session=sqlite_session)
assert response["data"][0]["permission_keys"] == ["app.acl.edit"]
def test_app_create_api_attaches_permission_keys(app, app_module):
def test_app_create_api_attaches_permission_keys(app, app_module, unbound_session: Session):
method = app_module.AppListApi.post
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -637,7 +650,7 @@ def test_app_create_api_attaches_permission_keys(app, app_module):
replace_whitelist,
)
resp, status = method(app_module.AppListApi(), MagicMock(), "tenant-1", SimpleNamespace(id="acct-1"))
resp, status = method(app_module.AppListApi(), unbound_session, "tenant-1", SimpleNamespace(id="acct-1"))
assert status == 201
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
@@ -645,7 +658,7 @@ def test_app_create_api_attaches_permission_keys(app, app_module):
initialize_rbac_task.delay.assert_called_once_with("tenant-1", "acct-1", app_id="app-new")
def test_app_list_api_attaches_permission_keys(app, app_module):
def test_app_list_api_attaches_permission_keys(app, app_module, sqlite_session: Session):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -697,9 +710,7 @@ def test_app_list_api_attaches_permission_keys(app, app_module):
lambda tenant_id, account_id: SimpleNamespace(unrestricted=True, resource_ids=[]),
)
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = []
resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session)
resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", sqlite_session)
assert status == 200
params = get_paginate_apps.call_args.args[2]
@@ -708,7 +719,7 @@ def test_app_list_api_attaches_permission_keys(app, app_module):
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module):
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module, unbound_session: Session):
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -745,7 +756,7 @@ def test_recent_app_list_api_returns_only_home_card_fields(app, app_module):
),
)
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", unbound_session)
assert status == 200
assert resp == {
@@ -786,7 +797,7 @@ def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -
)
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module, unbound_session: Session):
method = app_module.RecentAppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -814,7 +825,7 @@ def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
),
)
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", unbound_session)
assert status == 200
assert resp == {"data": []}
@@ -823,7 +834,9 @@ def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
assert params.include_own_apps is True
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module):
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(
app, app_module, unbound_session: Session
):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -855,8 +868,7 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
session = MagicMock()
resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", session)
resp, status = method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
assert status == 200
assert resp["data"] == []
@@ -866,7 +878,9 @@ def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permis
assert params.is_created_by_me is None
def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(app, app_module):
def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
app, app_module, unbound_session: Session
):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -913,8 +927,7 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
session = MagicMock()
method(app_module.AppListApi(), "tenant-1", "acct-1", session)
method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
params = get_paginate_apps.call_args.args[2]
assert params.accessible_app_ids == ["app-acl-shared", "app-full", "app-shared", "app-whitelist-only"]
@@ -922,7 +935,9 @@ def test_app_list_api_limits_to_preview_overrides_without_manage_own_permission(
assert params.is_created_by_me is None
def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permission(app, app_module):
def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permission(
app, app_module, unbound_session: Session
):
method = app_module.AppListApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -950,8 +965,7 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
session = MagicMock()
method(app_module.AppListApi(), "tenant-1", "acct-1", session)
method(app_module.AppListApi(), "tenant-1", "acct-1", unbound_session)
params = get_paginate_apps.call_args.args[2]
assert params.accessible_app_ids == ["app-not-permitted"]
@@ -959,7 +973,7 @@ def test_app_list_api_returns_no_apps_without_workspace_or_resource_view_permiss
assert params.is_created_by_me is None
def test_app_detail_api_attaches_current_user_permission_keys(app, app_module):
def test_app_detail_api_attaches_current_user_permission_keys(app, app_module, unbound_session: Session):
method = app_module.AppApi.get
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
@@ -1002,40 +1016,40 @@ def test_app_detail_api_attaches_current_user_permission_keys(app, app_module):
get_permissions,
)
session = MagicMock()
resp = method(
app_module.AppApi(),
session,
unbound_session,
"tenant-1",
SimpleNamespace(id="acct-1"),
app_model=app_obj,
)
get_app.assert_called_once_with(app_obj, session=session)
get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1", session=session)
get_app.assert_called_once_with(app_obj, session=unbound_session)
get_permissions.assert_called_once_with("tenant-1", "acct-1", app_id="app-1", session=unbound_session)
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit", "app.acl.monitor"]
def test_app_copy_api_attaches_permission_keys(app, app_module):
def test_app_copy_api_attaches_permission_keys(app, app_module, sqlite_session: Session, sqlite_engine: Engine):
method = app_module.AppCopyApi.post
while hasattr(method, "__wrapped__"):
method = method.__wrapped__
app_obj = SimpleNamespace(
id="app-new",
app_obj = App(
id="00000000-0000-0000-0000-000000000101",
tenant_id="00000000-0000-0000-0000-000000000102",
name="Copied App",
description="Summary",
mode_compatible_with_agent="workflow",
mode=AppMode.WORKFLOW,
icon_type=IconType.EMOJI,
icon="copy",
icon_background="#ffffff",
enable_site=True,
enable_api=True,
permission_keys=[],
)
sqlite_session.add(app_obj)
sqlite_session.commit()
import_result = SimpleNamespace(status=app_module.ImportStatus.COMPLETED, app_id="app-new")
fake_session = MagicMock()
fake_session.__enter__.return_value = fake_session
fake_session.__exit__.return_value = None
fake_session.scalar.return_value = app_obj
import_result = SimpleNamespace(status=app_module.ImportStatus.COMPLETED, app_id=app_obj.id)
with app.test_request_context("/apps/app-original/copy", method="POST", json={}):
with pytest.MonkeyPatch.context() as monkeypatch:
@@ -1053,16 +1067,11 @@ def test_app_copy_api_attaches_permission_keys(app, app_module):
"get_system_features",
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=object(), session=lambda: MagicMock()))
monkeypatch.setattr(
app_module,
"Session",
lambda *_args, **_kwargs: fake_session,
)
monkeypatch.setattr(app_module, "db", SimpleNamespace(engine=sqlite_engine))
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppPermissions,
"batch_get",
lambda tenant_id, account_id, app_ids, session: {"app-new": ["app.acl.view_layout", "app.acl.edit"]},
lambda tenant_id, account_id, app_ids, session: {app_obj.id: ["app.acl.view_layout", "app.acl.edit"]},
)
resp, status = method(
@@ -1073,5 +1082,5 @@ def test_app_copy_api_attaches_permission_keys(app, app_module):
)
assert status == 201
assert fake_session.scalar.called
assert sqlite_session.get(App, app_obj.id) is not None
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
@@ -6,10 +6,13 @@ from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, NotFound
from controllers.console.app import conversation as conversation_module
from models.model import AppMode
from core.app.entities.app_invoke_entities import InvokeFrom
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation
from services.errors.conversation import ConversationNotExistsError
@@ -17,7 +20,32 @@ def _make_account():
return SimpleNamespace(timezone="UTC", id="u1")
def test_completion_conversation_list_returns_paginated_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def _conversation(*, conversation_id: str = "c1", app_id: str = "app-1") -> Conversation:
conversation = Conversation(
app_id=app_id,
app_model_config_id=None,
model_provider=None,
override_model_configs=None,
model_id=None,
mode=AppMode.CHAT,
name="Conversation",
inputs={},
introduction="",
system_instruction="",
system_instruction_tokens=0,
status="normal",
invoke_from=InvokeFrom.EXPLORE,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="u1",
)
conversation.id = conversation_id
return conversation
def test_completion_conversation_list_returns_paginated_result(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationApi()
method = unwrap(api.get)
account = _make_account()
@@ -30,11 +58,13 @@ def test_completion_conversation_list_returns_paginated_result(app: Flask, monke
paginate_result.items = []
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/completion-conversations", method="GET"):
response = method(api, MagicMock(), account, app_model=SimpleNamespace(id="app-1"))
response = method(api, unbound_session, account, app_model=SimpleNamespace(id="app-1"))
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_completion_conversation_list_invalid_time_range(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_completion_conversation_list_invalid_time_range(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationApi()
method = unwrap(api.get)
account = _make_account()
@@ -47,10 +77,12 @@ def test_completion_conversation_list_invalid_time_range(app: Flask, monkeypatch
"/console/api/apps/app-1/completion-conversations", method="GET", query_string={"start": "bad"}
):
with pytest.raises(BadRequest):
method(api, MagicMock(), account, app_model=SimpleNamespace(id="app-1"))
method(api, unbound_session, account, app_model=SimpleNamespace(id="app-1"))
def test_chat_conversation_list_advanced_chat_calls_paginate(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
def test_chat_conversation_list_advanced_chat_calls_paginate(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.ChatConversationApi()
method = unwrap(api.get)
account = _make_account()
@@ -63,30 +95,31 @@ def test_chat_conversation_list_advanced_chat_calls_paginate(app: Flask, monkeyp
paginate_result.items = []
monkeypatch.setattr(conversation_module, "paginate_query", lambda *_args, **_kwargs: paginate_result)
with app.test_request_context("/console/api/apps/app-1/chat-conversations", method="GET"):
response = method(api, MagicMock(), account, app_model=SimpleNamespace(id="app-1", mode=AppMode.ADVANCED_CHAT))
response = method(
api, unbound_session, account, app_model=SimpleNamespace(id="app-1", mode=AppMode.ADVANCED_CHAT)
)
assert response == {"page": 1, "limit": 20, "total": 0, "has_more": False, "data": []}
def test_get_conversation_updates_read_at(monkeypatch: pytest.MonkeyPatch) -> None:
conversation = SimpleNamespace(id="c1", app_id="app-1")
session = MagicMock()
session.scalar.return_value = conversation
def test_get_conversation_updates_read_at(sqlite_session: Session) -> None:
conversation = _conversation()
sqlite_session.add(conversation)
sqlite_session.flush()
session = sqlite_session
result = conversation_module._get_conversation(session, _make_account(), SimpleNamespace(id="app-1"), "c1")
assert result is conversation
session.execute.assert_called_once()
session.flush.assert_called_once()
session.refresh.assert_called_once_with(conversation)
assert conversation.read_at is not None
assert conversation.read_account_id == "u1"
def test_get_conversation_missing_raises_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
session = MagicMock()
session.scalar.return_value = None
def test_get_conversation_missing_raises_not_found(sqlite_session: Session) -> None:
session = sqlite_session
with pytest.raises(NotFound):
conversation_module._get_conversation(session, _make_account(), SimpleNamespace(id="app-1"), "missing")
def test_conversation_response_source_uses_caller_session() -> None:
session = MagicMock()
def test_conversation_response_source_uses_caller_session(unbound_session: Session) -> None:
session = unbound_session
account = object()
annotation = MagicMock()
annotation.account_with_session.return_value = account
@@ -136,7 +169,9 @@ def test_conversation_response_source_uses_caller_session() -> None:
annotation.account_with_session.assert_called_once_with(session=session)
def test_completion_conversation_delete_maps_not_found(monkeypatch: pytest.MonkeyPatch) -> None:
def test_completion_conversation_delete_maps_not_found(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
api = conversation_module.CompletionConversationDetailApi()
method = unwrap(api.delete)
monkeypatch.setattr(
@@ -144,6 +179,6 @@ def test_completion_conversation_delete_maps_not_found(monkeypatch: pytest.Monke
"delete",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ConversationNotExistsError()),
)
session = MagicMock()
session = unbound_session
with pytest.raises(NotFound):
method(api, session, _make_account(), app_model=SimpleNamespace(id="app-1"), conversation_id="c1")
@@ -16,7 +16,6 @@ from graphon.variables.types import SegmentType
from models import ConversationVariable
@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True)
def test_get_conversation_variables_returns_paginated_response(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
@@ -129,7 +129,6 @@ class TestAppMCPServerResponse:
class TestAppMCPServerController:
@pytest.mark.parametrize("sqlite_session", [(AppMCPServer,)], indirect=True)
def test_get_returns_empty_dict_when_server_missing(self, sqlite_session: Session) -> None:
api = AppMCPServerController()
method = unwrap(api.get)
@@ -139,7 +138,6 @@ class TestAppMCPServerController:
assert response == {}
@pytest.mark.parametrize("sqlite_session", [(AppMCPServer,)], indirect=True)
def test_post_returns_201(self, sqlite_session: Session) -> None:
api = AppMCPServerController()
method = unwrap(api.post)
@@ -163,7 +161,6 @@ class TestAppMCPServerController:
assert response["parameters"] == {"timeout": 30}
assert status_code == 201
@pytest.mark.parametrize("sqlite_session", [(AppMCPServer,)], indirect=True)
def test_put_updates_server_for_app(self, sqlite_session: Session) -> None:
api = AppMCPServerController()
method = unwrap(api.put)
@@ -193,7 +190,6 @@ class TestAppMCPServerController:
assert response["id"] == "server-1"
assert updated_server.description == "Updated"
@pytest.mark.parametrize("sqlite_session", [(AppMCPServer,)], indirect=True)
@pytest.mark.parametrize(
("foreign_tenant_id", "foreign_app_id"),
[
@@ -7,12 +7,67 @@ from unittest.mock import MagicMock
import pytest
from flask import Flask
from sqlalchemy import event
from sqlalchemy.orm import Session
from controllers.console.app import message as message_module
from core.app.entities.app_invoke_entities import InvokeFrom
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation, Message
def test_app_message_routes_pass_injected_session(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
session = MagicMock()
def _persist_message(session: Session, *, message_id: str, app_id: str = "app-1") -> Message:
conversation = Conversation(
app_id=app_id,
app_model_config_id=None,
model_provider=None,
override_model_configs=None,
model_id=None,
mode=AppMode.CHAT,
name="Conversation",
inputs={},
introduction="",
system_instruction="",
system_instruction_tokens=0,
status="normal",
invoke_from=InvokeFrom.DEBUGGER,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="account-1",
)
conversation.id = "conversation-1"
message = Message(
app_id=app_id,
conversation_id=conversation.id,
inputs={},
query="query",
message="",
message_tokens=0,
message_unit_price=0,
message_price_unit=0,
answer="answer",
answer_tokens=0,
answer_unit_price=0,
answer_price_unit=0,
provider_response_latency=0,
total_price=0,
currency="USD",
invoke_from=InvokeFrom.DEBUGGER,
from_source=ConversationFromSource.CONSOLE,
from_end_user_id=None,
from_account_id="account-1",
app_mode=AppMode.CHAT,
)
message.id = message_id
session.add_all([conversation, message])
session.flush()
return message
def test_app_message_routes_pass_injected_session(
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
session = unbound_session
current_user = SimpleNamespace(id="account-1")
app_model = SimpleNamespace(id="app-1", mode="chat")
message_id = "550e8400-e29b-41d4-a716-446655440000"
@@ -44,17 +99,15 @@ def test_app_message_routes_pass_injected_session(app: Flask, monkeypatch: pytes
assert get_message_detail.call_args.kwargs["session"] is session
def test_update_message_feedback_commits_injected_session(app: Flask) -> None:
def test_update_message_feedback_commits_injected_session(app: Flask, sqlite_session: Session) -> None:
message_id = "550e8400-e29b-41d4-a716-446655440000"
feedback = SimpleNamespace(rating="dislike", content=None)
get_admin_feedback = MagicMock(return_value=feedback)
message = SimpleNamespace(
id=message_id,
conversation_id="conversation-1",
admin_feedback_with_session=get_admin_feedback,
)
session = MagicMock()
session.scalar.return_value = message
message = _persist_message(sqlite_session, message_id=message_id)
message.admin_feedback_with_session = get_admin_feedback
session = sqlite_session
commits: list[str] = []
event.listen(session, "after_commit", lambda _session: commits.append("commit"))
with app.test_request_context(json={"message_id": message_id, "rating": "like", "content": "helpful"}):
result = message_module._update_message_feedback(
@@ -67,16 +120,15 @@ def test_update_message_feedback_commits_injected_session(app: Flask) -> None:
assert feedback.rating == "like"
assert feedback.content == "helpful"
get_admin_feedback.assert_called_once_with(session=session)
session.commit.assert_called_once_with()
assert commits == ["commit"]
def test_get_message_detail_uses_injected_session(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_message_detail_uses_injected_session(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
message_id = "550e8400-e29b-41d4-a716-446655440000"
message = SimpleNamespace(id=message_id)
message = _persist_message(sqlite_session, message_id=message_id)
response_source = object()
response_source_factory = MagicMock(return_value=response_source)
session = MagicMock()
session.scalar.return_value = message
session = sqlite_session
monkeypatch.setattr(message_module, "attach_message_extra_contents", MagicMock())
monkeypatch.setattr(message_module, "MessageResponseSource", response_source_factory)
monkeypatch.setattr(message_module, "dump_response", lambda _model, value: value)
@@ -89,11 +141,10 @@ def test_get_message_detail_uses_injected_session(monkeypatch: pytest.MonkeyPatc
assert result is response_source
response_source_factory.assert_called_once_with(message, session=session)
session.scalar.assert_called_once()
def test_message_response_source_uses_caller_session_for_nested_fields() -> None:
session = MagicMock()
def test_message_response_source_uses_caller_session_for_nested_fields(unbound_session: Session) -> None:
session = unbound_session
account = object()
feedback = MagicMock()
feedback.from_account_with_session.return_value = account
@@ -724,6 +724,7 @@ def test_advanced_chat_run_conversation_not_exists(app: Flask, monkeypatch: pyte
def test_trigger_run_loads_draft_with_request_session(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
unbound_session: Session,
resource: type,
payload: dict[str, object],
) -> None:
@@ -733,7 +734,7 @@ def test_trigger_run_loads_draft_with_request_session(
"WorkflowService",
lambda: SimpleNamespace(get_draft_workflow=get_draft_workflow),
)
session = Mock()
session = unbound_session
app_model = SimpleNamespace(id="app-1")
handler = inspect.unwrap(resource.post)
@@ -7,7 +7,6 @@ from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy import Select
from sqlalchemy.orm import Session
from controllers.common import session as session_module
@@ -33,7 +32,6 @@ def _persist_app(sqlite_session: Session, *, mode: AppMode = AppMode.CHAT) -> Ap
return app_model
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
app_model = _persist_app(sqlite_session)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, app_model.tenant_id))
@@ -46,7 +44,6 @@ def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch, sqlite_ses
assert handler(app_id=app_model.id) == app_model.id
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
app_model = _persist_app(sqlite_session)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, app_model.tenant_id))
@@ -60,17 +57,10 @@ def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch, sqlit
handler(app_id=app_model.id)
def test_get_app_model_with_trial_requires_trial_app_registration(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
session = MagicMock(spec=Session)
def scalar(statement: Select[tuple[App]]) -> object | None:
has_trial_app_join = any(
from_clause.is_derived_from(TrialApp.__table__) for from_clause in statement.get_final_froms()
)
return None if has_trial_app_join else app_model
monkeypatch.setattr(session, "scalar", scalar)
def test_get_app_model_with_trial_requires_trial_app_registration(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
app_model = _persist_app(sqlite_session)
recommended_get_app = MagicMock(return_value=None)
monkeypatch.setattr(wraps_module.RecommendedAppService, "get_app", recommended_get_app)
@@ -80,14 +70,15 @@ def test_get_app_model_with_trial_requires_trial_app_registration(monkeypatch: p
return app_model.id
with pytest.raises(AppNotFoundError):
Handler().get(session, app_id="app-1")
Handler().get(sqlite_session, app_id=app_model.id)
recommended_get_app.assert_called_once_with("app-1", session=session)
recommended_get_app.assert_called_once_with(app_model.id, session=sqlite_session)
def test_get_app_model_with_trial_falls_back_to_recommended_app(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_app_model_with_trial_falls_back_to_recommended_app(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
session = MagicMock(spec=Session)
trial_app_loader = MagicMock(return_value=None)
recommended_get_app = MagicMock(return_value=app_model)
monkeypatch.setattr(wraps_module, "_load_app_model_with_trial", trial_app_loader)
@@ -98,14 +89,15 @@ def test_get_app_model_with_trial_falls_back_to_recommended_app(monkeypatch: pyt
def get(self, _injected_session, app_model):
return app_model.id
assert Handler().get(session, app_id="app-1") == "app-1"
trial_app_loader.assert_called_once_with(session, "app-1")
recommended_get_app.assert_called_once_with("app-1", session=session)
assert Handler().get(unbound_session, app_id="app-1") == "app-1"
trial_app_loader.assert_called_once_with(unbound_session, "app-1")
recommended_get_app.assert_called_once_with("app-1", session=unbound_session)
def test_get_app_model_with_trial_prefers_trial_registration(monkeypatch: pytest.MonkeyPatch) -> None:
def test_get_app_model_with_trial_prefers_trial_registration(
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
session = MagicMock(spec=Session)
trial_app_loader = MagicMock(return_value=app_model)
recommended_get_app = MagicMock()
monkeypatch.setattr(wraps_module, "_load_app_model_with_trial", trial_app_loader)
@@ -116,8 +108,8 @@ def test_get_app_model_with_trial_prefers_trial_registration(monkeypatch: pytest
def get(self, _injected_session, app_model):
return app_model.id
assert Handler().get(session, app_id="app-1") == "app-1"
trial_app_loader.assert_called_once_with(session, "app-1")
assert Handler().get(unbound_session, app_id="app-1") == "app-1"
trial_app_loader.assert_called_once_with(unbound_session, "app-1")
recommended_get_app.assert_not_called()
@@ -134,7 +126,6 @@ def test_wraps_with_session_reexports_common_session_decorator() -> None:
assert wraps_module.with_session is with_session
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_prefers_injected_session(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
@@ -154,26 +145,27 @@ def test_get_app_model_prefers_injected_session(
assert Handler().get(sqlite_session, app_id=app_model.id) == app_model.id
def test_get_app_model_with_trial_prefers_injected_session(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal")
session = MagicMock(spec=Session)
session.scalar.return_value = app_model
def test_get_app_model_with_trial_prefers_injected_session(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
app_model = _persist_app(sqlite_session)
sqlite_session.add(TrialApp(app_id=app_model.id, tenant_id=app_model.tenant_id))
sqlite_session.commit()
monkeypatch.setattr(
wraps_module.db,
"session",
SimpleNamespace(scalar=lambda *_args, **_kwargs: pytest.fail("db.session should not be used")),
)
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: nullcontext(session))
monkeypatch.setattr(session_module.session_factory, "create_session", lambda: nullcontext(sqlite_session))
class Handler:
@with_session(write=False)
@wraps_module.get_app_model_with_trial(None)
def get(self, injected_session, app_model):
assert injected_session is session
assert injected_session is sqlite_session
return app_model.id
assert Handler().get(app_id="app-1") == "app-1"
session.scalar.assert_called_once()
assert Handler().get(app_id=app_model.id) == app_model.id
def test_get_app_model_with_trial_requires_injected_session() -> None: