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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2615 additions and 6 deletions

View File

@ -67,6 +67,7 @@ from .app import (
workflow_draft_variable,
workflow_run,
workflow_statistic,
workflow_trigger,
)
# Import auth controllers

View File

@ -0,0 +1,151 @@
import logging
import secrets
from flask_restful import Resource, reqparse
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from configs import dify_config
from controllers.console import api
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db
from libs.login import current_user, login_required
from models.workflow import WorkflowWebhookTrigger
logger = logging.getLogger(__name__)
class WebhookTriggerApi(Resource):
"""Webhook Trigger API"""
@setup_required
@login_required
@account_initialization_required
@get_app_model
def post(self, app_model):
"""Create webhook trigger"""
parser = reqparse.RequestParser()
parser.add_argument("node_id", type=str, required=True, help="Node ID is required")
parser.add_argument(
"triggered_by",
type=str,
required=False,
default="production",
choices=["debugger", "production"],
help="triggered_by must be debugger or production",
)
args = parser.parse_args()
if app_model.mode != "workflow":
raise BadRequest("Invalid app mode, only workflow can add webhook node")
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()
node_id = args["node_id"]
triggered_by = args["triggered_by"]
with Session(db.engine) as session:
# Check if webhook trigger already exists for this app, node, and environment
existing_trigger = (
session.query(WorkflowWebhookTrigger)
.filter(
WorkflowWebhookTrigger.app_id == app_model.id,
WorkflowWebhookTrigger.node_id == node_id,
WorkflowWebhookTrigger.triggered_by == triggered_by,
)
.first()
)
if existing_trigger:
raise BadRequest("Webhook trigger already exists for this node and environment")
# Generate unique webhook_id
webhook_id = self._generate_webhook_id(session)
# Create new webhook trigger
webhook_trigger = WorkflowWebhookTrigger(
app_id=app_model.id,
node_id=node_id,
tenant_id=current_user.current_tenant_id,
webhook_id=webhook_id,
triggered_by=triggered_by,
)
session.add(webhook_trigger)
session.commit()
session.refresh(webhook_trigger)
return {
"id": webhook_trigger.id,
"webhook_id": webhook_trigger.webhook_id,
"webhook_url": f"{dify_config.SERVICE_API_URL}/triggers/webhook/{webhook_trigger.webhook_id}",
"node_id": webhook_trigger.node_id,
"triggered_by": webhook_trigger.triggered_by,
"created_at": webhook_trigger.created_at.isoformat(),
}
@setup_required
@login_required
@account_initialization_required
@get_app_model
def delete(self, app_model):
"""Delete webhook trigger"""
parser = reqparse.RequestParser()
parser.add_argument("node_id", type=str, required=True, help="Node ID is required")
parser.add_argument(
"triggered_by",
type=str,
required=False,
default="production",
choices=["debugger", "production"],
help="triggered_by must be debugger or production",
)
args = parser.parse_args()
# The role of the current user in the ta table must be admin, owner, or editor
if not current_user.is_editor:
raise Forbidden()
node_id = args["node_id"]
triggered_by = args["triggered_by"]
with Session(db.engine) as session:
# Find webhook trigger
webhook_trigger = (
session.query(WorkflowWebhookTrigger)
.filter(
WorkflowWebhookTrigger.app_id == app_model.id,
WorkflowWebhookTrigger.node_id == node_id,
WorkflowWebhookTrigger.triggered_by == triggered_by,
WorkflowWebhookTrigger.tenant_id == current_user.current_tenant_id,
)
.first()
)
if not webhook_trigger:
raise NotFound("Webhook trigger not found")
session.delete(webhook_trigger)
session.commit()
return {"result": "success"}, 204
def _generate_webhook_id(self, session: Session) -> str:
"""Generate unique 24-character webhook ID"""
while True:
# Generate 24-character random string
webhook_id = secrets.token_urlsafe(18)[:24] # token_urlsafe gives base64url, take first 24 chars
# Check if it already exists
existing = (
session.query(WorkflowWebhookTrigger).filter(WorkflowWebhookTrigger.webhook_id == webhook_id).first()
)
if not existing:
return webhook_id
api.add_resource(WebhookTriggerApi, "/apps/<uuid:app_id>/workflows/triggers/webhook")

