feat: webhook trigger backend api (#24387)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
非法操作
2025-08-27 14:42:45 +08:00
committed by GitHub
parent 7129de98cd
commit a63d1e87b1
21 changed files with 2615 additions and 6 deletions

View File

@ -0,0 +1,497 @@
import json
from io import BytesIO
from unittest.mock import MagicMock, patch
import pytest
from faker import Faker
from flask import Flask
from werkzeug.datastructures import FileStorage
from models.model import App
from models.workflow import Workflow, WorkflowWebhookTrigger
from services.account_service import AccountService, TenantService
from services.webhook_service import WebhookService
class TestWebhookService:
"""Integration tests for WebhookService using testcontainers."""
@pytest.fixture
def mock_external_dependencies(self):
"""Mock external service dependencies."""
with (
patch("services.webhook_service.AsyncWorkflowService") as mock_async_service,
patch("services.webhook_service.ToolFileManager") as mock_tool_file_manager,
patch("services.webhook_service.file_factory") as mock_file_factory,
patch("services.account_service.FeatureService") as mock_feature_service,
):
# Mock ToolFileManager
mock_tool_file_instance = MagicMock()
mock_tool_file_manager.return_value = mock_tool_file_instance
# 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
# Mock feature service
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
yield {
"async_service": mock_async_service,
"tool_file_manager": mock_tool_file_manager,
"file_factory": mock_file_factory,
"tool_file": mock_tool_file,
"file_obj": mock_file_obj,
"feature_service": mock_feature_service,
}
@pytest.fixture
def test_data(self, db_session_with_containers, mock_external_dependencies):
"""Create test data for webhook service tests."""
fake = Faker()
# Create account and tenant
account = AccountService.create_account(
email=fake.email(),
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(account, name=fake.company())
tenant = account.current_tenant
# Create app
app = App(
tenant_id=tenant.id,
name=fake.company(),
description=fake.text(),
mode="workflow",
icon="",
icon_background="",
enable_site=True,
enable_api=True,
)
db_session_with_containers.add(app)
db_session_with_containers.flush()
# Create workflow
workflow_data = {
"nodes": [
{
"id": "webhook_node",
"type": "webhook",
"data": {
"title": "Test Webhook",
"method": "post",
"content-type": "application/json",
"headers": [
{"name": "Authorization", "required": True},
{"name": "Content-Type", "required": False},
],
"params": [{"name": "version", "required": True}, {"name": "format", "required": False}],
"body": [
{"name": "message", "type": "string", "required": True},
{"name": "count", "type": "number", "required": False},
{"name": "upload", "type": "file", "required": False},
],
"status_code": 200,
"response_body": '{"status": "success"}',
"timeout": 30,
},
}
],
"edges": [],
}
workflow = Workflow(
tenant_id=tenant.id,
app_id=app.id,
type="workflow",
graph=json.dumps(workflow_data),
features=json.dumps({}),
created_by=account.id,
environment_variables=[],
conversation_variables=[],
version="1.0",
)
db_session_with_containers.add(workflow)
db_session_with_containers.flush()
# Create webhook trigger
webhook_id = fake.uuid4()[:16]
webhook_trigger = WorkflowWebhookTrigger(
app_id=app.id,
node_id="webhook_node",
tenant_id=tenant.id,
webhook_id=webhook_id,
triggered_by="production",
)
db_session_with_containers.add(webhook_trigger)
db_session_with_containers.commit()
return {
"tenant": tenant,
"account": account,
"app": app,
"workflow": workflow,
"webhook_trigger": webhook_trigger,
"webhook_id": webhook_id,
}
def test_get_webhook_trigger_and_workflow_success(self, test_data, flask_app_with_containers):
"""Test successful retrieval of webhook trigger and workflow."""
webhook_id = test_data["webhook_id"]
with flask_app_with_containers.app_context():
webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(webhook_id)
assert webhook_trigger is not None
assert webhook_trigger.webhook_id == webhook_id
assert workflow is not None
assert workflow.app_id == test_data["app"].id
assert node_config is not None
assert node_config["id"] == "webhook_node"
assert node_config["data"]["title"] == "Test Webhook"
def test_get_webhook_trigger_and_workflow_not_found(self, flask_app_with_containers):
"""Test webhook trigger not found scenario."""
with flask_app_with_containers.app_context():
with pytest.raises(ValueError, match="Webhook not found"):
WebhookService.get_webhook_trigger_and_workflow("nonexistent_webhook")
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"
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_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, mock_external_dependencies):
"""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", "upload": file_storage},
):
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["body"]["message"] == "test"
assert "upload" in webhook_data["files"]
# Verify file processing was called
mock_external_dependencies["tool_file_manager"].assert_called_once()
mock_external_dependencies["file_factory"].build_from_mapping.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_validate_webhook_request_success(self):
"""Test successful webhook request validation."""
webhook_data = {
"method": "POST",
"headers": {"Authorization": "Bearer token", "Content-Type": "application/json"},
"query_params": {"version": "1"},
"body": {"message": "hello"},
"files": {},
}
node_config = {
"data": {
"method": "post",
"headers": [{"name": "Authorization", "required": True}, {"name": "Content-Type", "required": False}],
"params": [{"name": "version", "required": True}],
"body": [{"name": "message", "type": "string", "required": True}],
}
}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is True
def test_validate_webhook_request_method_mismatch(self):
"""Test webhook validation with HTTP method mismatch."""
webhook_data = {"method": "GET", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post"}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "HTTP method mismatch" in result["error"]
def test_validate_webhook_request_missing_required_header(self):
"""Test webhook validation with missing required header."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "headers": [{"name": "Authorization", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required header missing: Authorization" in result["error"]
def test_validate_webhook_request_case_insensitive_headers(self):
"""Test webhook validation with case-insensitive header matching."""
webhook_data = {
"method": "POST",
"headers": {"authorization": "Bearer token"}, # lowercase
"query_params": {},
"body": {},
"files": {},
}
node_config = {
"data": {
"method": "post",
"headers": [
{"name": "Authorization", "required": True} # Pascal case
],
}
}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is True
def test_validate_webhook_request_missing_required_param(self):
"""Test webhook validation with missing required query parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "params": [{"name": "version", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required query parameter missing: version" in result["error"]
def test_validate_webhook_request_missing_required_body_param(self):
"""Test webhook validation with missing required body parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "body": [{"name": "message", "type": "string", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required body parameter missing: message" in result["error"]
def test_validate_webhook_request_missing_required_file(self):
"""Test webhook validation with missing required file parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "body": [{"name": "upload", "type": "file", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required file parameter missing: upload" in result["error"]
def test_trigger_workflow_execution_success(self, test_data, mock_external_dependencies, flask_app_with_containers):
"""Test successful workflow execution trigger."""
webhook_data = {
"method": "POST",
"headers": {"Authorization": "Bearer token"},
"query_params": {"version": "1"},
"body": {"message": "hello"},
"files": {},
}
with flask_app_with_containers.app_context():
# Mock tenant owner lookup to return the test account
with patch("services.webhook_service.select") as mock_select:
mock_query = MagicMock()
mock_select.return_value.join.return_value.where.return_value = mock_query
# Mock the session to return our test account
with patch("services.webhook_service.Session") as mock_session:
mock_session_instance = MagicMock()
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_session_instance.scalar.return_value = test_data["account"]
# Should not raise any exceptions
WebhookService.trigger_workflow_execution(
test_data["webhook_trigger"], webhook_data, test_data["workflow"]
)
# Verify AsyncWorkflowService was called
mock_external_dependencies["async_service"].trigger_workflow_async.assert_called_once()
def test_trigger_workflow_execution_no_tenant_owner(
self, test_data, mock_external_dependencies, flask_app_with_containers
):
"""Test workflow execution trigger when tenant owner not found."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
with flask_app_with_containers.app_context():
# Mock tenant owner lookup to return None
with (
patch("services.webhook_service.select") as mock_select,
patch("services.webhook_service.Session") as mock_session,
):
mock_session_instance = MagicMock()
mock_session.return_value.__enter__.return_value = mock_session_instance
mock_session_instance.scalar.return_value = None
with pytest.raises(ValueError, match="Tenant owner not found"):
WebhookService.trigger_workflow_execution(
test_data["webhook_trigger"], webhook_data, test_data["workflow"]
)
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_process_file_uploads_success(self, mock_external_dependencies):
"""Test successful file upload processing."""
# 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"].read.return_value = b"content1"
files["file2"].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_external_dependencies["tool_file_manager"].call_count == 2
assert mock_external_dependencies["file_factory"].build_from_mapping.call_count == 2
def test_process_file_uploads_with_errors(self, mock_external_dependencies):
"""Test file upload processing with errors."""
# 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"].read.return_value = b"content"
files["bad_file"].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, mock_external_dependencies):
"""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
mock_external_dependencies["tool_file_manager"].assert_not_called()

View File

@ -0,0 +1,294 @@
import pytest
from pydantic import ValidationError
from core.workflow.nodes.webhook.entities import (
ContentType,
Method,
WebhookBodyParameter,
WebhookData,
WebhookParameter,
)
def test_method_enum():
"""Test Method enum values."""
assert Method.GET == "get"
assert Method.POST == "post"
assert Method.HEAD == "head"
assert Method.PATCH == "patch"
assert Method.PUT == "put"
assert Method.DELETE == "delete"
# Test all enum values are strings
for method in Method:
assert isinstance(method.value, str)
def test_content_type_enum():
"""Test ContentType enum values."""
assert ContentType.JSON == "application/json"
assert ContentType.FORM_DATA == "multipart/form-data"
assert ContentType.FORM_URLENCODED == "application/x-www-form-urlencoded"
assert ContentType.TEXT == "text/plain"
assert ContentType.FORM == "form"
# Test all enum values are strings
for content_type in ContentType:
assert isinstance(content_type.value, str)
def test_webhook_parameter_creation():
"""Test WebhookParameter model creation and validation."""
# Test with all fields
param = WebhookParameter(name="api_key", required=True)
assert param.name == "api_key"
assert param.required is True
# Test with defaults
param_default = WebhookParameter(name="optional_param")
assert param_default.name == "optional_param"
assert param_default.required is False
# Test validation - name is required
with pytest.raises(ValidationError):
WebhookParameter()
def test_webhook_body_parameter_creation():
"""Test WebhookBodyParameter model creation and validation."""
# Test with all fields
body_param = WebhookBodyParameter(
name="user_data",
type="object",
required=True,
)
assert body_param.name == "user_data"
assert body_param.type == "object"
assert body_param.required is True
# Test with defaults
body_param_default = WebhookBodyParameter(name="message")
assert body_param_default.name == "message"
assert body_param_default.type == "string" # Default type
assert body_param_default.required is False
# Test validation - name is required
with pytest.raises(ValidationError):
WebhookBodyParameter()
def test_webhook_body_parameter_types():
"""Test WebhookBodyParameter type validation."""
valid_types = ["string", "number", "boolean", "object", "array", "file"]
for param_type in valid_types:
param = WebhookBodyParameter(name="test", type=param_type)
assert param.type == param_type
# Test invalid type
with pytest.raises(ValidationError):
WebhookBodyParameter(name="test", type="invalid_type")
def test_webhook_data_creation_minimal():
"""Test WebhookData creation with minimal required fields."""
data = WebhookData(title="Test Webhook")
assert data.title == "Test Webhook"
assert data.method == Method.GET # Default
assert data.content_type == ContentType.JSON # Default
assert data.headers == [] # Default
assert data.params == [] # Default
assert data.body == [] # Default
assert data.status_code == 200 # Default
assert data.response_body == "" # Default
assert data.webhook_id is None # Default
assert data.timeout == 30 # Default
def test_webhook_data_creation_full():
"""Test WebhookData creation with all fields."""
headers = [
WebhookParameter(name="Authorization", required=True),
WebhookParameter(name="Content-Type", required=False),
]
params = [
WebhookParameter(name="version", required=True),
WebhookParameter(name="format", required=False),
]
body = [
WebhookBodyParameter(name="message", type="string", required=True),
WebhookBodyParameter(name="count", type="number", required=False),
WebhookBodyParameter(name="upload", type="file", required=True),
]
# Use the alias for content_type to test it properly
data = WebhookData(
title="Full Webhook Test",
desc="A comprehensive webhook test",
method=Method.POST,
**{"content-type": ContentType.FORM_DATA},
headers=headers,
params=params,
body=body,
status_code=201,
response_body='{"success": true}',
webhook_id="webhook_123",
timeout=60,
)
assert data.title == "Full Webhook Test"
assert data.desc == "A comprehensive webhook test"
assert data.method == Method.POST
assert data.content_type == ContentType.FORM_DATA
assert len(data.headers) == 2
assert len(data.params) == 2
assert len(data.body) == 3
assert data.status_code == 201
assert data.response_body == '{"success": true}'
assert data.webhook_id == "webhook_123"
assert data.timeout == 60
def test_webhook_data_content_type_alias():
"""Test WebhookData content_type field alias."""
# Test using the alias "content-type"
data1 = WebhookData(title="Test", **{"content-type": "application/json"})
assert data1.content_type == ContentType.JSON
# Test using the alias with enum value
data2 = WebhookData(title="Test", **{"content-type": ContentType.FORM_DATA})
assert data2.content_type == ContentType.FORM_DATA
# Test both approaches result in same field
assert hasattr(data1, "content_type")
assert hasattr(data2, "content_type")
def test_webhook_data_model_dump():
"""Test WebhookData model serialization."""
data = WebhookData(
title="Test Webhook",
method=Method.POST,
content_type=ContentType.JSON,
headers=[WebhookParameter(name="Authorization", required=True)],
params=[WebhookParameter(name="version", required=False)],
body=[WebhookBodyParameter(name="message", type="string", required=True)],
status_code=200,
response_body="OK",
timeout=30,
)
dumped = data.model_dump()
assert dumped["title"] == "Test Webhook"
assert dumped["method"] == "post"
assert dumped["content_type"] == "application/json"
assert len(dumped["headers"]) == 1
assert dumped["headers"][0]["name"] == "Authorization"
assert dumped["headers"][0]["required"] is True
assert len(dumped["params"]) == 1
assert len(dumped["body"]) == 1
assert dumped["body"][0]["type"] == "string"
def test_webhook_data_model_dump_with_alias():
"""Test WebhookData model serialization includes alias."""
data = WebhookData(
title="Test Webhook",
**{"content-type": ContentType.FORM_DATA},
)
dumped = data.model_dump(by_alias=True)
assert "content-type" in dumped
assert dumped["content-type"] == "multipart/form-data"
def test_webhook_data_validation_errors():
"""Test WebhookData validation errors."""
# Title is required (inherited from BaseNodeData)
with pytest.raises(ValidationError):
WebhookData()
# Invalid method
with pytest.raises(ValidationError):
WebhookData(title="Test", method="invalid_method")
# Invalid content_type via alias
with pytest.raises(ValidationError):
WebhookData(title="Test", **{"content-type": "invalid/type"})
# Invalid status_code (should be int) - use non-numeric string
with pytest.raises(ValidationError):
WebhookData(title="Test", status_code="invalid")
# Invalid timeout (should be int) - use non-numeric string
with pytest.raises(ValidationError):
WebhookData(title="Test", timeout="invalid")
# Valid cases that should NOT raise errors
# These should work fine (pydantic converts string numbers to int)
valid_data = WebhookData(title="Test", status_code="200", timeout="30")
assert valid_data.status_code == 200
assert valid_data.timeout == 30
def test_webhook_data_sequence_fields():
"""Test WebhookData sequence field behavior."""
# Test empty sequences
data = WebhookData(title="Test")
assert data.headers == []
assert data.params == []
assert data.body == []
# Test immutable sequences
headers = [WebhookParameter(name="test")]
data = WebhookData(title="Test", headers=headers)
# Original list shouldn't affect the model
headers.append(WebhookParameter(name="test2"))
assert len(data.headers) == 1 # Should still be 1
def test_webhook_data_sync_mode():
"""Test WebhookData SyncMode nested enum."""
# Test that SyncMode enum exists and has expected value
assert hasattr(WebhookData, "SyncMode")
assert WebhookData.SyncMode.SYNC == "async" # Note: confusingly named but correct
def test_webhook_parameter_edge_cases():
"""Test WebhookParameter edge cases."""
# Test with special characters in name
param = WebhookParameter(name="X-Custom-Header-123", required=True)
assert param.name == "X-Custom-Header-123"
# Test with empty string name (should be valid if pydantic allows it)
param_empty = WebhookParameter(name="", required=False)
assert param_empty.name == ""
def test_webhook_body_parameter_edge_cases():
"""Test WebhookBodyParameter edge cases."""
# Test file type parameter
file_param = WebhookBodyParameter(name="upload", type="file", required=True)
assert file_param.type == "file"
assert file_param.required is True
# Test all valid types
for param_type in ["string", "number", "boolean", "object", "array", "file"]:
param = WebhookBodyParameter(name=f"test_{param_type}", type=param_type)
assert param.type == param_type
def test_webhook_data_inheritance():
"""Test WebhookData inherits from BaseNodeData correctly."""
from core.workflow.nodes.base import BaseNodeData
# Test that WebhookData is a subclass of BaseNodeData
assert issubclass(WebhookData, BaseNodeData)
# Test that instances have BaseNodeData properties
data = WebhookData(title="Test")
assert hasattr(data, "title")
assert hasattr(data, "desc") # Inherited from BaseNodeData

View File

@ -0,0 +1,195 @@
import pytest
from core.workflow.nodes.base.exc import BaseNodeError
from core.workflow.nodes.webhook.exc import (
WebhookConfigError,
WebhookNodeError,
WebhookNotFoundError,
WebhookTimeoutError,
)
def test_webhook_node_error_inheritance():
"""Test WebhookNodeError inherits from BaseNodeError."""
assert issubclass(WebhookNodeError, BaseNodeError)
# Test instantiation
error = WebhookNodeError("Test error message")
assert str(error) == "Test error message"
assert isinstance(error, BaseNodeError)
def test_webhook_timeout_error():
"""Test WebhookTimeoutError functionality."""
# Test inheritance
assert issubclass(WebhookTimeoutError, WebhookNodeError)
assert issubclass(WebhookTimeoutError, BaseNodeError)
# Test instantiation with message
error = WebhookTimeoutError("Webhook request timed out")
assert str(error) == "Webhook request timed out"
# Test instantiation without message
error_no_msg = WebhookTimeoutError()
assert isinstance(error_no_msg, WebhookTimeoutError)
def test_webhook_not_found_error():
"""Test WebhookNotFoundError functionality."""
# Test inheritance
assert issubclass(WebhookNotFoundError, WebhookNodeError)
assert issubclass(WebhookNotFoundError, BaseNodeError)
# Test instantiation with message
error = WebhookNotFoundError("Webhook trigger not found")
assert str(error) == "Webhook trigger not found"
# Test instantiation without message
error_no_msg = WebhookNotFoundError()
assert isinstance(error_no_msg, WebhookNotFoundError)
def test_webhook_config_error():
"""Test WebhookConfigError functionality."""
# Test inheritance
assert issubclass(WebhookConfigError, WebhookNodeError)
assert issubclass(WebhookConfigError, BaseNodeError)
# Test instantiation with message
error = WebhookConfigError("Invalid webhook configuration")
assert str(error) == "Invalid webhook configuration"
# Test instantiation without message
error_no_msg = WebhookConfigError()
assert isinstance(error_no_msg, WebhookConfigError)
def test_webhook_error_hierarchy():
"""Test the complete webhook error hierarchy."""
# All webhook errors should inherit from WebhookNodeError
webhook_errors = [
WebhookTimeoutError,
WebhookNotFoundError,
WebhookConfigError,
]
for error_class in webhook_errors:
assert issubclass(error_class, WebhookNodeError)
assert issubclass(error_class, BaseNodeError)
def test_webhook_error_instantiation_with_args():
"""Test webhook error instantiation with various arguments."""
# Test with single string argument
error1 = WebhookNodeError("Simple error message")
assert str(error1) == "Simple error message"
# Test with multiple arguments
error2 = WebhookTimeoutError("Timeout after", 30, "seconds")
# Note: The exact string representation depends on Exception.__str__ implementation
assert "Timeout after" in str(error2)
# Test with keyword arguments (if supported by base Exception)
error3 = WebhookConfigError("Config error in field: timeout")
assert "Config error in field: timeout" in str(error3)
def test_webhook_error_as_exceptions():
"""Test that webhook errors can be raised and caught properly."""
# Test raising and catching WebhookNodeError
with pytest.raises(WebhookNodeError) as exc_info:
raise WebhookNodeError("Base webhook error")
assert str(exc_info.value) == "Base webhook error"
# Test raising and catching specific errors
with pytest.raises(WebhookTimeoutError) as exc_info:
raise WebhookTimeoutError("Request timeout")
assert str(exc_info.value) == "Request timeout"
with pytest.raises(WebhookNotFoundError) as exc_info:
raise WebhookNotFoundError("Webhook not found")
assert str(exc_info.value) == "Webhook not found"
with pytest.raises(WebhookConfigError) as exc_info:
raise WebhookConfigError("Invalid config")
assert str(exc_info.value) == "Invalid config"
def test_webhook_error_catching_hierarchy():
"""Test that webhook errors can be caught by their parent classes."""
# WebhookTimeoutError should be catchable as WebhookNodeError
with pytest.raises(WebhookNodeError):
raise WebhookTimeoutError("Timeout error")
# WebhookNotFoundError should be catchable as WebhookNodeError
with pytest.raises(WebhookNodeError):
raise WebhookNotFoundError("Not found error")
# WebhookConfigError should be catchable as WebhookNodeError
with pytest.raises(WebhookNodeError):
raise WebhookConfigError("Config error")
# All webhook errors should be catchable as BaseNodeError
with pytest.raises(BaseNodeError):
raise WebhookTimeoutError("Timeout as base error")
with pytest.raises(BaseNodeError):
raise WebhookNotFoundError("Not found as base error")
with pytest.raises(BaseNodeError):
raise WebhookConfigError("Config as base error")
def test_webhook_error_attributes():
"""Test webhook error class attributes."""
# Test that all error classes have proper __name__
assert WebhookNodeError.__name__ == "WebhookNodeError"
assert WebhookTimeoutError.__name__ == "WebhookTimeoutError"
assert WebhookNotFoundError.__name__ == "WebhookNotFoundError"
assert WebhookConfigError.__name__ == "WebhookConfigError"
# Test that all error classes have proper __module__
expected_module = "core.workflow.nodes.webhook.exc"
assert WebhookNodeError.__module__ == expected_module
assert WebhookTimeoutError.__module__ == expected_module
assert WebhookNotFoundError.__module__ == expected_module
assert WebhookConfigError.__module__ == expected_module
def test_webhook_error_docstrings():
"""Test webhook error class docstrings."""
assert WebhookNodeError.__doc__ == "Base webhook node error."
assert WebhookTimeoutError.__doc__ == "Webhook timeout error."
assert WebhookNotFoundError.__doc__ == "Webhook not found error."
assert WebhookConfigError.__doc__ == "Webhook configuration error."
def test_webhook_error_repr_and_str():
"""Test webhook error string representations."""
error = WebhookNodeError("Test message")
# Test __str__ method
assert str(error) == "Test message"
# Test __repr__ method (should include class name)
repr_str = repr(error)
assert "WebhookNodeError" in repr_str
assert "Test message" in repr_str
def test_webhook_error_with_no_message():
"""Test webhook errors with no message."""
# Test that errors can be instantiated without messages
errors = [
WebhookNodeError(),
WebhookTimeoutError(),
WebhookNotFoundError(),
WebhookConfigError(),
]
for error in errors:
# Should be instances of their respective classes
assert isinstance(error, type(error))
# Should be able to be raised
with pytest.raises(type(error)):
raise error

View File

@ -0,0 +1,481 @@
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.file import File, FileTransferMethod, FileType
from core.variables import StringVariable
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState
from core.workflow.nodes.answer import AnswerStreamGenerateRoute
from core.workflow.nodes.end import EndStreamParam
from core.workflow.nodes.webhook import WebhookNode
from core.workflow.nodes.webhook.entities import (
ContentType,
Method,
WebhookBodyParameter,
WebhookData,
WebhookParameter,
)
from core.workflow.system_variable import SystemVariable
from models.enums import UserFrom
from models.workflow import WorkflowType
def create_webhook_node(webhook_data: WebhookData, variable_pool: VariablePool) -> WebhookNode:
"""Helper function to create a webhook node with proper initialization."""
node_config = {
"id": "1",
"data": webhook_data.model_dump(),
}
node = WebhookNode(
id="1",
config=node_config,
graph_init_params=GraphInitParams(
tenant_id="1",
app_id="1",
workflow_type=WorkflowType.WORKFLOW,
workflow_id="1",
graph_config={},
user_id="1",
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.SERVICE_API,
call_depth=0,
),
graph=Graph(
root_node_id="1",
answer_stream_generate_routes=AnswerStreamGenerateRoute(
answer_dependencies={},
answer_generate_route={},
),
end_stream_param=EndStreamParam(
end_dependencies={},
end_stream_variable_selector_mapping={},
),
),
graph_runtime_state=GraphRuntimeState(
variable_pool=variable_pool,
start_at=0,
),
)
node.init_node_data(node_config["data"])
return node
def test_webhook_node_basic_initialization():
"""Test basic webhook node initialization and configuration."""
data = WebhookData(
title="Test Webhook",
method=Method.POST,
content_type=ContentType.JSON,
headers=[WebhookParameter(name="X-API-Key", required=True)],
params=[WebhookParameter(name="version", required=False)],
body=[WebhookBodyParameter(name="message", type="string", required=True)],
status_code=200,
response_body="OK",
timeout=30,
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={},
)
node = create_webhook_node(data, variable_pool)
assert node._node_type.value == "webhook"
assert node.version() == "1"
assert node._get_title() == "Test Webhook"
assert node._node_data.method == Method.POST
assert node._node_data.content_type == ContentType.JSON
assert len(node._node_data.headers) == 1
assert len(node._node_data.params) == 1
assert len(node._node_data.body) == 1
def test_webhook_node_default_config():
"""Test webhook node default configuration."""
config = WebhookNode.get_default_config()
assert config["type"] == "webhook"
assert config["config"]["method"] == "get"
assert config["config"]["content-type"] == "application/json"
assert config["config"]["headers"] == []
assert config["config"]["params"] == []
assert config["config"]["body"] == []
assert config["config"]["async_mode"] is True
assert config["config"]["status_code"] == 200
assert config["config"]["response_body"] == ""
assert config["config"]["timeout"] == 30
def test_webhook_node_run_with_headers():
"""Test webhook node execution with header extraction."""
data = WebhookData(
title="Test Webhook",
headers=[
WebhookParameter(name="Authorization", required=True),
WebhookParameter(name="Content-Type", required=False),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {
"Authorization": "Bearer token123",
"content-type": "application/json", # Different case
"X-Custom": "custom-value",
},
"query_params": {},
"body": {},
"files": {},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["Authorization"] == "Bearer token123"
assert result.outputs["Content-Type"] == "application/json" # Case-insensitive match
assert "_webhook_raw" in result.outputs
def test_webhook_node_run_with_query_params():
"""Test webhook node execution with query parameter extraction."""
data = WebhookData(
title="Test Webhook",
params=[
WebhookParameter(name="page", required=True),
WebhookParameter(name="limit", required=False),
WebhookParameter(name="missing", required=False),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {},
"query_params": {
"page": "1",
"limit": "10",
},
"body": {},
"files": {},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["page"] == "1"
assert result.outputs["limit"] == "10"
assert result.outputs["missing"] is None # Missing parameter should be None
def test_webhook_node_run_with_body_params():
"""Test webhook node execution with body parameter extraction."""
data = WebhookData(
title="Test Webhook",
body=[
WebhookBodyParameter(name="message", type="string", required=True),
WebhookBodyParameter(name="count", type="number", required=False),
WebhookBodyParameter(name="active", type="boolean", required=False),
WebhookBodyParameter(name="metadata", type="object", required=False),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {},
"query_params": {},
"body": {
"message": "Hello World",
"count": 42,
"active": True,
"metadata": {"key": "value"},
},
"files": {},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["message"] == "Hello World"
assert result.outputs["count"] == 42
assert result.outputs["active"] is True
assert result.outputs["metadata"] == {"key": "value"}
def test_webhook_node_run_with_file_params():
"""Test webhook node execution with file parameter extraction."""
# Create mock file objects
file1 = File(
tenant_id="1",
type=FileType.IMAGE,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="file1",
filename="image.jpg",
mime_type="image/jpeg",
storage_key="",
)
file2 = File(
tenant_id="1",
type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="file2",
filename="document.pdf",
mime_type="application/pdf",
storage_key="",
)
data = WebhookData(
title="Test Webhook",
body=[
WebhookBodyParameter(name="upload", type="file", required=True),
WebhookBodyParameter(name="document", type="file", required=False),
WebhookBodyParameter(name="missing_file", type="file", required=False),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {},
"query_params": {},
"body": {},
"files": {
"upload": file1,
"document": file2,
},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["upload"] == file1
assert result.outputs["document"] == file2
assert result.outputs["missing_file"] is None
def test_webhook_node_run_mixed_parameters():
"""Test webhook node execution with mixed parameter types."""
file_obj = File(
tenant_id="1",
type=FileType.IMAGE,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="file1",
filename="test.jpg",
mime_type="image/jpeg",
storage_key="",
)
data = WebhookData(
title="Test Webhook",
headers=[WebhookParameter(name="Authorization", required=True)],
params=[WebhookParameter(name="version", required=False)],
body=[
WebhookBodyParameter(name="message", type="string", required=True),
WebhookBodyParameter(name="upload", type="file", required=False),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {"Authorization": "Bearer token"},
"query_params": {"version": "v1"},
"body": {"message": "Test message"},
"files": {"upload": file_obj},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["Authorization"] == "Bearer token"
assert result.outputs["version"] == "v1"
assert result.outputs["message"] == "Test message"
assert result.outputs["upload"] == file_obj
assert "_webhook_raw" in result.outputs
def test_webhook_node_run_empty_webhook_data():
"""Test webhook node execution with empty webhook data."""
data = WebhookData(
title="Test Webhook",
headers=[WebhookParameter(name="Authorization", required=False)],
params=[WebhookParameter(name="page", required=False)],
body=[WebhookBodyParameter(name="message", type="string", required=False)],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={}, # No webhook_data
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["Authorization"] is None
assert result.outputs["page"] is None
assert result.outputs["message"] is None
assert result.outputs["_webhook_raw"] == {}
def test_webhook_node_run_case_insensitive_headers():
"""Test webhook node header extraction is case-insensitive."""
data = WebhookData(
title="Test Webhook",
headers=[
WebhookParameter(name="Content-Type", required=True),
WebhookParameter(name="X-API-KEY", required=True),
WebhookParameter(name="authorization", required=True),
],
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {
"content-type": "application/json", # lowercase
"x-api-key": "key123", # lowercase
"Authorization": "Bearer token", # different case
},
"query_params": {},
"body": {},
"files": {},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert result.outputs["Content-Type"] == "application/json"
assert result.outputs["X-API-KEY"] == "key123"
assert result.outputs["authorization"] == "Bearer token"
def test_webhook_node_variable_pool_user_inputs():
"""Test that webhook node uses user_inputs from variable pool correctly."""
data = WebhookData(title="Test Webhook")
# Add some additional variables to the pool
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {"headers": {}, "query_params": {}, "body": {}, "files": {}},
"other_var": "should_be_included",
},
)
variable_pool.add(["node1", "extra"], StringVariable(name="extra", value="extra_value"))
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
# Check that all user_inputs are included in the inputs (they get converted to dict)
inputs_dict = dict(result.inputs)
assert "webhook_data" in inputs_dict
assert "other_var" in inputs_dict
assert inputs_dict["other_var"] == "should_be_included"
@pytest.mark.parametrize(
"method",
[Method.GET, Method.POST, Method.PUT, Method.DELETE, Method.PATCH, Method.HEAD],
)
def test_webhook_node_different_methods(method):
"""Test webhook node with different HTTP methods."""
data = WebhookData(
title="Test Webhook",
method=method,
)
variable_pool = VariablePool(
system_variables=SystemVariable.empty(),
user_inputs={
"webhook_data": {
"headers": {},
"query_params": {},
"body": {},
"files": {},
}
},
)
node = create_webhook_node(data, variable_pool)
result = node._run()
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
assert node._node_data.method == method
def test_webhook_data_alias_content_type():
"""Test that content-type field alias works correctly."""
# Test both ways of setting content_type
data1 = WebhookData(title="Test", **{"content-type": "application/json"})
assert data1.content_type == ContentType.JSON
data2 = WebhookData(title="Test", **{"content-type": ContentType.FORM_DATA})
assert data2.content_type == ContentType.FORM_DATA
def test_webhook_parameter_models():
"""Test webhook parameter model validation."""
# Test WebhookParameter
param = WebhookParameter(name="test_param", required=True)
assert param.name == "test_param"
assert param.required is True
param_default = WebhookParameter(name="test_param")
assert param_default.required is False
# Test WebhookBodyParameter
body_param = WebhookBodyParameter(name="test_body", type="string", required=True)
assert body_param.name == "test_body"
assert body_param.type == "string"
assert body_param.required is True
body_param_default = WebhookBodyParameter(name="test_body")
assert body_param_default.type == "string" # Default type
assert body_param_default.required is False
def test_webhook_data_field_defaults():
"""Test webhook data model field defaults."""
data = WebhookData(title="Minimal Webhook")
assert data.method == Method.GET
assert data.content_type == ContentType.JSON
assert data.headers == []
assert data.params == []
assert data.body == []
assert data.status_code == 200
assert data.response_body == ""
assert data.webhook_id is None
assert data.timeout == 30

View File

@ -0,0 +1,368 @@
from io import BytesIO
from unittest.mock import MagicMock, patch
from flask import Flask
from werkzeug.datastructures import FileStorage
from services.webhook_service import WebhookService
class TestWebhookServiceUnit:
"""Unit tests for WebhookService focusing on business logic without database dependencies."""
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"
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_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", "upload": file_storage},
):
webhook_trigger = MagicMock()
webhook_trigger.tenant_id = "test_tenant"
with patch.object(WebhookService, "_process_file_uploads") as mock_process_files:
mock_process_files.return_value = {"upload": "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"]["upload"] == "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_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()
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
assert webhook_data["method"] == "POST"
assert webhook_data["body"] == {} # Should default to empty dict
def test_validate_webhook_request_success(self):
"""Test successful webhook request validation."""
webhook_data = {
"method": "POST",
"headers": {"Authorization": "Bearer token", "Content-Type": "application/json"},
"query_params": {"version": "1"},
"body": {"message": "hello"},
"files": {},
}
node_config = {
"data": {
"method": "post",
"headers": [{"name": "Authorization", "required": True}, {"name": "Content-Type", "required": False}],
"params": [{"name": "version", "required": True}],
"body": [{"name": "message", "type": "string", "required": True}],
}
}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is True
def test_validate_webhook_request_method_mismatch(self):
"""Test webhook validation with HTTP method mismatch."""
webhook_data = {"method": "GET", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post"}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "HTTP method mismatch" in result["error"]
assert "Expected POST, got GET" in result["error"]
def test_validate_webhook_request_missing_required_header(self):
"""Test webhook validation with missing required header."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "headers": [{"name": "Authorization", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required header missing: Authorization" in result["error"]
def test_validate_webhook_request_case_insensitive_headers(self):
"""Test webhook validation with case-insensitive header matching."""
webhook_data = {
"method": "POST",
"headers": {"authorization": "Bearer token"}, # lowercase
"query_params": {},
"body": {},
"files": {},
}
node_config = {
"data": {
"method": "post",
"headers": [
{"name": "Authorization", "required": True} # Pascal case
],
}
}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is True
def test_validate_webhook_request_missing_required_param(self):
"""Test webhook validation with missing required query parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "params": [{"name": "version", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required query parameter missing: version" in result["error"]
def test_validate_webhook_request_missing_required_body_param(self):
"""Test webhook validation with missing required body parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "body": [{"name": "message", "type": "string", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required body parameter missing: message" in result["error"]
def test_validate_webhook_request_missing_required_file(self):
"""Test webhook validation with missing required file parameter."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
node_config = {"data": {"method": "post", "body": [{"name": "upload", "type": "file", "required": True}]}}
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Required file parameter missing: upload" in result["error"]
def test_validate_webhook_request_validation_exception(self):
"""Test webhook validation with exception handling."""
webhook_data = {"method": "POST", "headers": {}, "query_params": {}, "body": {}, "files": {}}
# Invalid node config that will cause an exception
node_config = None
result = WebhookService.validate_webhook_request(webhook_data, node_config)
assert result["valid"] is False
assert "Validation failed:" in result["error"]
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.webhook_service.ToolFileManager")
@patch("services.webhook_service.file_factory")
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 = MagicMock()
mock_tool_file_manager.return_value = mock_tool_file_instance
# 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"].read.return_value = b"content1"
files["file2"].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.webhook_service.ToolFileManager")
@patch("services.webhook_service.file_factory")
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 = MagicMock()
mock_tool_file_manager.return_value = mock_tool_file_instance
# 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"].read.return_value = b"content"
files["bad_file"].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