From 4f8100566285fe28002da2ed9dd2bbae4d090455 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Tue, 18 Aug 2026 12:27:32 +0000 Subject: [PATCH] test: migrate app runtime sessions and ORM models to SQLite (#40590) --- .../easy_ui_based_app/test_dataset_manager.py | 88 +++++++++++-------- .../advanced_chat/test_app_config_manager.py | 26 +++++- .../apps/agent_app/test_app_config_manager.py | 45 +++++----- .../app/apps/agent_app/test_input_guards.py | 34 +++++-- .../app/apps/chat/test_app_config_manager.py | 30 ++++--- ...workflow_response_converter_human_input.py | 4 +- ..._workflow_response_converter_resumption.py | 4 +- .../apps/test_message_based_app_generator.py | 31 +++++-- .../app/apps/test_workflow_app_runner_core.py | 34 +++++-- .../app/apps/test_workflow_pause_events.py | 17 +++- .../apps/workflow/test_app_config_manager.py | 26 +++++- .../workflow/test_generate_task_pipeline.py | 35 ++++---- .../test_generate_task_pipeline_core.py | 37 ++++++-- .../app/features/test_annotation_reply.py | 39 +++++--- ...st_easy_ui_based_generate_task_pipeline.py | 42 +++++---- ...test_message_cycle_manager_optimization.py | 9 +- 16 files changed, 343 insertions(+), 158 deletions(-) diff --git a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py index ccfa51280e1..ee04ec941c4 100644 --- a/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py +++ b/api/tests/unit_tests/core/app/app_config/easy_ui_based_app/test_dataset_manager.py @@ -3,9 +3,11 @@ from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture +from sqlalchemy.orm import Session from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager from core.entities.agent_entities import PlanningStrategy +from models.dataset import Dataset from models.model import AppMode # ============================== @@ -33,12 +35,16 @@ def base_config(valid_uuid): @pytest.fixture def mock_dataset_service(mocker: MockerFixture, valid_uuid): - mock_dataset = MagicMock() - mock_dataset.tenant_id = "tenant1" + dataset = Dataset( + id=valid_uuid, + tenant_id="tenant1", + name="Test Dataset", + created_by="account-1", + ) mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", - return_value=mock_dataset, + return_value=dataset, ) @@ -206,19 +212,21 @@ class TestDatasetConfigManagerConvert: class TestValidateAndSetDefaults: - def test_validate_sets_defaults(self): + def test_validate_sets_defaults(self, unbound_session: Session): config = {} - updated, fields = DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, MagicMock()) + updated, fields = DatasetConfigManager.validate_and_set_defaults( + "tenant1", AppMode.CHAT, config, unbound_session + ) assert "dataset_configs" in updated assert updated["dataset_configs"]["retrieval_model"] == "single" assert isinstance(fields, list) - def test_validate_raises_when_dataset_configs_not_dict(self): + def test_validate_raises_when_dataset_configs_not_dict(self, unbound_session: Session): config = {"dataset_configs": "invalid"} with pytest.raises(AttributeError): - DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, MagicMock()) + DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, unbound_session) - def test_validate_requires_query_variable_in_completion_mode(self, valid_uuid): + def test_validate_requires_query_variable_in_completion_mode(self, valid_uuid, unbound_session: Session): config = { "dataset_configs": { "datasets": { @@ -228,7 +236,7 @@ class TestValidateAndSetDefaults: } } with pytest.raises(ValueError): - DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.COMPLETION, config, MagicMock()) + DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.COMPLETION, config, unbound_session) # ============================== @@ -237,37 +245,37 @@ class TestValidateAndSetDefaults: class TestExtractDatasetConfig: - def test_extract_sets_defaults(self): + def test_extract_sets_defaults(self, unbound_session: Session): config = {} result = DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) assert "agent_mode" in result assert result["agent_mode"]["enabled"] is False assert result["agent_mode"]["tools"] == [] - def test_extract_invalid_agent_mode_type(self): + def test_extract_invalid_agent_mode_type(self, unbound_session: Session): config = {"agent_mode": "invalid"} with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) - def test_extract_invalid_enabled_type(self): + def test_extract_invalid_enabled_type(self, unbound_session: Session): config = {"agent_mode": {"enabled": "yes"}} with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) - def test_extract_invalid_tools_type(self): + def test_extract_invalid_tools_type(self, unbound_session: Session): config = {"agent_mode": {"enabled": True, "tools": "invalid"}} with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) - def test_extract_invalid_uuid(self, mocker: MockerFixture): + def test_extract_invalid_uuid(self, mocker: MockerFixture, unbound_session: Session): invalid_uuid = "not-a-uuid" config = { "agent_mode": { @@ -278,10 +286,10 @@ class TestExtractDatasetConfig: } with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) - def test_extract_dataset_not_exists(self, valid_uuid, mocker: MockerFixture): + def test_extract_dataset_not_exists(self, valid_uuid, mocker: MockerFixture, unbound_session: Session): mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", return_value=None, @@ -295,7 +303,7 @@ class TestExtractDatasetConfig: } with pytest.raises(ValueError): DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) @@ -305,31 +313,41 @@ class TestExtractDatasetConfig: class TestIsDatasetExists: - def test_dataset_exists_true(self, mocker: MockerFixture, valid_uuid): - mock_dataset = MagicMock() - mock_dataset.tenant_id = "tenant1" + def test_dataset_exists_true(self, mocker: MockerFixture, valid_uuid, unbound_session: Session): + dataset = Dataset( + id=valid_uuid, + tenant_id="tenant1", + name="Test Dataset", + created_by="account-1", + ) mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", - return_value=mock_dataset, + return_value=dataset, ) - assert DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock()) + assert DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session) - def test_dataset_exists_false_when_not_found(self, mocker: MockerFixture, valid_uuid): + def test_dataset_exists_false_when_not_found(self, mocker: MockerFixture, valid_uuid, unbound_session: Session): mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", return_value=None, ) - assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock()) + assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session) - def test_dataset_exists_false_when_tenant_mismatch(self, mocker: MockerFixture, valid_uuid): - mock_dataset = MagicMock() - mock_dataset.tenant_id = "other" + def test_dataset_exists_false_when_tenant_mismatch( + self, mocker: MockerFixture, valid_uuid, unbound_session: Session + ): + dataset = Dataset( + id=valid_uuid, + tenant_id="other", + name="Other Tenant Dataset", + created_by="account-2", + ) mocker.patch( "core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset", - return_value=mock_dataset, + return_value=dataset, ) - assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock()) + assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session) # ============================== @@ -338,7 +356,7 @@ class TestIsDatasetExists: class TestExtractDatasetConfigForLegacyCompatibility: - def test_skips_empty_tool_entry(self): + def test_skips_empty_tool_entry(self, unbound_session: Session): # A malformed empty tool dict in agent_mode.tools must be skipped, not # crash with `IndexError` on `list(tool.keys())[0]`. The sibling # convert() already guards this with `if len(tool) == 1`. @@ -351,7 +369,7 @@ class TestExtractDatasetConfigForLegacyCompatibility: } result = DatasetConfigManager.extract_dataset_config_for_legacy_compatibility( - "tenant1", AppMode.CHAT, config, MagicMock() + "tenant1", AppMode.CHAT, config, unbound_session ) assert result["agent_mode"]["tools"] == [{}] diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py index 6ca4f604594..aaea73832ef 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_config_manager.py @@ -1,14 +1,32 @@ -from types import SimpleNamespace +import json from unittest.mock import patch from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager -from models.model import AppMode +from models.model import App, AppMode +from models.workflow import Workflow, WorkflowType + + +def _app() -> App: + return App(id="app-1", tenant_id="tenant-1", name="Advanced Chat App", mode=AppMode.ADVANCED_CHAT) + + +def _workflow() -> Workflow: + return Workflow( + id="wf-1", + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.CHAT, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps({}), + created_by="account-1", + ) class TestAdvancedChatAppConfigManager: def test_get_app_config(self): - app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.ADVANCED_CHAT.value) - workflow = SimpleNamespace(id="wf-1", features_dict={}) + app_model = _app() + workflow = _workflow() with ( patch( diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py index d714e20ffcf..5b040022a88 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_config_manager.py @@ -3,10 +3,11 @@ app_model_config-shaped dict bridge that lets an Agent App ride the chat pipelin from __future__ import annotations -from types import SimpleNamespace +import json from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager from models.agent_config_entities import AgentSoulConfig +from models.model import App, AppMode, AppModelConfig def _soul() -> AgentSoulConfig: @@ -27,6 +28,13 @@ def _soul() -> AgentSoulConfig: ) +def _app_model_config(**values: object) -> AppModelConfig: + config = AppModelConfig(app_id="app-1") + for key, value in values.items(): + setattr(config, key, json.dumps(value) if isinstance(value, (dict, list)) else value) + return config + + def test_model_and_prompt_come_from_soul(): d = AgentAppConfigManager._synthesize_config_dict(_soul(), None, annotation_reply=None) assert d["model"] == { @@ -44,17 +52,15 @@ def test_model_and_prompt_come_from_soul(): def test_feature_flags_come_from_app_model_config_when_present(): # Q3: opener/follow-up/etc. live on app_model_config; model/prompt stay from Soul. - fake_amc = SimpleNamespace( - to_dict=lambda **_: { - "opening_statement": "Hi, I'm Iris.", - "suggested_questions_after_answer": {"enabled": True}, - "model": {"provider": "should-be-overridden", "name": "old"}, - "pre_prompt": "old prompt", - } + app_model_config = _app_model_config( + opening_statement="Hi, I'm Iris.", + suggested_questions_after_answer={"enabled": True}, + model={"provider": "should-be-overridden", "name": "old"}, + pre_prompt="old prompt", ) d = AgentAppConfigManager._synthesize_config_dict( _soul(), - fake_amc, + app_model_config, annotation_reply={"enabled": False}, # type: ignore[arg-type] ) # feature flags preserved @@ -80,18 +86,16 @@ def test_missing_soul_model_leaves_no_model_key(): def test_soul_file_upload_overrides_legacy_app_model_config(): - fake_amc = SimpleNamespace( - to_dict=lambda **_: { - "file_upload": { - "enabled": False, - "image": {"enabled": False}, - }, - } + app_model_config = _app_model_config( + file_upload={ + "enabled": False, + "image": {"enabled": False}, + }, ) d = AgentAppConfigManager._synthesize_config_dict( AgentSoulConfig(), - fake_amc, + app_model_config, annotation_reply={"enabled": False}, # type: ignore[arg-type] ) @@ -115,13 +119,14 @@ def test_prompt_type_defaults_to_simple(): def test_get_app_config_has_null_model_config_id_without_legacy_row(): # An Agent App has no app_model_config row; the conversation's # app_model_config_id (a UUID column) must be NULL, not "". - app_model = SimpleNamespace( + app_model = App( tenant_id="11111111-1111-1111-1111-111111111111", id="22222222-2222-2222-2222-222222222222", - mode="agent", + name="Agent App", + mode=AppMode.AGENT_CHAT, ) app_config = AgentAppConfigManager.get_app_config( - app_model=app_model, # type: ignore[arg-type] + app_model=app_model, agent_soul=_soul(), annotation_reply=None, app_model_config=None, diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index dc14fb9895c..9445bc800d4 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -22,6 +22,7 @@ from core.app.entities.queue_entities import ( QueueMessageEndEvent, ) from core.moderation.base import ModerationError +from models.model import App, AppMode, Message, MessageAnnotation class _FakeQueueManager: @@ -44,6 +45,14 @@ def _make_entity(query: str = "hello") -> SimpleNamespace: ) +def _app() -> App: + return App(id="app-1", tenant_id="tenant-1", name="Agent App", mode=AppMode.AGENT_CHAT) + + +def _message() -> Message: + return Message(id="msg-1", app_id="app-1", conversation_id="conversation-1") + + def _patch_moderation(monkeypatch: pytest.MonkeyPatch, *, returns=None, raises: Exception | None = None) -> None: class _FakeModeration: def check(self, **kwargs: Any): @@ -83,8 +92,8 @@ class TestRunInputGuards: handled, query, annotation_reply = AgentAppGenerator()._run_input_guards( session=sqlite_session, application_generate_entity=_make_entity("hello"), - app_model=SimpleNamespace(id="app-1"), - message=SimpleNamespace(id="msg-1"), + app_model=_app(), + message=_message(), queue_manager=qm, ) @@ -101,8 +110,8 @@ class TestRunInputGuards: handled, query, annotation_reply = AgentAppGenerator()._run_input_guards( session=sqlite_session, application_generate_entity=_make_entity("leak my secret"), - app_model=SimpleNamespace(id="app-1"), - message=SimpleNamespace(id="msg-1"), + app_model=_app(), + message=_message(), queue_manager=qm, ) @@ -119,8 +128,8 @@ class TestRunInputGuards: handled, _, annotation_reply = AgentAppGenerator()._run_input_guards( session=sqlite_session, application_generate_entity=_make_entity("forbidden"), - app_model=SimpleNamespace(id="app-1"), - message=SimpleNamespace(id="msg-1"), + app_model=_app(), + message=_message(), queue_manager=qm, ) @@ -132,14 +141,21 @@ class TestRunInputGuards: def test_annotation_hit_short_circuits(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) - _patch_annotation(monkeypatch, reply=SimpleNamespace(id="anno-1", content="I am the annotated Iris.")) + annotation = MessageAnnotation( + app_id="app-1", + question="what is your name", + content="I am the annotated Iris.", + account_id="account-1", + ) + annotation.id = "anno-1" + _patch_annotation(monkeypatch, reply=annotation) qm = _FakeQueueManager() handled, _, annotation_reply = AgentAppGenerator()._run_input_guards( session=sqlite_session, application_generate_entity=_make_entity("what is your name"), - app_model=SimpleNamespace(id="app-1"), - message=SimpleNamespace(id="msg-1"), + app_model=_app(), + message=_message(), queue_manager=qm, ) diff --git a/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py index 6bb0c0c977c..7df8a5d8e4e 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_app_config_manager.py @@ -1,17 +1,27 @@ -from types import SimpleNamespace -from unittest.mock import MagicMock, patch +import json +from unittest.mock import patch from sqlalchemy.orm import Session from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom, ModelConfigEntity, PromptTemplateEntity from core.app.apps.chat.app_config_manager import ChatAppConfigManager -from models.model import AppMode +from models.model import App, AppMode, AppModelConfig + + +def _app() -> App: + return App(id="app-1", tenant_id="tenant-1", name="Chat App", mode=AppMode.CHAT) + + +def _app_model_config() -> AppModelConfig: + config = AppModelConfig(app_id="app-1", model=json.dumps({"model": "m"})) + config.id = "config-1" + return config class TestChatAppConfigManager: def test_get_app_config_uses_override_dict(self): - app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value) - app_model_config = SimpleNamespace(id="config-1", to_dict=lambda: {"model": "m"}) + app_model = _app() + app_model_config = _app_model_config() override = {"model": "override"} model_entity = ModelConfigEntity(provider="p", model="m") @@ -45,11 +55,8 @@ class TestChatAppConfigManager: assert app_config.app_mode == AppMode.CHAT def test_get_app_config_uses_injected_annotation_reply(self): - app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value) - app_model_config = SimpleNamespace( - id="config-1", - to_dict=MagicMock(return_value={"model": "m"}), - ) + app_model = _app() + app_model_config = _app_model_config() annotation_reply = {"enabled": False} model_entity = ModelConfigEntity(provider="p", model="m") @@ -59,6 +66,7 @@ class TestChatAppConfigManager: ) with ( + patch.object(app_model_config, "to_dict", wraps=app_model_config.to_dict) as to_dict, patch("core.app.apps.chat.app_config_manager.ModelConfigManager.convert", return_value=model_entity), patch( "core.app.apps.chat.app_config_manager.PromptTemplateConfigManager.convert", return_value=prompt_entity @@ -76,7 +84,7 @@ class TestChatAppConfigManager: annotation_reply=annotation_reply, ) - app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply) + to_dict.assert_called_once_with(annotation_reply=annotation_reply) def test_config_validate_filters_related_keys(self, unbound_session: Session): config = {"extra": 1} diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py index 9df351fb7aa..8b898d8bd2f 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py @@ -8,6 +8,7 @@ from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import StringSegment +from models.account import Account def _build_converter(): @@ -28,7 +29,8 @@ def _build_converter(): workflow_execution_id="run-1", call_depth=0, ) - account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com") + account = Account(name="tester", email="tester@example.com") + account.id = "acc-1" return WorkflowResponseConverter( application_generate_entity=app_entity, user=account, diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py index 936ac37e55a..7e397ebc73f 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py @@ -5,6 +5,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState, VariablePool +from models.account import Account def _build_converter() -> WorkflowResponseConverter: @@ -26,7 +27,8 @@ def _build_converter() -> WorkflowResponseConverter: workflow_execution_id="run-1", call_depth=0, ) - account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com") + account = Account(name="tester", email="tester@example.com") + account.id = "acc-1" return WorkflowResponseConverter( application_generate_entity=app_entity, user=account, diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py index 61f7127cca5..410ec9db8a9 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py @@ -15,7 +15,8 @@ from core.app.app_config.entities import ( from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom -from models.model import AppMode +from models.account import Account +from models.model import App, AppMode, Conversation, Message from services.errors.app_model_config import AppModelConfigBrokenError @@ -45,6 +46,22 @@ class DummyCompletionGenerateEntity: self.model_conf = DummyModelConf() +def _app(*, app_id: str = "app") -> App: + return App( + id=app_id, + tenant_id="tenant-id", + name="Message App", + mode=AppMode.CHAT, + app_model_config_id=None, + ) + + +def _account() -> Account: + account = Account(name="Message User", email="message-user@example.com") + account.id = "user-id" + return account + + def _make_app_config(app_mode: AppMode) -> EasyUIBasedAppConfig: return EasyUIBasedAppConfig( tenant_id="tenant-id", @@ -138,24 +155,24 @@ class TestMessageBasedAppGeneratorExtras: generator._handle_response( application_generate_entity=_make_chat_generate_entity(_make_app_config(AppMode.CHAT)), queue_manager=SimpleNamespace(), - conversation=SimpleNamespace(id="conv"), - message=SimpleNamespace(id="msg"), - user=SimpleNamespace(), + conversation=Conversation(id="conv", app_id="app"), + message=Message(id="msg", app_id="app", conversation_id="conv"), + user=_account(), stream=False, ) def test_get_app_model_config_requires_valid_config(self, sqlite_session: Session): generator = MessageBasedAppGenerator() - app_model = SimpleNamespace(id="app", app_model_config_id=None, app_model_config=None) + app_model = _app() session = sqlite_session with pytest.raises(AppModelConfigBrokenError): generator._get_app_model_config(app_model, conversation=None, session=session) - conversation = SimpleNamespace(app_model_config_id="missing-id") + conversation = Conversation(id="conversation-id", app_id="app", app_model_config_id="missing-id") with pytest.raises(AppModelConfigBrokenError): generator._get_app_model_config( - app_model=SimpleNamespace(id="app"), + app_model=_app(), conversation=conversation, session=session, ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index 460d2943624..69d470f0ced 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import UTC, datetime from types import SimpleNamespace @@ -48,6 +49,22 @@ from graphon.node_events import NodeRunResult from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable +from models.workflow import Workflow, WorkflowType + + +def _workflow(graph: dict[str, object] | None = None) -> Workflow: + return Workflow.new( + tenant_id="tenant", + app_id="app", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph=json.dumps(graph or {}, default=str), + features="{}", + created_by="account", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) class TestWorkflowBasedAppRunner: @@ -122,7 +139,7 @@ class TestWorkflowBasedAppRunner: def test_prepare_single_node_execution_requires_run(self): runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") - workflow = SimpleNamespace(environment_variables=[], graph_dict={}) + workflow = _workflow() with pytest.raises(ValueError, match="Neither single_iteration_run nor single_loop_run"): runner._prepare_single_node_execution(workflow, None, None, user_id="00000000-0000-0000-0000-000000000001") @@ -138,7 +155,8 @@ class TestWorkflowBasedAppRunner: "nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}], "edges": [], } - workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config) + workflow = _workflow(graph_config) + workflow.id = "workflow" monkeypatch.setattr( "core.app.apps.workflow_app_runner.Graph.init", @@ -191,7 +209,8 @@ class TestWorkflowBasedAppRunner: "nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}], "edges": [], } - workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config) + workflow = _workflow(graph_config) + workflow.id = "workflow" captured = {} def fake_from_graph_init_context(**kwargs): @@ -256,10 +275,8 @@ class TestWorkflowBasedAppRunner: start_at=0.0, ) - workflow = SimpleNamespace( - tenant_id="tenant", - id="workflow", - graph_dict={ + workflow = _workflow( + { "nodes": [ {"id": "loop-node", "data": {"type": "loop", "version": "1", "title": "Loop"}}, { @@ -273,8 +290,9 @@ class TestWorkflowBasedAppRunner: }, ], "edges": [], - }, + } ) + workflow.id = "workflow" class _LoopNodeCls: @staticmethod diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index e57b56623c3..fb9b7f6a4b1 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -1,3 +1,4 @@ +import json from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock @@ -26,6 +27,7 @@ from graphon.graph_events import GraphRunPausedEvent from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType +from models.workflow import Workflow, WorkflowType class _RecordingWorkflowAppRunner(WorkflowAppRunner): @@ -92,12 +94,19 @@ def _build_runner(): workflow_execution_id="run-id", user_id="user-id", ) - workflow = SimpleNamespace( - graph_dict={}, + workflow = Workflow.new( tenant_id="tenant-id", - environment_variables={}, - id="workflow-id", + app_id="app-id", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph=json.dumps({}), + features="{}", + created_by="account-id", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], ) + workflow.id = "workflow-id" queue_manager = SimpleNamespace(publish=lambda event, pub_from: None) return _RecordingWorkflowAppRunner( application_generate_entity=app_entity, diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py index e62f4584d69..55911e1cedf 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_config_manager.py @@ -1,14 +1,32 @@ -from types import SimpleNamespace +import json from unittest.mock import patch from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager -from models.model import AppMode +from models.model import App, AppMode +from models.workflow import Workflow, WorkflowType + + +def _app() -> App: + return App(id="app-1", tenant_id="tenant-1", name="Workflow App", mode=AppMode.WORKFLOW) + + +def _workflow() -> Workflow: + return Workflow( + id="wf-1", + tenant_id="tenant-1", + app_id="app-1", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps({}), + created_by="account-1", + ) class TestWorkflowAppConfigManager: def test_get_app_config(self): - app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW) - workflow = SimpleNamespace(id="wf-1", features_dict={}) + app_model = _app() + workflow = _workflow() with ( patch( diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py index 1f6e7e12ef1..eee3229c500 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py @@ -1,7 +1,9 @@ +import json import time -from contextlib import contextmanager from unittest.mock import MagicMock +from sqlalchemy.orm import Session, sessionmaker + from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline @@ -12,6 +14,7 @@ from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState from models.account import Account from models.model import AppMode +from models.workflow import Workflow, WorkflowType from tests.workflow_test_utils import build_test_variable_pool @@ -42,18 +45,20 @@ def _build_runtime_state(run_id: str) -> GraphRuntimeState: return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) -@contextmanager -def _noop_session(): - yield MagicMock() - - -def _build_pipeline(run_id: str) -> WorkflowAppGenerateTaskPipeline: +def _build_pipeline(run_id: str, unbound_session_factory: sessionmaker[Session]) -> WorkflowAppGenerateTaskPipeline: queue_manager = MagicMock(spec=AppQueueManager) queue_manager.invoke_from = InvokeFrom.SERVICE_API queue_manager.graph_runtime_state = _build_runtime_state(run_id) - workflow = MagicMock() - workflow.id = "workflow-id" - workflow.features_dict = {} + workflow = Workflow( + id="workflow-id", + tenant_id="tenant-id", + app_id="app-id", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps({}), + created_by="user-id", + ) user = Account(name="user", email="user@example.com") pipeline = WorkflowAppGenerateTaskPipeline( application_generate_entity=_build_generate_entity(run_id), @@ -63,13 +68,13 @@ def _build_pipeline(run_id: str) -> WorkflowAppGenerateTaskPipeline: stream=False, draft_var_saver_factory=MagicMock(), ) - pipeline._database_session = _noop_session + pipeline._database_session = unbound_session_factory return pipeline -def test_workflow_app_log_saved_only_on_initial_start() -> None: +def test_workflow_app_log_saved_only_on_initial_start(unbound_session_factory: sessionmaker[Session]) -> None: run_id = "run-initial" - pipeline = _build_pipeline(run_id) + pipeline = _build_pipeline(run_id, unbound_session_factory) pipeline._save_workflow_app_log = MagicMock() event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.INITIAL) @@ -81,9 +86,9 @@ def test_workflow_app_log_saved_only_on_initial_start() -> None: assert pipeline._workflow_execution_id == run_id -def test_workflow_app_log_skipped_on_resumption_start() -> None: +def test_workflow_app_log_skipped_on_resumption_start(unbound_session_factory: sessionmaker[Session]) -> None: run_id = "run-resume" - pipeline = _build_pipeline(run_id) + pipeline = _build_pipeline(run_id, unbound_session_factory) pipeline._save_workflow_app_log = MagicMock() event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 884b7a7a8aa..f3b307b7f74 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging from types import SimpleNamespace @@ -53,12 +54,35 @@ from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, EndUserType from models.model import AppMode, EndUser -from models.workflow import WorkflowAppLog +from models.workflow import Workflow, WorkflowAppLog, WorkflowType from tests.workflow_test_utils import build_test_variable_pool +def _workflow() -> Workflow: + return Workflow( + id="workflow-id", + tenant_id="tenant", + app_id="app", + type=WorkflowType.WORKFLOW, + version=Workflow.VERSION_DRAFT, + graph="{}", + features=json.dumps({}), + created_by="user", + ) + + +def _end_user(*, end_user_id: str = "user", session_id: str = "session") -> EndUser: + return EndUser( + id=end_user_id, + tenant_id="tenant", + app_id="app", + type=EndUserType.BROWSER, + session_id=session_id, + ) + + def _make_pipeline(): app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", @@ -81,8 +105,8 @@ def _make_pipeline(): extras={}, call_depth=0, ) - workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}) - user = SimpleNamespace(id="user", session_id="session") + workflow = _workflow() + user = _end_user() pipeline = WorkflowAppGenerateTaskPipeline( application_generate_entity=application_generate_entity, @@ -505,10 +529,9 @@ class TestWorkflowGenerateTaskPipeline: extras={}, call_depth=0, ) - workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}) + workflow = _workflow() queue_manager = SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None) - end_user = EndUser(tenant_id="tenant", type="session", name="user", session_id="session-id") - end_user.id = "end-user-id" + end_user = _end_user(end_user_id="end-user-id", session_id="session-id") pipeline = WorkflowAppGenerateTaskPipeline( application_generate_entity=application_generate_entity, diff --git a/api/tests/unit_tests/core/app/features/test_annotation_reply.py b/api/tests/unit_tests/core/app/features/test_annotation_reply.py index 242364b301e..a9ae870fabc 100644 --- a/api/tests/unit_tests/core/app/features/test_annotation_reply.py +++ b/api/tests/unit_tests/core/app/features/test_annotation_reply.py @@ -10,11 +10,28 @@ from core.app.entities.app_invoke_entities import InvokeFrom from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature from models.dataset import DatasetCollectionBinding from models.enums import CollectionBindingType, ConversationFromSource -from models.model import AppAnnotationHitHistory, AppAnnotationSetting, MessageAnnotation +from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, AppMode, Message, MessageAnnotation TABLES = (AppAnnotationSetting, DatasetCollectionBinding, MessageAnnotation, AppAnnotationHitHistory) +def _app() -> App: + return App( + id="app-1", + tenant_id="tenant-1", + name="Test App", + description="", + mode=AppMode.CHAT, + enable_site=True, + enable_api=True, + max_active_requests=0, + ) + + +def _message() -> Message: + return Message(id="msg-1") + + def _persist_binding(session: Session) -> DatasetCollectionBinding: binding = DatasetCollectionBinding( provider_name="prov", @@ -65,8 +82,8 @@ class TestAnnotationReplyFeature: _persist_setting(sqlite_session, app_id="other-app", collection_binding_id=binding.id) result = AnnotationReplyFeature().query( - app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - message=SimpleNamespace(id="msg-1"), + app_record=_app(), + message=_message(), query="hi", user_id="user-1", invoke_from=InvokeFrom.SERVICE_API, @@ -79,8 +96,8 @@ class TestAnnotationReplyFeature: _persist_setting(sqlite_session, collection_binding_id="missing-binding") result = AnnotationReplyFeature().query( - app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - message=SimpleNamespace(id="msg-1"), + app_record=_app(), + message=_message(), query="hi", user_id="user-1", invoke_from=InvokeFrom.SERVICE_API, @@ -101,8 +118,8 @@ class TestAnnotationReplyFeature: "core.app.features.annotation_reply.annotation_reply.Vector", return_value=vector_instance ) as vector_cls: result = AnnotationReplyFeature().query( - app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - message=SimpleNamespace(id="msg-1"), + app_record=_app(), + message=_message(), query="hi", user_id="user-1", invoke_from=InvokeFrom.SERVICE_API, @@ -135,8 +152,8 @@ class TestAnnotationReplyFeature: with patch("core.app.features.annotation_reply.annotation_reply.Vector", return_value=vector_instance): result = AnnotationReplyFeature().query( - app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - message=SimpleNamespace(id="msg-1"), + app_record=_app(), + message=_message(), query="hi", user_id="user-1", invoke_from=InvokeFrom.EXPLORE, @@ -162,8 +179,8 @@ class TestAnnotationReplyFeature: caplog.at_level(logging.WARNING), ): result = AnnotationReplyFeature().query( - app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"), - message=SimpleNamespace(id="msg-1"), + app_record=_app(), + message=_message(), query="hi", user_id="user-1", invoke_from=InvokeFrom.SERVICE_API, diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py index 7d0c61fe89a..cb92ab20242 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py @@ -1,4 +1,5 @@ from collections.abc import Iterator +from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import Mock, patch @@ -31,7 +32,8 @@ from core.base.tts import AppGeneratorTTSPublisher from core.ops.ops_trace_manager import TraceQueueManager from graphon.model_runtime.entities.llm_entities import LLMResult as RuntimeLLMResult from graphon.model_runtime.entities.message_entities import TextPromptMessageContent -from models.model import AppMode +from models.enums import ConversationFromSource +from models.model import AppMode, Conversation, Message @pytest.fixture @@ -92,21 +94,25 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse: return manager @pytest.fixture - def mock_conversation(self): - """Create a mock conversation.""" - conversation = Mock() - conversation.id = "test-conversation-id" - conversation.mode = "chat" - return conversation + def conversation(self): + """Create a transient mapped conversation.""" + return Conversation( + id="test-conversation-id", + app_id="test-app-id", + mode=AppMode.CHAT, + name="Test Conversation", + status="normal", + from_source=ConversationFromSource.API, + inputs={}, + ) @pytest.fixture - def mock_message(self): - """Create a mock message.""" - message = Mock() - message.id = "test-message-id" - message.created_at = Mock() - message.created_at.timestamp.return_value = 1234567890 - return message + def message(self): + """Create a transient mapped message.""" + return Message( + id="test-message-id", + created_at=datetime.fromtimestamp(1234567890, tz=UTC), + ) @pytest.fixture def mock_task_state(self): @@ -129,8 +135,8 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse: self, mock_application_generate_entity, mock_queue_manager, - mock_conversation, - mock_message, + conversation, + message, mock_message_cycle_manager, mock_task_state, ): @@ -141,8 +147,8 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse: pipeline = EasyUIBasedGenerateTaskPipeline( application_generate_entity=mock_application_generate_entity, queue_manager=mock_queue_manager, - conversation=mock_conversation, - message=mock_message, + conversation=conversation, + message=message, stream=True, ) pipeline._message_cycle_manager = mock_message_cycle_manager diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index c888bef060d..c74dcda3c3e 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -20,7 +20,7 @@ from graphon.file import FileTransferMethod, FileType from models import model as model_module from models.base import TypeBase from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo -from models.model import App, AppMode, Conversation, MessageFile +from models.model import App, AppMode, Conversation, MessageAnnotation, MessageFile @dataclass(frozen=True) @@ -426,10 +426,13 @@ class TestMessageCycleManagerOptimization: """ message_cycle_manager._task_state = SimpleNamespace(metadata=TaskStateMetadata()) - annotation = SimpleNamespace( - id="ann-1", + annotation = MessageAnnotation( + app_id="app-id", + question="question", + content="answer", account_id="acct-1", ) + annotation.id = "ann-1" session = unbound_session with (