diff --git a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py index 6d90aa7e53b..c41c33487eb 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py @@ -4,12 +4,16 @@ from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.apps.base_app_runner import AppRunner from core.app.entities.app_invoke_entities import InvokeFrom from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from models.enums import CreatorUserRole +from models.model import MessageFile +from models.tools import ToolFile class TestBaseAppRunnerMultimodal: @@ -38,18 +42,18 @@ class TestBaseAppRunnerMultimodal: return manager @pytest.fixture - def mock_tool_file(self): - """Create a mock tool file.""" - tool_file = MagicMock() - tool_file.id = str(uuid4()) - return tool_file - - @pytest.fixture - def mock_message_file(self): - """Create a mock message file.""" - message_file = MagicMock() - message_file.id = str(uuid4()) - return message_file + def tool_file(self, mock_user_id: str, mock_tenant_id: str) -> ToolFile: + """Create a real transient tool-file model returned by the external file manager.""" + return ToolFile( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + conversation_id=None, + file_key="generated/image.png", + mimetype="image/png", + original_url="http://example.com/image.png", + name="image.png", + size=68, + ) def test_handle_multimodal_image_content_with_url( self, @@ -57,8 +61,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from URL.""" # Arrange @@ -72,48 +76,33 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - # Setup mock message file - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - # Act - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - # Assert - mock_mgr.create_file_by_url.assert_called_once_with( - user_id=mock_user_id, - tenant_id=mock_tenant_id, - file_url=image_url, - conversation_id=None, - ) - - mock_msg_file_class.assert_called_once() - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["message_id"] == mock_message_id - assert call_kwargs["type"] == FileType.IMAGE - assert call_kwargs["transfer_method"] == FileTransferMethod.TOOL_FILE - assert call_kwargs["belongs_to"] == "assistant" - assert call_kwargs["created_by"] == mock_user_id - - file_session.add.assert_called_once_with(mock_message_file) - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_url.assert_called_once_with( + user_id=mock_user_id, + tenant_id=mock_tenant_id, + file_url=image_url, + conversation_id=None, + ) + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.message_id == mock_message_id + assert message_file.type == FileType.IMAGE + assert message_file.transfer_method == FileTransferMethod.TOOL_FILE + assert message_file.belongs_to == "assistant" + assert message_file.created_by == mock_user_id + assert message_file.upload_file_id == tool_file.id + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64( self, @@ -121,8 +110,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data.""" # Arrange @@ -141,41 +130,29 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - message_file_id = runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert call_kwargs["user_id"] == mock_user_id - assert call_kwargs["tenant_id"] == mock_tenant_id - assert call_kwargs["conversation_id"] is None - assert "file_binary" in call_kwargs - assert call_kwargs["mimetype"] == "image/png" - assert call_kwargs["filename"].startswith("generated_image") - assert call_kwargs["filename"].endswith(".png") - - mock_msg_file_class.assert_called_once() - file_session.add.assert_called_once() - file_session.flush.assert_called_once() - assert message_file_id == mock_message_file.id - mock_queue_manager.publish.assert_not_called() + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert call_kwargs["user_id"] == mock_user_id + assert call_kwargs["tenant_id"] == mock_tenant_id + assert call_kwargs["conversation_id"] is None + assert "file_binary" in call_kwargs + assert call_kwargs["mimetype"] == "image/png" + assert call_kwargs["filename"].startswith("generated_image") + assert call_kwargs["filename"].endswith(".png") + assert sqlite_session.get(MessageFile, message_file_id) is not None + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_base64_data_uri( self, @@ -183,8 +160,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test handling image from base64 data with URI prefix.""" # Arrange @@ -201,29 +178,22 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() - mock_mgr.create_file_by_raw.return_value = mock_tool_file + mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr.create_file_by_raw.assert_called_once() - call_kwargs = mock_mgr.create_file_by_raw.call_args[1] - assert "file_binary" in call_kwargs + mock_mgr.create_file_by_raw.assert_called_once() + call_kwargs = mock_mgr.create_file_by_raw.call_args[1] + assert "file_binary" in call_kwargs + assert sqlite_session.get(MessageFile, message_file_id) is not None def test_handle_multimodal_image_content_without_url_or_base64( self, @@ -231,6 +201,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content without URL or base64 data.""" # Arrange @@ -242,24 +213,19 @@ class TestBaseAppRunnerMultimodal: ) with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_mgr_class.assert_not_called() - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_mgr_class.assert_not_called() + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_error( self, @@ -267,6 +233,7 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, + sqlite_session: Session, ): """Test handling image content when an error occurs.""" # Arrange @@ -282,23 +249,18 @@ class TestBaseAppRunnerMultimodal: mock_mgr.create_file_by_url.side_effect = Exception("Network error") mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) + result = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - mock_msg_file_class.assert_not_called() - mock_queue_manager.publish.assert_not_called() + assert result is None + assert sqlite_session.scalar(select(func.count()).select_from(MessageFile)) == 0 + mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_debugger_mode( self, @@ -306,8 +268,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that debugger mode sets correct created_by_role.""" # Arrange @@ -321,28 +283,21 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.ACCOUNT + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.ACCOUNT def test_handle_multimodal_image_content_service_api_mode( self, @@ -350,8 +305,8 @@ class TestBaseAppRunnerMultimodal: mock_tenant_id, mock_message_id, mock_queue_manager, - mock_tool_file, - mock_message_file, + tool_file, + sqlite_session: Session, ): """Test that service API mode sets correct created_by_role.""" # Arrange @@ -365,25 +320,18 @@ class TestBaseAppRunnerMultimodal: with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() - mock_mgr.create_file_by_url.return_value = mock_tool_file + mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - with patch("core.app.apps.base_app_runner.MessageFile", autospec=True) as mock_msg_file_class: - mock_msg_file_class.return_value = mock_message_file + message_file_id = AppRunner()._handle_multimodal_image_content( + session=sqlite_session, + content=content, + message_id=mock_message_id, + user_id=mock_user_id, + tenant_id=mock_tenant_id, + queue_manager=mock_queue_manager, + ) - file_session = MagicMock() - runner = MagicMock() - method = AppRunner._handle_multimodal_image_content - runner._handle_multimodal_image_content = lambda *args, **kwargs: method(runner, *args, **kwargs) - - runner._handle_multimodal_image_content( - session=file_session, - content=content, - message_id=mock_message_id, - user_id=mock_user_id, - tenant_id=mock_tenant_id, - queue_manager=mock_queue_manager, - ) - - call_kwargs = mock_msg_file_class.call_args[1] - assert call_kwargs["created_by_role"] == CreatorUserRole.END_USER + message_file = sqlite_session.get(MessageFile, message_file_id) + assert message_file is not None + assert message_file.created_by_role == CreatorUserRole.END_USER diff --git a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py index 09f0d9dc4ff..bdd418e47e4 100644 --- a/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py +++ b/api/tests/unit_tests/extensions/otel/test_retrieval_tracing.py @@ -1,10 +1,11 @@ import threading from collections.abc import Callable -from unittest.mock import MagicMock, patch +from unittest.mock import patch from uuid import uuid4 import pytest from opentelemetry.trace import StatusCode, get_current_span, get_tracer +from sqlalchemy.orm import Session from core.rag.rerank.rerank_type import RerankMode from core.rag.retrieval.dataset_retrieval import DatasetRetrieval @@ -20,6 +21,7 @@ def _otel_enabled(config_overrides: Callable[..., None]) -> None: def test_knowledge_retrieval_creates_a_child_otel_span( memory_span_exporter, tracer_provider_with_memory_exporter, + sqlite_session: Session, ) -> None: """The retrieval entry point must be visible beneath its workflow node span.""" request = KnowledgeRetrievalRequest( @@ -38,7 +40,7 @@ def test_knowledge_retrieval_creates_a_child_otel_span( patch.object(retrieval, "_get_available_datasets", return_value=[]), get_tracer(__name__).start_as_current_span("knowledge-retrieval-node") as node_span, ): - assert retrieval.knowledge_retrieval(MagicMock(), request) == [] + assert retrieval.knowledge_retrieval(sqlite_session, request) == [] retrieval_span = next( span @@ -101,7 +103,6 @@ def test_retriever_thread_exception_sets_error_span_and_is_collected( expected_error = RuntimeError("retrieval failed") with ( - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), ): retrieval._run_retriever_thread_safely( @@ -139,7 +140,6 @@ def test_retriever_thread_exception_emits_skip_event_when_requested( dataset_id = str(uuid4()) with ( - patch("core.rag.retrieval.dataset_retrieval.session_factory.create_session"), patch.object(retrieval, "_retriever", side_effect=expected_error), get_tracer(__name__).start_as_current_span("dataset-retrieval-parent") as parent_span, ): diff --git a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py index 50ca483b976..76dc9f584d2 100644 --- a/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py +++ b/api/tests/unit_tests/services/test_dataset_service_lock_not_owned.py @@ -1,5 +1,5 @@ import types -from unittest.mock import Mock, create_autospec +from unittest.mock import Mock import pytest from redis.exceptions import LockNotOwnedError @@ -203,19 +203,48 @@ def test_add_segment_ignores_lock_not_owned( # --------------------------------------------------------------------------- +@pytest.mark.parametrize("sqlite_session", [(Account, Tenant, Dataset, Document, DocumentSegment)], indirect=True) def test_multi_create_segment_ignores_lock_not_owned( monkeypatch: pytest.MonkeyPatch, fake_current_user, fake_lock, + sqlite_session: Session, ): # Arrange - dataset = create_autospec(Dataset, instance=True) - dataset.id = "ds-1" - dataset.tenant_id = fake_current_user.current_tenant_id - dataset.indexing_technique = IndexTechniqueType.ECONOMY # again, skip high_quality path + dataset = Dataset( + id=DATASET_ID, + tenant_id=TENANT_ID, + name="Test Dataset", + description="", + created_by=USER_ID, + indexing_technique=IndexTechniqueType.ECONOMY, + ) + document = Document( + id=DOCUMENT_ID, + tenant_id=TENANT_ID, + dataset_id=DATASET_ID, + position=1, + data_source_type="upload_file", + data_source_info="{}", + batch="batch-1", + name="Test Document", + created_from="web", + created_by=USER_ID, + word_count=0, + doc_form=IndexStructureType.QA_INDEX, + ) + sqlite_session.add_all([fake_current_user._current_tenant, fake_current_user, dataset, document]) + sqlite_session.commit() - document = create_autospec(Document, instance=True) - document.id = "doc-1" - document.dataset_id = dataset.id - document.word_count = 0 - document.doc_form = IndexStructureType.QA_INDEX + result = SegmentService.multi_create_segment( + segments=[{"content": "question", "answer": "answer", "keywords": ["key"]}], + document=document, + dataset=dataset, + session=sqlite_session, + ) + + assert result is None + assert not sqlite_session.in_transaction() + assert sqlite_session.scalar(select(func.count(DocumentSegment.id))) == 0 + sqlite_session.refresh(document) + assert document.word_count == 0 diff --git a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py index 684ee06edf3..c9d20849af7 100644 --- a/api/tests/unit_tests/tasks/test_resume_agent_app_task.py +++ b/api/tests/unit_tests/tasks/test_resume_agent_app_task.py @@ -1,154 +1,260 @@ -"""Unit tests for the ``resume_agent_app_execution`` celery task (ENG-635). - -Every DB access (``db.session.get``) and the generator are patched at the module -level, so the task's branch logic is exercised without a database or live stack. -""" +"""Unit tests for the ``resume_agent_app_execution`` Celery task (ENG-635).""" from __future__ import annotations -from unittest.mock import MagicMock +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from uuid import uuid4 +import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session, scoped_session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom -from models.account import Account +from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus +from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole +from models.enums import ConversationFromSource, EndUserType +from models.enums import InvokeFrom as StoredInvokeFrom from models.human_input import HumanInputForm -from models.model import App, Conversation, EndUser +from models.model import App, AppMode, Conversation, EndUser from tasks.app_generate import resume_agent_app_task as mod MODULE = "tasks.app_generate.resume_agent_app_task" -def _form(conversation_id: str = "conv-1", app_id: str = "app-1") -> MagicMock: - return MagicMock(conversation_id=conversation_id, app_id=app_id) +@pytest.fixture +def task_session(mocker: MockerFixture, sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]: + """Bind the task's Flask-SQLAlchemy session proxy to the shared SQLite database.""" + registry = scoped_session(sqlite_session_factory) + mocker.patch.object(mod.db, "session", registry) + session = registry() + yield session + registry.remove() -def _wire_db( - mocker: MockerFixture, +def _app(*, app_id: str, tenant_id: str) -> App: + return App( + id=app_id, + tenant_id=tenant_id, + name="Agent app", + description="", + mode=AppMode.AGENT_CHAT, + icon_type=None, + icon=None, + icon_background=None, + enable_site=False, + enable_api=False, + ) + + +def _conversation( *, - form=None, - app=None, - conversation=None, - account=None, - end_user=None, -) -> MagicMock: - """Patch the module ``db`` so ``db.session.get(Model, id)`` dispatches by model.""" - table = { - HumanInputForm: form, - App: app, - Conversation: conversation, - Account: account, - EndUser: end_user, - } - db = mocker.patch(f"{MODULE}.db") - db.session.get.side_effect = lambda model, _id: table.get(model) - return db + conversation_id: str, + app_id: str, + account_id: str | None = None, + end_user_id: str | None = None, + invoke_from: StoredInvokeFrom = StoredInvokeFrom.WEB_APP, +) -> Conversation: + return Conversation( + id=conversation_id, + app_id=app_id, + mode=AppMode.AGENT_CHAT, + name="Agent conversation", + inputs={}, + invoke_from=invoke_from, + from_source=ConversationFromSource.API, + from_account_id=account_id, + from_end_user_id=end_user_id, + ) -def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - db = _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - account.set_tenant_id_with_session.assert_called_once_with("tenant-1", session=db.session.return_value) - gen.return_value.resume_after_form_submission.assert_called_once() - kwargs = gen.return_value.resume_after_form_submission.call_args.kwargs - assert kwargs["conversation_id"] == "conv-1" - assert kwargs["form_id"] == "form-1" - assert kwargs["user"] is account - assert kwargs["app_model"] is app - assert kwargs["invoke_from"] == InvokeFrom.WEB_APP - assert kwargs["session"] is db.session.return_value +def _form(*, form_id: str, conversation_id: str, app_id: str) -> HumanInputForm: + return HumanInputForm( + id=form_id, + tenant_id=str(uuid4()), + app_id=app_id, + workflow_run_id=None, + conversation_id=conversation_id, + form_kind=HumanInputFormKind.RUNTIME, + node_id="ask-human", + form_definition="{}", + rendered_content="Question", + status=HumanInputFormStatus.WAITING, + expiration_time=datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + ) -def test_resume_end_user_path(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id="eu-1", invoke_from=InvokeFrom.WEB_APP) - end_user = MagicMock() - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, end_user=end_user) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - assert gen.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user +def _seed_account(session: Session, *, tenant_id: str, account_id: str) -> Account: + tenant = Tenant(name="Tenant") + tenant.id = tenant_id + account = Account(name="Account", email="account@example.com") + account.id = account_id + join = TenantAccountJoin( + tenant_id=tenant_id, + account_id=account_id, + current=True, + role=TenantAccountRole.NORMAL, + ) + session.add_all([tenant, account, join]) + return account -def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.DEBUGGER) - account = MagicMock() - app = MagicMock(tenant_id="tenant-1") - _wire_db(mocker, form=_form(), app=app, conversation=conversation, account=account) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_happy_path_account_user_sets_tenant_and_runs(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + account = _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + conversation = _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id) + task_session.add_all([app, conversation, _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id)]) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - assert gen.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER + call = generator.return_value.resume_after_form_submission.call_args + assert call is not None + assert call.kwargs["conversation_id"] == conversation_id + assert call.kwargs["form_id"] == form_id + assert call.kwargs["user"] is account + assert call.kwargs["app_model"] is app + assert call.kwargs["invoke_from"] == InvokeFrom.WEB_APP + assert isinstance(call.kwargs["session"], Session) + assert account.current_tenant_id == tenant_id -def test_resume_returns_when_form_missing(mocker: MockerFixture): - _wire_db(mocker, form=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_end_user_path(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, end_user_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + end_user = EndUser( + id=end_user_id, + tenant_id=tenant_id, + app_id=app_id, + type=EndUserType.BROWSER, + name="End user", + session_id="browser-session", + ) + task_session.add_all( + [ + app, + end_user, + _conversation(conversation_id=conversation_id, app_id=app_id, end_user_id=end_user_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["user"] is end_user -def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture): - _wire_db(mocker, form=_form(conversation_id="other-conv")) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") +def test_resume_preserves_debugger_invoke_from(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + app = _app(app_id=app_id, tenant_id=tenant_id) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + app, + _conversation( + conversation_id=conversation_id, + app_id=app_id, + account_id=account_id, + invoke_from=StoredInvokeFrom.DEBUGGER, + ), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) - gen.assert_not_called() + assert generator.return_value.resume_after_form_submission.call_args.kwargs["invoke_from"] == InvokeFrom.DEBUGGER -def test_resume_returns_when_app_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +@pytest.mark.usefixtures("task_session") +def test_resume_returns_when_form_missing(mocker: MockerFixture) -> None: + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=str(uuid4())) + generator.assert_not_called() -def test_resume_returns_when_conversation_missing(mocker: MockerFixture): - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_on_conversation_mismatch(mocker: MockerFixture, task_session: Session) -> None: + app_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=str(uuid4()), app_id=app_id)) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=str(uuid4()), form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture): - conversation = MagicMock(from_account_id=None, from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_app_missing(mocker: MockerFixture, task_session: Session) -> None: + conversation_id, form_id = str(uuid4()), str(uuid4()) + task_session.add(_form(form_id=form_id, conversation_id=conversation_id, app_id=str(uuid4()))) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-x", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(), conversation=conversation, account=None) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") - - gen.assert_not_called() +def test_resume_returns_when_conversation_missing(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() -def test_resume_swallows_generator_exception(mocker: MockerFixture): - conversation = MagicMock(from_account_id="acct-1", from_end_user_id=None, invoke_from=InvokeFrom.WEB_APP) - _wire_db(mocker, form=_form(), app=MagicMock(tenant_id="t"), conversation=conversation, account=MagicMock()) - gen = mocker.patch(f"{MODULE}.AgentAppGenerator") - gen.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") +def test_resume_returns_when_no_user_resolvable(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() - # The task must not propagate the failure (it is logged and the session closed). - mod.resume_agent_app_execution(conversation_id="conv-1", form_id="form-1") + +def test_resume_returns_when_account_id_set_but_account_gone(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id = (str(uuid4()) for _ in range(4)) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=str(uuid4())), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + generator.assert_not_called() + + +def test_resume_swallows_generator_exception(mocker: MockerFixture, task_session: Session) -> None: + tenant_id, app_id, conversation_id, form_id, account_id = (str(uuid4()) for _ in range(5)) + _seed_account(task_session, tenant_id=tenant_id, account_id=account_id) + task_session.add_all( + [ + _app(app_id=app_id, tenant_id=tenant_id), + _conversation(conversation_id=conversation_id, app_id=app_id, account_id=account_id), + _form(form_id=form_id, conversation_id=conversation_id, app_id=app_id), + ] + ) + task_session.commit() + generator = mocker.patch(f"{MODULE}.AgentAppGenerator") + generator.return_value.resume_after_form_submission.side_effect = RuntimeError("boom") + + mod.resume_agent_app_execution(conversation_id=conversation_id, form_id=form_id) + + generator.return_value.resume_after_form_submission.assert_called_once()