import logging from io import BytesIO from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest from flask import Flask from sqlalchemy.engine import Engine from sqlalchemy.orm import Session from werkzeug.datastructures import FileStorage from services.errors.app import QuotaExceededError from services.trigger import webhook_service as webhook_service_module from services.trigger.webhook_service import WebhookService class TestWebhookServiceUnit: """Webhook business-logic tests with real sessions where the service owns their lifecycle.""" def test_trigger_workflow_execution_propagates_quota_error_without_error_log( self, caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: """Quota failures refund the charge and close the real service-owned session.""" monkeypatch.setattr(webhook_service_module, "db", SimpleNamespace(engine=sqlite_engine)) webhook_trigger = MagicMock( webhook_id="webhook-123", tenant_id="tenant-123", app_id="app-123", node_id="node-123", ) workflow = MagicMock(id="workflow-123") quota_charge = MagicMock() quota_error = QuotaExceededError(feature="workflow", tenant_id="tenant-123", required=1) caplog.set_level(logging.INFO) with ( patch( "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type", return_value=MagicMock(id="end-user-123"), ), patch("services.trigger.webhook_service.QuotaService.reserve", return_value=quota_charge), patch( "services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async", side_effect=quota_error, ) as mock_trigger_workflow_async, ): with pytest.raises(QuotaExceededError) as exc_info: WebhookService.trigger_workflow_execution( webhook_trigger, {"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}, workflow, ) assert exc_info.value is quota_error quota_charge.refund.assert_called_once_with() session = mock_trigger_workflow_async.call_args.kwargs["session"] assert isinstance(session, Session) assert session.in_transaction() is False assert len(caplog.records) == 1 assert caplog.records[0].levelno == logging.INFO assert caplog.records[0].message == ( "Tenant tenant-123 quota exceeded for feature workflow, skipping webhook trigger webhook-123" ) def test_extract_webhook_data_json(self): """Test webhook data extraction from JSON request.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/json", "Authorization": "Bearer token"}, query_string="version=1&format=json", json={"message": "hello", "count": 42}, ): webhook_trigger = MagicMock() webhook_data = WebhookService.extract_webhook_data(webhook_trigger) assert webhook_data["method"] == "POST" assert webhook_data["headers"]["Authorization"] == "Bearer token" # Query params are now extracted as raw strings assert webhook_data["query_params"]["version"] == "1" assert webhook_data["query_params"]["format"] == "json" assert webhook_data["body"]["message"] == "hello" assert webhook_data["body"]["count"] == 42 assert webhook_data["files"] == {} def test_extract_webhook_data_query_params_remain_strings(self): """Query parameters should be extracted as raw strings without automatic conversion.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="GET", headers={"Content-Type": "application/json"}, query_string="count=42&threshold=3.14&enabled=true¬e=text", ): webhook_trigger = MagicMock() webhook_data = WebhookService.extract_webhook_data(webhook_trigger) # After refactoring, raw extraction keeps query params as strings assert webhook_data["query_params"]["count"] == "42" assert webhook_data["query_params"]["threshold"] == "3.14" assert webhook_data["query_params"]["enabled"] == "true" assert webhook_data["query_params"]["note"] == "text" def test_extract_webhook_data_form_urlencoded(self): """Test webhook data extraction from form URL encoded request.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, data={"username": "test", "password": "secret"}, ): webhook_trigger = MagicMock() webhook_data = WebhookService.extract_webhook_data(webhook_trigger) assert webhook_data["method"] == "POST" assert webhook_data["body"]["username"] == "test" assert webhook_data["body"]["password"] == "secret" def test_extract_webhook_data_multipart_with_files(self): """Test webhook data extraction from multipart form with files.""" app = Flask(__name__) # Create a mock file file_content = b"test file content" file_storage = FileStorage(stream=BytesIO(file_content), filename="test.txt", content_type="text/plain") with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "multipart/form-data"}, data={"message": "test", "file": file_storage}, ): webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" with patch.object(WebhookService, "_process_file_uploads", autospec=True) as mock_process_files: mock_process_files.return_value = {"file": "mocked_file_obj"} webhook_data = WebhookService.extract_webhook_data(webhook_trigger) assert webhook_data["method"] == "POST" assert webhook_data["body"]["message"] == "test" assert webhook_data["files"]["file"] == "mocked_file_obj" mock_process_files.assert_called_once() def test_extract_webhook_data_raw_text(self): """Test webhook data extraction from raw text request.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "text/plain"}, data="raw text content" ): webhook_trigger = MagicMock() webhook_data = WebhookService.extract_webhook_data(webhook_trigger) assert webhook_data["method"] == "POST" assert webhook_data["body"]["raw"] == "raw text content" def test_extract_octet_stream_body_uses_detected_mime(self): """Octet-stream uploads should rely on detected MIME type.""" app = Flask(__name__) binary_content = b"plain text data" with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/octet-stream"}, data=binary_content ): webhook_trigger = MagicMock() mock_file = MagicMock() mock_file.to_dict.return_value = {"file": "data"} with ( patch.object( WebhookService, "_detect_binary_mimetype", return_value="text/plain", autospec=True ) as mock_detect, patch.object(WebhookService, "_create_file_from_binary", autospec=True) as mock_create, ): mock_create.return_value = mock_file body, files = WebhookService._extract_octet_stream_body(webhook_trigger) assert body["raw"] == {"file": "data"} assert files == {} mock_detect.assert_called_once_with(binary_content) mock_create.assert_called_once() args = mock_create.call_args[0] assert args[0] == binary_content assert args[1] == "text/plain" assert args[2] is webhook_trigger def test_detect_binary_mimetype_uses_magic(self, monkeypatch: pytest.MonkeyPatch): """python-magic output should be used when available.""" fake_magic = MagicMock() fake_magic.from_buffer.return_value = "image/png" monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic) result = WebhookService._detect_binary_mimetype(b"binary data") assert result == "image/png" fake_magic.from_buffer.assert_called_once() def test_detect_binary_mimetype_fallback_without_magic(self, monkeypatch: pytest.MonkeyPatch): """Fallback MIME type should be used when python-magic is unavailable.""" monkeypatch.setattr("services.trigger.webhook_service.magic", None) result = WebhookService._detect_binary_mimetype(b"binary data") assert result == "application/octet-stream" def test_detect_binary_mimetype_handles_magic_exception( self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ): """Fallback MIME type should be used when python-magic raises an exception.""" try: import magic as real_magic except ImportError: pytest.skip("python-magic is not installed") fake_magic = MagicMock() fake_magic.from_buffer.side_effect = real_magic.MagicException("magic error") monkeypatch.setattr("services.trigger.webhook_service.magic", fake_magic) caplog.set_level(logging.DEBUG, logger="services.trigger.webhook_service") result = WebhookService._detect_binary_mimetype(b"binary data") assert result == "application/octet-stream" assert "python-magic detection failed for octet-stream payload" in caplog.messages def test_extract_webhook_data_invalid_json(self): """Test webhook data extraction with invalid JSON.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/json"}, data="invalid json" ): webhook_trigger = MagicMock() with pytest.raises(ValueError, match="Invalid JSON body"): WebhookService.extract_webhook_data(webhook_trigger) def test_generate_webhook_response_default(self): """Test webhook response generation with default values.""" node_config = {"data": {}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 200 assert response_data["status"] == "success" assert "Webhook processed successfully" in response_data["message"] def test_generate_webhook_response_custom_json(self): """Test webhook response generation with custom JSON response.""" node_config = {"data": {"status_code": 201, "response_body": '{"result": "created", "id": 123}'}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 201 assert response_data["result"] == "created" assert response_data["id"] == 123 def test_generate_webhook_response_custom_text(self): """Test webhook response generation with custom text response.""" node_config = {"data": {"status_code": 202, "response_body": "Request accepted for processing"}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 202 assert response_data["message"] == "Request accepted for processing" def test_generate_webhook_response_invalid_json(self): """Test webhook response generation with invalid JSON response.""" node_config = {"data": {"status_code": 400, "response_body": '{"invalid": json}'}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 400 assert response_data["message"] == '{"invalid": json}' def test_generate_webhook_response_empty_response_body(self): """Test webhook response generation with empty response body.""" node_config = {"data": {"status_code": 204, "response_body": ""}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 204 assert response_data["status"] == "success" assert "Webhook processed successfully" in response_data["message"] def test_generate_webhook_response_array_json(self): """Test webhook response generation with JSON array response.""" node_config = {"data": {"status_code": 200, "response_body": '[{"id": 1}, {"id": 2}]'}} response_data, status_code = WebhookService.generate_webhook_response(node_config) assert status_code == 200 assert isinstance(response_data, list) assert len(response_data) == 2 assert response_data[0]["id"] == 1 assert response_data[1]["id"] == 2 @patch("services.trigger.webhook_service.ToolFileManager", autospec=True) @patch("services.trigger.webhook_service.file_factory", autospec=True) def test_process_file_uploads_success(self, mock_file_factory, mock_tool_file_manager): """Test successful file upload processing.""" # Mock ToolFileManager mock_tool_file_instance = mock_tool_file_manager.return_value # Mock file creation mock_tool_file = MagicMock() mock_tool_file.id = "test_file_id" mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file # Mock file factory mock_file_obj = MagicMock() mock_file_factory.build_from_mapping.return_value = mock_file_obj # Create mock files files = { "file1": MagicMock(filename="test1.txt", content_type="text/plain"), "file2": MagicMock(filename="test2.jpg", content_type="image/jpeg"), } # Mock file reads files["file1"].stream.read.return_value = b"content1" files["file2"].stream.read.return_value = b"content2" webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" result = WebhookService._process_file_uploads(files, webhook_trigger) assert len(result) == 2 assert "file1" in result assert "file2" in result # Verify file processing was called for each file assert mock_tool_file_manager.call_count == 2 assert mock_file_factory.build_from_mapping.call_count == 2 @patch("services.trigger.webhook_service.ToolFileManager", autospec=True) @patch("services.trigger.webhook_service.file_factory", autospec=True) def test_process_file_uploads_with_errors(self, mock_file_factory, mock_tool_file_manager): """Test file upload processing with errors.""" # Mock ToolFileManager mock_tool_file_instance = mock_tool_file_manager.return_value # Mock file creation mock_tool_file = MagicMock() mock_tool_file.id = "test_file_id" mock_tool_file_instance.create_file_by_raw.return_value = mock_tool_file # Mock file factory mock_file_obj = MagicMock() mock_file_factory.build_from_mapping.return_value = mock_file_obj # Create mock files, one will fail files = { "good_file": MagicMock(filename="test.txt", content_type="text/plain"), "bad_file": MagicMock(filename="test.bad", content_type="text/plain"), } files["good_file"].stream.read.return_value = b"content" files["bad_file"].stream.read.side_effect = Exception("Read error") webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" result = WebhookService._process_file_uploads(files, webhook_trigger) # Should process the good file and skip the bad one assert len(result) == 1 assert "good_file" in result assert "bad_file" not in result def test_process_file_uploads_empty_filename(self): """Test file upload processing with empty filename.""" files = { "no_filename": MagicMock(filename="", content_type="text/plain"), "none_filename": MagicMock(filename=None, content_type="text/plain"), } webhook_trigger = MagicMock() webhook_trigger.tenant_id = "test_tenant" result = WebhookService._process_file_uploads(files, webhook_trigger) # Should skip files without filenames assert len(result) == 0 def test_validate_json_value_string(self): """Test JSON value validation for string type.""" # Valid string result = WebhookService._validate_json_value("name", "hello", "string") assert result == "hello" # Invalid string (number) - should raise ValueError with pytest.raises(ValueError, match="Expected string, got int"): WebhookService._validate_json_value("name", 123, "string") def test_validate_json_value_number(self): """Test JSON value validation for number type.""" # Valid integer result = WebhookService._validate_json_value("count", 42, "number") assert result == 42 # Valid float result = WebhookService._validate_json_value("price", 19.99, "number") assert result == 19.99 # Invalid number (string) - should raise ValueError with pytest.raises(ValueError, match="Expected number, got str"): WebhookService._validate_json_value("count", "42", "number") def test_validate_json_value_bool(self): """Test JSON value validation for boolean type.""" # Valid boolean result = WebhookService._validate_json_value("enabled", True, "boolean") assert result is True result = WebhookService._validate_json_value("enabled", False, "boolean") assert result is False # Invalid boolean (string) - should raise ValueError with pytest.raises(ValueError, match="Expected boolean, got str"): WebhookService._validate_json_value("enabled", "true", "boolean") def test_validate_json_value_object(self): """Test JSON value validation for object type.""" # Valid object result = WebhookService._validate_json_value("user", {"name": "John", "age": 30}, "object") assert result == {"name": "John", "age": 30} # Invalid object (string) - should raise ValueError with pytest.raises(ValueError, match="Expected object, got str"): WebhookService._validate_json_value("user", "not_an_object", "object") def test_validate_json_value_array_string(self): """Test JSON value validation for array[string] type.""" # Valid array of strings result = WebhookService._validate_json_value("tags", ["tag1", "tag2", "tag3"], "array[string]") assert result == ["tag1", "tag2", "tag3"] # Invalid - not an array with pytest.raises(ValueError, match="Expected array of strings, got str"): WebhookService._validate_json_value("tags", "not_an_array", "array[string]") # Invalid - array with non-strings with pytest.raises(ValueError, match="Expected array of strings, got list"): WebhookService._validate_json_value("tags", ["tag1", 123, "tag3"], "array[string]") def test_validate_json_value_array_number(self): """Test JSON value validation for array[number] type.""" # Valid array of numbers result = WebhookService._validate_json_value("scores", [1, 2.5, 3, 4.7], "array[number]") assert result == [1, 2.5, 3, 4.7] # Invalid - array with non-numbers with pytest.raises(ValueError, match="Expected array of numbers, got list"): WebhookService._validate_json_value("scores", [1, "2", 3], "array[number]") def test_validate_json_value_array_bool(self): """Test JSON value validation for array[boolean] type.""" # Valid array of booleans result = WebhookService._validate_json_value("flags", [True, False, True], "array[boolean]") assert result == [True, False, True] # Invalid - array with non-booleans with pytest.raises(ValueError, match="Expected array of booleans, got list"): WebhookService._validate_json_value("flags", [True, "false", True], "array[boolean]") def test_validate_json_value_array_object(self): """Test JSON value validation for array[object] type.""" # Valid array of objects result = WebhookService._validate_json_value("users", [{"name": "John"}, {"name": "Jane"}], "array[object]") assert result == [{"name": "John"}, {"name": "Jane"}] # Invalid - array with non-objects with pytest.raises(ValueError, match="Expected array of objects, got list"): WebhookService._validate_json_value("users", [{"name": "John"}, "not_object"], "array[object]") def test_convert_form_value_string(self): """Test form value conversion for string type.""" result = WebhookService._convert_form_value("test", "hello", "string") assert result == "hello" def test_convert_form_value_number(self): """Test form value conversion for number type.""" # Integer result = WebhookService._convert_form_value("count", "42", "number") assert result == 42 # Float result = WebhookService._convert_form_value("price", "19.99", "number") assert result == 19.99 # Invalid number with pytest.raises(ValueError, match="Cannot convert 'not_a_number' to number"): WebhookService._convert_form_value("count", "not_a_number", "number") def test_convert_form_value_boolean(self): """Test form value conversion for boolean type.""" # True values assert WebhookService._convert_form_value("flag", "true", "boolean") is True assert WebhookService._convert_form_value("flag", "1", "boolean") is True assert WebhookService._convert_form_value("flag", "yes", "boolean") is True # False values assert WebhookService._convert_form_value("flag", "false", "boolean") is False assert WebhookService._convert_form_value("flag", "0", "boolean") is False assert WebhookService._convert_form_value("flag", "no", "boolean") is False # Invalid boolean with pytest.raises(ValueError, match="Cannot convert 'maybe' to boolean"): WebhookService._convert_form_value("flag", "maybe", "boolean") def test_extract_and_validate_webhook_data_success(self): """Test successful unified data extraction and validation.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/json"}, query_string="count=42&enabled=true", json={"message": "hello", "age": 25}, ): webhook_trigger = MagicMock() node_config = { "data": { "method": "post", "content_type": "application/json", "params": [ {"name": "count", "type": "number", "required": True}, {"name": "enabled", "type": "boolean", "required": True}, ], "body": [ {"name": "message", "type": "string", "required": True}, {"name": "age", "type": "number", "required": True}, ], } } result = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) # Check that types are correctly converted assert result["query_params"]["count"] == 42 # Converted to int assert result["query_params"]["enabled"] is True # Converted to bool assert result["body"]["message"] == "hello" # Already string assert result["body"]["age"] == 25 # Already number def test_extract_and_validate_webhook_data_invalid_json_error(self): """Invalid JSON should bubble up as a ValueError with details.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="POST", headers={"Content-Type": "application/json"}, data='{"invalid": }', ): webhook_trigger = MagicMock() node_config = { "data": { "method": "post", "content_type": "application/json", } } with pytest.raises(ValueError, match="Invalid JSON body"): WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) def test_extract_and_validate_webhook_data_validation_error(self): """Test unified data extraction with validation error.""" app = Flask(__name__) with app.test_request_context( "/webhook", method="GET", # Wrong method headers={"Content-Type": "application/json"}, ): webhook_trigger = MagicMock() node_config = { "data": { "method": "post", # Expects POST "content_type": "application/json", } } with pytest.raises(ValueError, match="HTTP method mismatch"): WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config) def test_debug_mode_parameter_handling(self): """Test that the debug mode parameter is properly handled in _prepare_webhook_execution.""" from controllers.trigger.webhook import _prepare_webhook_execution # Mock the WebhookService methods with ( patch.object(WebhookService, "get_webhook_trigger_and_workflow", autospec=True) as mock_get_trigger, patch.object(WebhookService, "extract_and_validate_webhook_data", autospec=True) as mock_extract, ): mock_trigger = MagicMock() mock_workflow = MagicMock() mock_config = {"data": {"test": "config"}} mock_data = {"test": "data"} mock_get_trigger.return_value = (mock_trigger, mock_workflow, mock_config) mock_extract.return_value = mock_data result = _prepare_webhook_execution("test_webhook", is_debug=False) assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None) # Reset mock mock_get_trigger.reset_mock() result = _prepare_webhook_execution("test_webhook", is_debug=True) assert result == (mock_trigger, mock_workflow, mock_config, mock_data, None)