View File

@ -0,0 +1,7 @@
from flask import Blueprint
# Create trigger blueprint
bp = Blueprint("trigger", __name__, url_prefix="/triggers")
# Import routes after blueprint creation to avoid circular imports
from . import webhook

View File

@ -0,0 +1,43 @@
import logging
from flask import jsonify
from werkzeug.exceptions import BadRequest, NotFound
from controllers.trigger import bp
from services.webhook_service import WebhookService
logger = logging.getLogger(__name__)
@bp.route("/webhook/<string:webhook_id>", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"])
def handle_webhook(webhook_id: str):
"""
Handle webhook trigger calls.
This endpoint receives webhook calls and processes them according to the
configured webhook trigger settings.
"""
try:
# Get webhook trigger, workflow, and node configuration
webhook_trigger, workflow, node_config = WebhookService.get_webhook_trigger_and_workflow(webhook_id)
# Extract request data
webhook_data = WebhookService.extract_webhook_data(webhook_trigger)
# Validate request against node configuration
validation_result = WebhookService.validate_webhook_request(webhook_data, node_config)
if not validation_result["valid"]:
raise BadRequest(validation_result["error"])
# Process webhook call (send to Celery)
WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)
# Return configured response
response_data, status_code = WebhookService.generate_webhook_response(node_config)
return jsonify(response_data), status_code
except ValueError as e:
raise NotFound(str(e))
except Exception as e:
logger.exception(f"Webhook processing failed for {webhook_id}: {str(e)}")
return jsonify({"error": "Internal server error", "message": str(e)}), 500

View File

@ -138,17 +138,20 @@ class WorkflowAppGenerator(BaseAppGenerator):
**extract_external_trace_id_from_args(args),
}
workflow_run_id = str(uuid.uuid4())
if triggered_from in (WorkflowRunTriggeredFrom.DEBUGGING, WorkflowRunTriggeredFrom.APP_RUN):
# start node get inputs
inputs = self._prepare_user_inputs(
user_inputs=inputs,
variables=app_config.variables,
tenant_id=app_model.tenant_id,
strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False,
)
# init application generate entity
application_generate_entity = WorkflowAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
file_upload_config=file_extra_config,
inputs=self._prepare_user_inputs(
user_inputs=inputs,
variables=app_config.variables,
tenant_id=app_model.tenant_id,
strict_type_validation=True if invoke_from == InvokeFrom.SERVICE_API else False,
),
inputs=inputs,
files=list(system_files),
user_id=user.id,
stream=streaming,

View File

@ -25,6 +25,7 @@ class NodeType(StrEnum):
DOCUMENT_EXTRACTOR = "document-extractor"
LIST_OPERATOR = "list-operator"
AGENT = "agent"
WEBHOOK = "webhook"
class ErrorStrategy(StrEnum):

View File

@ -22,6 +22,7 @@ from core.workflow.nodes.tool import ToolNode
from core.workflow.nodes.variable_aggregator import VariableAggregatorNode
from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode as VariableAssignerNodeV1
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2
from core.workflow.nodes.webhook import WebhookNode
LATEST_VERSION = "latest"
@ -132,4 +133,8 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
"2": AgentNode,
"1": AgentNode,
},
NodeType.WEBHOOK: {
LATEST_VERSION: WebhookNode,
"1": WebhookNode,
},
}

View File

@ -0,0 +1,3 @@
from .node import WebhookNode
__all__ = ["WebhookNode"]

View File

