From 212756c3153bf6b82c9f74abf95d2a84c86036d4 Mon Sep 17 00:00:00 2001 From: rajatagarwal-oss Date: Wed, 25 Feb 2026 12:11:42 +0530 Subject: [PATCH] test: unit test cases for controllers.files, controllers.mcp and controllers.trigger module (#32057) --- api/controllers/files/tool_files.py | 4 + .../controllers/files/test_image_preview.py | 211 ++++++++ .../controllers/files/test_tool_files.py | 173 ++++++ .../controllers/files/test_upload.py | 189 +++++++ .../unit_tests/controllers/mcp/test_mcp.py | 508 ++++++++++++++++++ .../controllers/trigger/test_trigger.py | 73 +++ .../controllers/trigger/test_webhook.py | 152 ++++++ 7 files changed, 1310 insertions(+) create mode 100644 api/tests/unit_tests/controllers/files/test_image_preview.py create mode 100644 api/tests/unit_tests/controllers/files/test_tool_files.py create mode 100644 api/tests/unit_tests/controllers/files/test_upload.py create mode 100644 api/tests/unit_tests/controllers/mcp/test_mcp.py create mode 100644 api/tests/unit_tests/controllers/trigger/test_trigger.py create mode 100644 api/tests/unit_tests/controllers/trigger/test_webhook.py diff --git a/api/controllers/files/tool_files.py b/api/controllers/files/tool_files.py index 89aa472015..f6032a8e49 100644 --- a/api/controllers/files/tool_files.py +++ b/api/controllers/files/tool_files.py @@ -64,6 +64,10 @@ class ToolFileApi(Resource): if not stream or not tool_file: raise NotFound("file is not found") + + except NotFound: + raise + except Exception: raise UnsupportedFileTypeError() diff --git a/api/tests/unit_tests/controllers/files/test_image_preview.py b/api/tests/unit_tests/controllers/files/test_image_preview.py new file mode 100644 index 0000000000..fe3d9313b9 --- /dev/null +++ b/api/tests/unit_tests/controllers/files/test_image_preview.py @@ -0,0 +1,211 @@ +import types +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import NotFound + +import controllers.files.image_preview as module + + +def unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +@pytest.fixture(autouse=True) +def mock_db(): + """ + Replace Flask-SQLAlchemy db with a plain object + to avoid touching Flask app context entirely. + """ + fake_db = types.SimpleNamespace(engine=object()) + module.db = fake_db + + +class DummyUploadFile: + def __init__(self, mime_type="text/plain", size=10, name="test.txt", extension="txt"): + self.mime_type = mime_type + self.size = size + self.name = name + self.extension = extension + + +def fake_request(args: dict): + """Return a fake request object (NOT a Flask LocalProxy).""" + return types.SimpleNamespace(args=types.SimpleNamespace(to_dict=lambda flat=True: args)) + + +class TestImagePreviewApi: + @patch.object(module, "FileService") + def test_success(self, mock_file_service): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + } + ) + + generator = iter([b"img"]) + mock_file_service.return_value.get_image_preview.return_value = ( + generator, + "image/png", + ) + + api = module.ImagePreviewApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id") + + assert response.mimetype == "image/png" + + @patch.object(module, "FileService") + def test_unsupported_file_type(self, mock_file_service): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + } + ) + + mock_file_service.return_value.get_image_preview.side_effect = ( + module.services.errors.file.UnsupportedFileTypeError() + ) + + api = module.ImagePreviewApi() + get_fn = unwrap(api.get) + + with pytest.raises(module.UnsupportedFileTypeError): + get_fn("file-id") + + +class TestFilePreviewApi: + @patch.object(module, "enforce_download_for_html") + @patch.object(module, "FileService") + def test_basic_stream(self, mock_file_service, mock_enforce): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + generator = iter([b"data"]) + upload_file = DummyUploadFile(size=100) + + mock_file_service.return_value.get_file_generator_by_file_id.return_value = ( + generator, + upload_file, + ) + + api = module.FilePreviewApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id") + + assert response.mimetype == "text/plain" + assert response.headers["Content-Length"] == "100" + assert "Accept-Ranges" not in response.headers + mock_enforce.assert_called_once() + + @patch.object(module, "enforce_download_for_html") + @patch.object(module, "FileService") + def test_as_attachment(self, mock_file_service, mock_enforce): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": True, + } + ) + + generator = iter([b"data"]) + upload_file = DummyUploadFile( + mime_type="application/pdf", + name="doc.pdf", + extension="pdf", + ) + + mock_file_service.return_value.get_file_generator_by_file_id.return_value = ( + generator, + upload_file, + ) + + api = module.FilePreviewApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id") + + assert response.headers["Content-Disposition"].startswith("attachment") + assert response.headers["Content-Type"] == "application/octet-stream" + mock_enforce.assert_called_once() + + @patch.object(module, "FileService") + def test_unsupported_file_type(self, mock_file_service): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + mock_file_service.return_value.get_file_generator_by_file_id.side_effect = ( + module.services.errors.file.UnsupportedFileTypeError() + ) + + api = module.FilePreviewApi() + get_fn = unwrap(api.get) + + with pytest.raises(module.UnsupportedFileTypeError): + get_fn("file-id") + + +class TestWorkspaceWebappLogoApi: + @patch.object(module, "FileService") + @patch.object(module.TenantService, "get_custom_config") + def test_success(self, mock_config, mock_file_service): + mock_config.return_value = {"replace_webapp_logo": "logo-id"} + generator = iter([b"logo"]) + + mock_file_service.return_value.get_public_image_preview.return_value = ( + generator, + "image/png", + ) + + api = module.WorkspaceWebappLogoApi() + get_fn = unwrap(api.get) + + response = get_fn("workspace-id") + + assert response.mimetype == "image/png" + + @patch.object(module.TenantService, "get_custom_config") + def test_logo_not_configured(self, mock_config): + mock_config.return_value = {} + + api = module.WorkspaceWebappLogoApi() + get_fn = unwrap(api.get) + + with pytest.raises(NotFound): + get_fn("workspace-id") + + @patch.object(module, "FileService") + @patch.object(module.TenantService, "get_custom_config") + def test_unsupported_file_type(self, mock_config, mock_file_service): + mock_config.return_value = {"replace_webapp_logo": "logo-id"} + mock_file_service.return_value.get_public_image_preview.side_effect = ( + module.services.errors.file.UnsupportedFileTypeError() + ) + + api = module.WorkspaceWebappLogoApi() + get_fn = unwrap(api.get) + + with pytest.raises(module.UnsupportedFileTypeError): + get_fn("workspace-id") diff --git a/api/tests/unit_tests/controllers/files/test_tool_files.py b/api/tests/unit_tests/controllers/files/test_tool_files.py new file mode 100644 index 0000000000..e5df7a1eea --- /dev/null +++ b/api/tests/unit_tests/controllers/files/test_tool_files.py @@ -0,0 +1,173 @@ +import types +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import Forbidden, NotFound + +import controllers.files.tool_files as module + + +def unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def fake_request(args: dict): + return types.SimpleNamespace(args=types.SimpleNamespace(to_dict=lambda flat=True: args)) + + +class DummyToolFile: + def __init__(self, mimetype="text/plain", size=10, name="tool.txt"): + self.mimetype = mimetype + self.size = size + self.name = name + + +@pytest.fixture(autouse=True) +def mock_global_db(): + fake_db = types.SimpleNamespace(engine=object()) + module.global_db = fake_db + + +class TestToolFileApi: + @patch.object(module, "verify_tool_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_success_stream( + self, + mock_tool_file_manager, + mock_verify, + ): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + stream = iter([b"data"]) + tool_file = DummyToolFile(size=100) + + mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = ( + stream, + tool_file, + ) + + api = module.ToolFileApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id", "txt") + + assert response.mimetype == "text/plain" + assert response.headers["Content-Length"] == "100" + mock_verify.assert_called_once_with( + file_id="file-id", + timestamp="123", + nonce="abc", + sign="sig", + ) + + @patch.object(module, "verify_tool_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_as_attachment( + self, + mock_tool_file_manager, + mock_verify, + ): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": True, + } + ) + + stream = iter([b"data"]) + tool_file = DummyToolFile( + mimetype="application/pdf", + name="doc.pdf", + ) + + mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = ( + stream, + tool_file, + ) + + api = module.ToolFileApi() + get_fn = unwrap(api.get) + + response = get_fn("file-id", "pdf") + + assert response.headers["Content-Disposition"].startswith("attachment") + mock_verify.assert_called_once() + + @patch.object(module, "verify_tool_file_signature", return_value=False) + def test_invalid_signature(self, mock_verify): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "bad-sig", + "as_attachment": False, + } + ) + + api = module.ToolFileApi() + get_fn = unwrap(api.get) + + with pytest.raises(Forbidden): + get_fn("file-id", "txt") + + @patch.object(module, "verify_tool_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_file_not_found( + self, + mock_tool_file_manager, + mock_verify, + ): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.return_value = ( + None, + None, + ) + + api = module.ToolFileApi() + get_fn = unwrap(api.get) + + with pytest.raises(NotFound): + get_fn("file-id", "txt") + + @patch.object(module, "verify_tool_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_unsupported_file_type( + self, + mock_tool_file_manager, + mock_verify, + ): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "as_attachment": False, + } + ) + + mock_tool_file_manager.return_value.get_file_generator_by_tool_file_id.side_effect = Exception("boom") + + api = module.ToolFileApi() + get_fn = unwrap(api.get) + + with pytest.raises(module.UnsupportedFileTypeError): + get_fn("file-id", "txt") diff --git a/api/tests/unit_tests/controllers/files/test_upload.py b/api/tests/unit_tests/controllers/files/test_upload.py new file mode 100644 index 0000000000..e8f3cd4b66 --- /dev/null +++ b/api/tests/unit_tests/controllers/files/test_upload.py @@ -0,0 +1,189 @@ +import types +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import Forbidden + +import controllers.files.upload as module + + +def unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +def fake_request(args: dict, file=None): + return types.SimpleNamespace( + args=types.SimpleNamespace(to_dict=lambda flat=True: args), + files={"file": file} if file else {}, + ) + + +class DummyUser: + def __init__(self, user_id="user-1"): + self.id = user_id + + +class DummyFile: + def __init__(self, filename="test.txt", mimetype="text/plain", content=b"data"): + self.filename = filename + self.mimetype = mimetype + self._content = content + + def read(self): + return self._content + + +class DummyToolFile: + def __init__(self): + self.id = "file-id" + self.name = "test.txt" + self.size = 10 + self.mimetype = "text/plain" + self.original_url = "http://original" + self.user_id = "user-1" + self.tenant_id = "tenant-1" + self.conversation_id = None + self.file_key = "file-key" + + +class TestPluginUploadFileApi: + @patch.object(module, "verify_plugin_file_signature", return_value=True) + @patch.object(module, "get_user", return_value=DummyUser()) + @patch.object(module, "ToolFileManager") + def test_success_upload( + self, + mock_tool_file_manager, + mock_get_user, + mock_verify_signature, + ): + dummy_file = DummyFile() + + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "tenant_id": "tenant-1", + "user_id": "user-1", + }, + file=dummy_file, + ) + + tool_file_manager_instance = mock_tool_file_manager.return_value + tool_file_manager_instance.create_file_by_raw.return_value = DummyToolFile() + + mock_tool_file_manager.sign_file.return_value = "signed-url" + + api = module.PluginUploadFileApi() + post_fn = unwrap(api.post) + + result, status_code = post_fn(api) + + assert status_code == 201 + assert result["id"] == "file-id" + assert result["preview_url"] == "signed-url" + + def test_missing_file(self): + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "tenant_id": "tenant-1", + "user_id": "user-1", + } + ) + + api = module.PluginUploadFileApi() + post_fn = unwrap(api.post) + + with pytest.raises(Forbidden): + post_fn(api) + + @patch.object(module, "get_user", return_value=DummyUser()) + @patch.object(module, "verify_plugin_file_signature", return_value=False) + def test_invalid_signature(self, mock_verify, mock_get_user): + dummy_file = DummyFile() + + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "bad", + "tenant_id": "tenant-1", + "user_id": "user-1", + }, + file=dummy_file, + ) + + api = module.PluginUploadFileApi() + post_fn = unwrap(api.post) + + with pytest.raises(Forbidden): + post_fn(api) + + @patch.object(module, "get_user", return_value=DummyUser()) + @patch.object(module, "verify_plugin_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_file_too_large( + self, + mock_tool_file_manager, + mock_verify, + mock_get_user, + ): + dummy_file = DummyFile() + + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "tenant_id": "tenant-1", + "user_id": "user-1", + }, + file=dummy_file, + ) + + mock_tool_file_manager.return_value.create_file_by_raw.side_effect = ( + module.services.errors.file.FileTooLargeError("too large") + ) + + api = module.PluginUploadFileApi() + post_fn = unwrap(api.post) + + with pytest.raises(module.FileTooLargeError): + post_fn(api) + + @patch.object(module, "get_user", return_value=DummyUser()) + @patch.object(module, "verify_plugin_file_signature", return_value=True) + @patch.object(module, "ToolFileManager") + def test_unsupported_file_type( + self, + mock_tool_file_manager, + mock_verify, + mock_get_user, + ): + dummy_file = DummyFile() + + module.request = fake_request( + { + "timestamp": "123", + "nonce": "abc", + "sign": "sig", + "tenant_id": "tenant-1", + "user_id": "user-1", + }, + file=dummy_file, + ) + + mock_tool_file_manager.return_value.create_file_by_raw.side_effect = ( + module.services.errors.file.UnsupportedFileTypeError() + ) + + api = module.PluginUploadFileApi() + post_fn = unwrap(api.post) + + with pytest.raises(module.UnsupportedFileTypeError): + post_fn(api) diff --git a/api/tests/unit_tests/controllers/mcp/test_mcp.py b/api/tests/unit_tests/controllers/mcp/test_mcp.py new file mode 100644 index 0000000000..862d611087 --- /dev/null +++ b/api/tests/unit_tests/controllers/mcp/test_mcp.py @@ -0,0 +1,508 @@ +import types +from unittest.mock import MagicMock, patch + +import pytest +from flask import Response +from pydantic import ValidationError + +import controllers.mcp.mcp as module + + +def unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +@pytest.fixture(autouse=True) +def mock_db(): + module.db = types.SimpleNamespace(engine=object()) + + +@pytest.fixture +def fake_session(): + session = MagicMock() + session.__enter__.return_value = session + session.__exit__.return_value = False + return session + + +@pytest.fixture(autouse=True) +def mock_session(fake_session): + module.Session = MagicMock(return_value=fake_session) + + +@pytest.fixture(autouse=True) +def mock_mcp_ns(): + fake_ns = types.SimpleNamespace() + fake_ns.payload = None + fake_ns.models = {} + module.mcp_ns = fake_ns + + +def fake_payload(data): + module.mcp_ns.payload = data + + +class DummyServer: + def __init__(self, status, app_id="app-1", tenant_id="tenant-1", server_id="srv-1"): + self.status = status + self.app_id = app_id + self.tenant_id = tenant_id + self.id = server_id + + +class DummyApp: + def __init__(self, mode, workflow=None, app_model_config=None): + self.id = "app-1" + self.tenant_id = "tenant-1" + self.mode = mode + self.workflow = workflow + self.app_model_config = app_model_config + + +class DummyWorkflow: + def user_input_form(self, to_old_structure=False): + return [] + + +class DummyConfig: + def to_dict(self): + return {"user_input_form": []} + + +class DummyResult: + def model_dump(self, **kwargs): + return {"jsonrpc": "2.0", "result": "ok", "id": 1} + + +class TestMCPAppApi: + @patch.object(module, "handle_mcp_request", return_value=DummyResult()) + def test_success_request(self, mock_handle): + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + response = post_fn("server-1") + + assert isinstance(response, Response) + mock_handle.assert_called_once() + + def test_notification_initialized(self): + fake_payload( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + response = post_fn("server-1") + + assert response.status_code == 202 + + def test_invalid_notification_method(self): + fake_payload( + { + "jsonrpc": "2.0", + "method": "notifications/invalid", + "params": {}, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError): + post_fn("server-1") + + def test_inactive_server(self): + fake_payload( + { + "jsonrpc": "2.0", + "method": "test", + "id": 1, + "params": {}, + } + ) + + server = DummyServer(status="inactive") + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError): + post_fn("server-1") + + def test_invalid_payload(self): + fake_payload({"invalid": "data"}) + + api = module.MCPAppApi() + post_fn = unwrap(api.post) + + with pytest.raises(ValidationError): + post_fn("server-1") + + def test_missing_request_id(self): + fake_payload( + { + "jsonrpc": "2.0", + "method": "test", + "params": {}, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.WORKFLOW, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError): + post_fn("server-1") + + def test_server_not_found(self): + """Test when MCP server doesn't exist""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock( + side_effect=module.MCPRequestError(module.mcp_types.INVALID_REQUEST, "Server Not Found") + ) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "Server Not Found" in str(exc_info.value) + + def test_app_not_found(self): + """Test when app associated with server doesn't exist""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock( + side_effect=module.MCPRequestError(module.mcp_types.INVALID_REQUEST, "App Not Found") + ) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "App Not Found" in str(exc_info.value) + + def test_app_unavailable_no_workflow(self): + """Test when app has no workflow (ADVANCED_CHAT mode)""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=None, # No workflow + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "App is unavailable" in str(exc_info.value) + + def test_app_unavailable_no_model_config(self): + """Test when app has no model config (chat mode)""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.CHAT, + app_model_config=None, # No model config + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "App is unavailable" in str(exc_info.value) + + @patch.object(module, "handle_mcp_request", return_value=None) + def test_mcp_request_no_response(self, mock_handle): + """Test when handle_mcp_request returns None""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "No response generated" in str(exc_info.value) + + def test_workflow_mode_with_user_input_form(self): + """Test WORKFLOW mode app with user input form""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + class WorkflowWithForm: + def user_input_form(self, to_old_structure=False): + return [{"text-input": {"variable": "test_var", "label": "Test"}}] + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.WORKFLOW, + workflow=WorkflowWithForm(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + with patch.object(module, "handle_mcp_request", return_value=DummyResult()): + post_fn = unwrap(api.post) + response = post_fn("server-1") + assert isinstance(response, Response) + + def test_chat_mode_with_model_config(self): + """Test CHAT mode app with model config""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.CHAT, + app_model_config=DummyConfig(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + with patch.object(module, "handle_mcp_request", return_value=DummyResult()): + post_fn = unwrap(api.post) + response = post_fn("server-1") + assert isinstance(response, Response) + + def test_invalid_mcp_request_format(self): + """Test invalid MCP request that doesn't match any type""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "invalid_method_xyz", + "id": 1, + "params": {}, + } + ) + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "Invalid MCP request" in str(exc_info.value) + + def test_server_found_successfully(self): + """Test successful server and app retrieval""" + api = module.MCPAppApi() + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.ADVANCED_CHAT, + workflow=DummyWorkflow(), + ) + + session = MagicMock() + session.query().where().first.side_effect = [server, app] + + result_server, result_app = api._get_mcp_server_and_app("server-1", session) + + assert result_server == server + assert result_app == app + + def test_validate_server_status_active(self): + """Test successful server status validation""" + api = module.MCPAppApi() + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + + # Should not raise an exception + api._validate_server_status(server) + + def test_convert_user_input_form_empty(self): + """Test converting empty user input form""" + api = module.MCPAppApi() + result = api._convert_user_input_form([]) + assert result == [] + + def test_invalid_user_input_form_validation(self): + """Test invalid user input form that fails validation""" + fake_payload( + { + "jsonrpc": "2.0", + "method": "initialize", + "id": 1, + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0"}, + }, + } + ) + + class WorkflowWithBadForm: + def user_input_form(self, to_old_structure=False): + # Invalid type that will fail validation + return [{"invalid-type": {"variable": "test_var"}}] + + server = DummyServer(status=module.AppMCPServerStatus.ACTIVE) + app = DummyApp( + mode=module.AppMode.WORKFLOW, + workflow=WorkflowWithBadForm(), + ) + + api = module.MCPAppApi() + api._get_mcp_server_and_app = MagicMock(return_value=(server, app)) + + post_fn = unwrap(api.post) + + with pytest.raises(module.MCPRequestError) as exc_info: + post_fn("server-1") + assert "Invalid user_input_form" in str(exc_info.value) diff --git a/api/tests/unit_tests/controllers/trigger/test_trigger.py b/api/tests/unit_tests/controllers/trigger/test_trigger.py new file mode 100644 index 0000000000..1d6db9e232 --- /dev/null +++ b/api/tests/unit_tests/controllers/trigger/test_trigger.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import NotFound + +import controllers.trigger.trigger as module + + +@pytest.fixture(autouse=True) +def mock_request(): + module.request = object() + + +@pytest.fixture(autouse=True) +def mock_jsonify(): + module.jsonify = lambda payload: payload + + +VALID_UUID = "123e4567-e89b-42d3-a456-426614174000" +INVALID_UUID = "not-a-uuid" + + +class TestTriggerEndpoint: + def test_invalid_uuid(self): + with pytest.raises(NotFound): + module.trigger_endpoint(INVALID_UUID) + + @patch.object(module.TriggerService, "process_endpoint") + @patch.object(module.TriggerSubscriptionBuilderService, "process_builder_validation_endpoint") + def test_first_handler_returns_response(self, mock_builder, mock_trigger): + mock_trigger.return_value = ("ok", 200) + mock_builder.return_value = None + + response = module.trigger_endpoint(VALID_UUID) + + assert response == ("ok", 200) + mock_builder.assert_not_called() + + @patch.object(module.TriggerService, "process_endpoint") + @patch.object(module.TriggerSubscriptionBuilderService, "process_builder_validation_endpoint") + def test_second_handler_returns_response(self, mock_builder, mock_trigger): + mock_trigger.return_value = None + mock_builder.return_value = ("ok", 200) + + response = module.trigger_endpoint(VALID_UUID) + + assert response == ("ok", 200) + + @patch.object(module.TriggerService, "process_endpoint") + @patch.object(module.TriggerSubscriptionBuilderService, "process_builder_validation_endpoint") + def test_no_handler_returns_response(self, mock_builder, mock_trigger): + mock_trigger.return_value = None + mock_builder.return_value = None + + response, status = module.trigger_endpoint(VALID_UUID) + + assert status == 404 + assert response["error"] == "Endpoint not found" + + @patch.object(module.TriggerService, "process_endpoint", side_effect=ValueError("bad input")) + def test_value_error(self, mock_trigger): + response, status = module.trigger_endpoint(VALID_UUID) + + assert status == 400 + assert response["error"] == "Endpoint processing failed" + assert response["message"] == "bad input" + + @patch.object(module.TriggerService, "process_endpoint", side_effect=Exception("boom")) + def test_unexpected_exception(self, mock_trigger): + response, status = module.trigger_endpoint(VALID_UUID) + + assert status == 500 + assert response["error"] == "Internal server error" diff --git a/api/tests/unit_tests/controllers/trigger/test_webhook.py b/api/tests/unit_tests/controllers/trigger/test_webhook.py new file mode 100644 index 0000000000..d633365f2b --- /dev/null +++ b/api/tests/unit_tests/controllers/trigger/test_webhook.py @@ -0,0 +1,152 @@ +import types +from unittest.mock import patch + +import pytest +from werkzeug.exceptions import NotFound, RequestEntityTooLarge + +import controllers.trigger.webhook as module + + +@pytest.fixture(autouse=True) +def mock_request(): + module.request = types.SimpleNamespace( + method="POST", + headers={"x-test": "1"}, + args={"a": "b"}, + ) + + +@pytest.fixture(autouse=True) +def mock_jsonify(): + module.jsonify = lambda payload: payload + + +class DummyWebhookTrigger: + webhook_id = "wh-1" + tenant_id = "tenant-1" + app_id = "app-1" + node_id = "node-1" + + +class TestPrepareWebhookExecution: + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data") + def test_prepare_success(self, mock_extract, mock_get): + mock_get.return_value = ("trigger", "workflow", "node_config") + mock_extract.return_value = {"data": "ok"} + + result = module._prepare_webhook_execution("wh-1") + + assert result == ("trigger", "workflow", "node_config", {"data": "ok"}, None) + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad")) + def test_prepare_validation_error(self, mock_extract, mock_get): + mock_get.return_value = ("trigger", "workflow", "node_config") + + trigger, workflow, node_config, webhook_data, error = module._prepare_webhook_execution("wh-1") + + assert error == "bad" + assert webhook_data["method"] == "POST" + + +class TestHandleWebhook: + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data") + @patch.object(module.WebhookService, "trigger_workflow_execution") + @patch.object(module.WebhookService, "generate_webhook_response") + def test_success( + self, + mock_generate, + mock_trigger, + mock_extract, + mock_get, + ): + mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config") + mock_extract.return_value = {"input": "x"} + mock_generate.return_value = ({"ok": True}, 200) + + response, status = module.handle_webhook("wh-1") + + assert status == 200 + assert response["ok"] is True + mock_trigger.assert_called_once() + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad")) + def test_bad_request(self, mock_extract, mock_get): + mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config") + + response, status = module.handle_webhook("wh-1") + + assert status == 400 + assert response["error"] == "Bad Request" + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing")) + def test_value_error_not_found(self, mock_get): + with pytest.raises(NotFound): + module.handle_webhook("wh-1") + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge()) + def test_request_entity_too_large(self, mock_get): + with pytest.raises(RequestEntityTooLarge): + module.handle_webhook("wh-1") + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=Exception("boom")) + def test_internal_error(self, mock_get): + response, status = module.handle_webhook("wh-1") + + assert status == 500 + assert response["error"] == "Internal server error" + + +class TestHandleWebhookDebug: + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data") + @patch.object(module.WebhookService, "build_workflow_inputs", return_value={"x": 1}) + @patch.object(module.TriggerDebugEventBus, "dispatch") + @patch.object(module.WebhookService, "generate_webhook_response") + def test_debug_success( + self, + mock_generate, + mock_dispatch, + mock_build_inputs, + mock_extract, + mock_get, + ): + mock_get.return_value = (DummyWebhookTrigger(), None, "node_config") + mock_extract.return_value = {"method": "POST"} + mock_generate.return_value = ({"ok": True}, 200) + + response, status = module.handle_webhook_debug("wh-1") + + assert status == 200 + assert response["ok"] is True + mock_dispatch.assert_called_once() + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow") + @patch.object(module.WebhookService, "extract_and_validate_webhook_data", side_effect=ValueError("bad")) + def test_debug_bad_request(self, mock_extract, mock_get): + mock_get.return_value = (DummyWebhookTrigger(), None, "node_config") + + response, status = module.handle_webhook_debug("wh-1") + + assert status == 400 + assert response["error"] == "Bad Request" + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing")) + def test_debug_not_found(self, mock_get): + with pytest.raises(NotFound): + module.handle_webhook_debug("wh-1") + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=RequestEntityTooLarge()) + def test_debug_request_entity_too_large(self, mock_get): + with pytest.raises(RequestEntityTooLarge): + module.handle_webhook_debug("wh-1") + + @patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=Exception("boom")) + def test_debug_internal_error(self, mock_get): + response, status = module.handle_webhook_debug("wh-1") + + assert status == 500 + assert response["error"] == "Internal server error"