@ -0,0 +1,61 @@
from collections.abc import Sequence
from enum import StrEnum
from typing import Literal, Optional
from pydantic import BaseModel, Field
from core.workflow.nodes.base import BaseNodeData
class Method(StrEnum):
GET = "get"
POST = "post"
HEAD = "head"
PATCH = "patch"
PUT = "put"
DELETE = "delete"
class ContentType(StrEnum):
JSON = "application/json"
FORM_DATA = "multipart/form-data"
FORM_URLENCODED = "application/x-www-form-urlencoded"
TEXT = "text/plain"
FORM = "form"
class WebhookParameter(BaseModel):
"""Parameter definition for headers, query params, or body."""
name: str
required: bool = False
class WebhookBodyParameter(BaseModel):
"""Body parameter with type information."""
name: str
type: Literal["string", "number", "boolean", "object", "array", "file"] = "string"
required: bool = False
class WebhookData(BaseNodeData):
"""
Webhook Node Data.
"""
class SyncMode(StrEnum):
SYNC = "async" # only support
method: Method = Method.GET
content_type: ContentType = Field(alias="content-type", default=ContentType.JSON)
headers: Sequence[WebhookParameter] = Field(default_factory=list)
params: Sequence[WebhookParameter] = Field(default_factory=list) # query parameters
body: Sequence[WebhookBodyParameter] = Field(default_factory=list)
status_code: int = 200 # Expected status code for response
response_body: str = "" # Template for response body
# Webhook specific fields (not from client data, set internally)
webhook_id: Optional[str] = None # Set when webhook trigger is created
timeout: int = 30 # Timeout in seconds to wait for webhook response

View File

@ -0,0 +1,25 @@
from core.workflow.nodes.base.exc import BaseNodeError
class WebhookNodeError(BaseNodeError):
"""Base webhook node error."""
pass
class WebhookTimeoutError(WebhookNodeError):
"""Webhook timeout error."""
pass
class WebhookNotFoundError(WebhookNodeError):
"""Webhook not found error."""
pass
class WebhookConfigError(WebhookNodeError):
"""Webhook configuration error."""
pass

View File

@ -0,0 +1,118 @@
from collections.abc import Mapping
from typing import Any, Optional
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.enums import ErrorStrategy, NodeType
from .entities import WebhookData
class WebhookNode(BaseNode):
_node_type = NodeType.WEBHOOK
_node_data: WebhookData
def init_node_data(self, data: Mapping[str, Any]) -> None:
self._node_data = WebhookData.model_validate(data)
def _get_error_strategy(self) -> Optional[ErrorStrategy]:
return self._node_data.error_strategy
def _get_retry_config(self) -> RetryConfig:
return self._node_data.retry_config
def _get_title(self) -> str:
return self._node_data.title
def _get_description(self) -> Optional[str]:
return self._node_data.desc
def _get_default_value_dict(self) -> dict[str, Any]:
return self._node_data.default_value_dict
def get_base_node_data(self) -> BaseNodeData:
return self._node_data
@classmethod
def get_default_config(cls, filters: Optional[dict[str, Any]] = None) -> dict:
return {
"type": "webhook",
"config": {
"method": "get",
"content-type": "application/json",
"headers": [],
"params": [],
"body": [],
"async_mode": True,
"status_code": 200,
"response_body": "",
"timeout": 30,
},
}
@classmethod
def version(cls) -> str:
return "1"
def _run(self) -> NodeRunResult:
"""
Run the webhook node.
Like the start node, this simply takes the webhook data from the variable pool
and makes it available to downstream nodes. The actual webhook handling
happens in the trigger controller.
"""
# Get webhook data from variable pool (injected by Celery task)
webhook_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs)
# Extract webhook-specific outputs based on node configuration
outputs = self._extract_configured_outputs(webhook_inputs)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=webhook_inputs,
outputs=outputs,
)
def _extract_configured_outputs(self, webhook_inputs: dict[str, Any]) -> dict[str, Any]:
"""Extract outputs based on node configuration from webhook inputs."""
outputs = {}
# Get the raw webhook data (should be injected by Celery task)
webhook_data = webhook_inputs.get("webhook_data", {})
# Extract configured headers (case-insensitive)
webhook_headers = webhook_data.get("headers", {})
webhook_headers_lower = {k.lower(): v for k, v in webhook_headers.items()}
for header in self._node_data.headers:
header_name = header.name
# Try exact match first, then case-insensitive match
value = webhook_headers.get(header_name) or webhook_headers_lower.get(header_name.lower())
outputs[header_name] = value
# Extract configured query parameters
for param in self._node_data.params:
param_name = param.name
outputs[param_name] = webhook_data.get("query_params", {}).get(param_name)
# Extract configured body parameters
for body_param in self._node_data.body:
param_name = body_param.name
param_type = body_param.type
if param_type == "file":
# Get File object (already processed by webhook controller)
file_obj = webhook_data.get("files", {}).get(param_name)
outputs[param_name] = file_obj
else:
# Get regular body parameter
outputs[param_name] = webhook_data.get("body", {}).get(param_name)
# Include raw webhook data for debugging/advanced use
outputs["_webhook_raw"] = webhook_data
return outputs

View File

@ -12,6 +12,7 @@ def init_app(app: DifyApp):
from controllers.inner_api import bp as inner_api_bp
from controllers.mcp import bp as mcp_bp
from controllers.service_api import bp as service_api_bp
from controllers.trigger import bp as trigger_bp
from controllers.web import bp as web_bp
CORS(
@ -48,3 +49,11 @@ def init_app(app: DifyApp):
app.register_blueprint(inner_api_bp)
app.register_blueprint(mcp_bp)
# Register trigger blueprint with CORS for webhook calls
CORS(
trigger_bp,
allow_headers=["Content-Type", "Authorization", "X-App-Code"],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"],
)
app.register_blueprint(trigger_bp)

View File

@ -0,0 +1,47 @@
"""Add workflow webhook table
Revision ID: 5871f634954d
Revises: fa8b0fa6f407
Create Date: 2025-08-23 20:39:20.704501
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5871f634954d'
down_revision = '4558cfabe44e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('workflow_webhook_triggers',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('node_id', sa.String(length=64), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('webhook_id', sa.String(length=24), nullable=False),
sa.Column('triggered_by', sa.String(length=16), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='workflow_webhook_trigger_pkey'),
sa.UniqueConstraint('app_id', 'node_id', 'triggered_by', name='uniq_node'),
sa.UniqueConstraint('webhook_id', name='uniq_webhook_id')
)
with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op:
batch_op.create_index('workflow_webhook_trigger_tenant_idx', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('workflow_webhook_triggers', schema=None) as batch_op:
batch_op.drop_index('workflow_webhook_trigger_tenant_idx')
op.drop_table('workflow_webhook_triggers')
# ### end Alembic commands ###

View File

@ -1383,3 +1383,41 @@ class WorkflowTriggerLog(Base):
"triggered_at": self.triggered_at.isoformat() if self.triggered_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
}
class WorkflowWebhookTrigger(Base):
"""
Workflow Webhook Trigger
Attributes:
- id (uuid) Primary key
- app_id (uuid) App ID to bind to a specific app
- node_id (varchar) Node ID which node in the workflow
- tenant_id (uuid) Workspace ID
- webhook_id (varchar) Webhook ID for URL: https://api.dify.ai/triggers/webhook/:webhook_id
- triggered_by (varchar) Environment: debugger or production
- created_at (timestamp) Creation time
- updated_at (timestamp) Last update time
"""
__tablename__ = "workflow_webhook_triggers"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="workflow_webhook_trigger_pkey"),
sa.Index("workflow_webhook_trigger_tenant_idx", "tenant_id"),
sa.UniqueConstraint("app_id", "node_id", "triggered_by", name="uniq_node"),
sa.UniqueConstraint("webhook_id", name="uniq_webhook_id"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
node_id: Mapped[str] = mapped_column(String(64), nullable=False)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
webhook_id: Mapped[str] = mapped_column(String(24), nullable=False)
triggered_by: Mapped[str] = mapped_column(String(16), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
server_onupdate=func.current_timestamp(),
)

View File

@ -0,0 +1,262 @@
import logging
from typing import Any
from flask import request
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.file.models import FileTransferMethod
from core.tools.tool_file_manager import ToolFileManager
from extensions.ext_database import db
from factories import file_factory
from models.account import Account, TenantAccountJoin, TenantAccountRole
from models.enums import WorkflowRunTriggeredFrom
from models.workflow import Workflow, WorkflowWebhookTrigger
from services.async_workflow_service import AsyncWorkflowService
from services.workflow.entities import TriggerData
logger = logging.getLogger(__name__)
class WebhookService:
"""Service for handling webhook operations."""
@classmethod
def get_webhook_trigger_and_workflow(
cls, webhook_id: str
) -> tuple[WorkflowWebhookTrigger, Workflow, dict[str, Any]]:
"""Get webhook trigger, workflow, and node configuration."""
with Session(db.engine) as session:
# Get webhook trigger
webhook_trigger = (
session.query(WorkflowWebhookTrigger).filter(WorkflowWebhookTrigger.webhook_id == webhook_id).first()
)
if not webhook_trigger:
raise ValueError(f"Webhook not found: {webhook_id}")
# Get workflow
workflow = (
session.query(Workflow)
.filter(
Workflow.app_id == webhook_trigger.app_id,
Workflow.version != Workflow.VERSION_DRAFT,
)
.order_by(Workflow.created_at.desc())
.first()
)
if not workflow:
raise ValueError(f"Workflow not found for app {webhook_trigger.app_id}")
node_config = workflow.get_node_config_by_id(webhook_trigger.node_id)
return webhook_trigger, workflow, node_config
@classmethod
def extract_webhook_data(cls, webhook_trigger: WorkflowWebhookTrigger) -> dict[str, Any]:
"""Extract and process data from incoming webhook request."""
data = {
"method": request.method,
"headers": dict(request.headers),
"query_params": dict(request.args),
"body": {},
"files": {},
}
content_type = request.headers.get("Content-Type", "").lower()
# Extract body data based on content type
if "application/json" in content_type:
try:
data["body"] = request.get_json() or {}
except Exception:
data["body"] = {}
elif "application/x-www-form-urlencoded" in content_type:
data["body"] = dict(request.form)
elif "multipart/form-data" in content_type:
data["body"] = dict(request.form)
# Handle file uploads
if request.files:
data["files"] = cls._process_file_uploads(request.files, webhook_trigger)
else:
# Raw text data
try:
data["body"] = {"raw": request.get_data(as_text=True)}
except Exception:
data["body"] = {"raw": ""}
return data
@classmethod
def _process_file_uploads(cls, files, webhook_trigger: WorkflowWebhookTrigger) -> dict[str, Any]:
"""Process file uploads using ToolFileManager."""
processed_files = {}
for name, file in files.items():
if file and file.filename:
try:
tool_file_manager = ToolFileManager()
file_content = file.read()
# Create file using ToolFileManager
tool_file = tool_file_manager.create_file_by_raw(
user_id="webhook_user",
tenant_id=webhook_trigger.tenant_id,
conversation_id=None,
file_binary=file_content,
mimetype=file.content_type or "application/octet-stream",
)
# Build File object
mapping = {
"tool_file_id": tool_file.id,
"transfer_method": FileTransferMethod.TOOL_FILE.value,
}
file_obj = file_factory.build_from_mapping(
mapping=mapping,
tenant_id=webhook_trigger.tenant_id,
)
processed_files[name] = file_obj
except Exception as e:
logger.exception(f"Failed to process file upload {name}: {str(e)}")
# Continue processing other files
return processed_files
@classmethod
def validate_webhook_request(cls, webhook_data: dict[str, Any], node_config: dict[str, Any]) -> dict[str, Any]:
"""Validate webhook request against node configuration."""
try:
node_data = node_config.get("data", {})
# Validate HTTP method
configured_method = node_data.get("method", "get").upper()
request_method = webhook_data["method"].upper()
if configured_method != request_method:
return {
"valid": False,
"error": f"HTTP method mismatch. Expected {configured_method}, got {request_method}",
}
# Validate required headers (case-insensitive)
headers = node_data.get("headers", [])
# Create case-insensitive header lookup
webhook_headers_lower = {k.lower(): v for k, v in webhook_data["headers"].items()}
for header in headers:
if header.get("required", False):
header_name = header.get("name", "")
if header_name.lower() not in webhook_headers_lower:
return {"valid": False, "error": f"Required header missing: {header_name}"}
# Validate required query parameters
params = node_data.get("params", [])
for param in params:
if param.get("required", False):
param_name = param.get("name", "")
if param_name not in webhook_data["query_params"]:
return {"valid": False, "error": f"Required query parameter missing: {param_name}"}
# Validate required body parameters
body_params = node_data.get("body", [])
for body_param in body_params:
if body_param.get("required", False):
param_name = body_param.get("name", "")
param_type = body_param.get("type", "string")
# Check if parameter exists
if param_type == "file":
file_obj = webhook_data.get("files", {}).get(param_name)
if not file_obj:
return {"valid": False, "error": f"Required file parameter missing: {param_name}"}
else:
if param_name not in webhook_data.get("body", {}):
return {"valid": False, "error": f"Required body parameter missing: {param_name}"}
return {"valid": True}
except Exception as e:
logger.exception(f"Validation error: {str(e)}")
return {"valid": False, "error": f"Validation failed: {str(e)}"}
@classmethod
def trigger_workflow_execution(
cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow
) -> None:
"""Trigger workflow execution via AsyncWorkflowService."""
try:
with Session(db.engine) as session:
# Get tenant owner as the user for webhook execution
tenant_owner = session.scalar(
select(Account)
.join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
.where(
TenantAccountJoin.tenant_id == webhook_trigger.tenant_id,
TenantAccountJoin.role == TenantAccountRole.OWNER,
)
)
if not tenant_owner:
logger.error(f"Tenant owner not found for tenant {webhook_trigger.tenant_id}")
raise ValueError("Tenant owner not found")
# Prepare inputs for the webhook node
# The webhook node expects webhook_data in the inputs
workflow_inputs = {
"webhook_data": webhook_data,
"webhook_headers": webhook_data.get("headers", {}),
"webhook_query_params": webhook_data.get("query_params", {}),
"webhook_body": webhook_data.get("body", {}),
"webhook_files": webhook_data.get("files", {}),
}
# Create trigger data
trigger_data = TriggerData(
app_id=webhook_trigger.app_id,
workflow_id=workflow.id,
root_node_id=webhook_trigger.node_id, # Start from the webhook node
trigger_type=WorkflowRunTriggeredFrom.WEBHOOK,
inputs=workflow_inputs,
tenant_id=webhook_trigger.tenant_id,
)
# Trigger workflow execution asynchronously
AsyncWorkflowService.trigger_workflow_async(
session,
tenant_owner,
trigger_data,
)
except Exception as e:
logger.exception(f"Failed to trigger workflow for webhook {webhook_trigger.webhook_id}: {str(e)}")
raise
@classmethod
def generate_webhook_response(cls, node_config: dict[str, Any]) -> tuple[dict[str, Any], int]:
"""Generate HTTP response based on node configuration."""
import json
node_data = node_config.get("data", {})
# Get configured status code and response body
status_code = node_data.get("status_code", 200)
response_body = node_data.get("response_body", "")
# Parse response body as JSON if it's valid JSON, otherwise return as text
try:
if response_body:
try:
response_data = (
json.loads(response_body)
if response_body.strip().startswith(("{", "["))
else {"message": response_body}
)
except json.JSONDecodeError:
response_data = {"message": response_body}
else:
response_data = {"status": "success", "message": "Webhook processed successfully"}
except:
response_data = {"message": response_body or "Webhook processed successfully"}
return response_data, status_code

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