mirror of
https://github.com/langgenius/dify.git
synced 2026-04-29 12:37:20 +08:00
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:
parent
7129de98cd
commit
a63d1e87b1
@ -67,6 +67,7 @@ from .app import (
|
|||||||
workflow_draft_variable,
|
workflow_draft_variable,
|
||||||
workflow_run,
|
workflow_run,
|
||||||
workflow_statistic,
|
workflow_statistic,
|
||||||
|
workflow_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Import auth controllers
|
# Import auth controllers
|
||||||
|
|||||||
151
api/controllers/console/app/workflow_trigger.py
Normal file
151
api/controllers/console/app/workflow_trigger.py
Normal 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")
|
||||||
7
api/controllers/trigger/__init__.py
Normal file
7
api/controllers/trigger/__init__.py
Normal 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
|
||||||
43
api/controllers/trigger/webhook.py
Normal file
43
api/controllers/trigger/webhook.py
Normal 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
|
||||||
@ -138,17 +138,20 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
|||||||
**extract_external_trace_id_from_args(args),
|
**extract_external_trace_id_from_args(args),
|
||||||
}
|
}
|
||||||
workflow_run_id = str(uuid.uuid4())
|
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
|
# init application generate entity
|
||||||
application_generate_entity = WorkflowAppGenerateEntity(
|
application_generate_entity = WorkflowAppGenerateEntity(
|
||||||
task_id=str(uuid.uuid4()),
|
task_id=str(uuid.uuid4()),
|
||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
file_upload_config=file_extra_config,
|
file_upload_config=file_extra_config,
|
||||||
inputs=self._prepare_user_inputs(
|
inputs=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,
|
|
||||||
),
|
|
||||||
files=list(system_files),
|
files=list(system_files),
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
stream=streaming,
|
stream=streaming,
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class NodeType(StrEnum):
|
|||||||
DOCUMENT_EXTRACTOR = "document-extractor"
|
DOCUMENT_EXTRACTOR = "document-extractor"
|
||||||
LIST_OPERATOR = "list-operator"
|
LIST_OPERATOR = "list-operator"
|
||||||
AGENT = "agent"
|
AGENT = "agent"
|
||||||
|
WEBHOOK = "webhook"
|
||||||
|
|
||||||
|
|
||||||
class ErrorStrategy(StrEnum):
|
class ErrorStrategy(StrEnum):
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from core.workflow.nodes.tool import ToolNode
|
|||||||
from core.workflow.nodes.variable_aggregator import VariableAggregatorNode
|
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.v1 import VariableAssignerNode as VariableAssignerNodeV1
|
||||||
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2
|
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2
|
||||||
|
from core.workflow.nodes.webhook import WebhookNode
|
||||||
|
|
||||||
LATEST_VERSION = "latest"
|
LATEST_VERSION = "latest"
|
||||||
|
|
||||||
@ -132,4 +133,8 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
|
|||||||
"2": AgentNode,
|
"2": AgentNode,
|
||||||
"1": AgentNode,
|
"1": AgentNode,
|
||||||
},
|
},
|
||||||
|
NodeType.WEBHOOK: {
|
||||||
|
LATEST_VERSION: WebhookNode,
|
||||||
|
"1": WebhookNode,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
3
api/core/workflow/nodes/webhook/__init__.py
Normal file
3
api/core/workflow/nodes/webhook/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .node import WebhookNode
|
||||||
|
|
||||||
|
__all__ = ["WebhookNode"]
|
||||||
61
api/core/workflow/nodes/webhook/entities.py
Normal file
61
api/core/workflow/nodes/webhook/entities.py
Normal 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
|
||||||
25
api/core/workflow/nodes/webhook/exc.py
Normal file
25
api/core/workflow/nodes/webhook/exc.py
Normal 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
|
||||||
118
api/core/workflow/nodes/webhook/node.py
Normal file
118
api/core/workflow/nodes/webhook/node.py
Normal 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
|
||||||
@ -12,6 +12,7 @@ def init_app(app: DifyApp):
|
|||||||
from controllers.inner_api import bp as inner_api_bp
|
from controllers.inner_api import bp as inner_api_bp
|
||||||
from controllers.mcp import bp as mcp_bp
|
from controllers.mcp import bp as mcp_bp
|
||||||
from controllers.service_api import bp as service_api_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
|
from controllers.web import bp as web_bp
|
||||||
|
|
||||||
CORS(
|
CORS(
|
||||||
@ -48,3 +49,11 @@ def init_app(app: DifyApp):
|
|||||||
|
|
||||||
app.register_blueprint(inner_api_bp)
|
app.register_blueprint(inner_api_bp)
|
||||||
app.register_blueprint(mcp_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)
|
||||||
|
|||||||
@ -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 ###
|
||||||
@ -1383,3 +1383,41 @@ class WorkflowTriggerLog(Base):
|
|||||||
"triggered_at": self.triggered_at.isoformat() if self.triggered_at else None,
|
"triggered_at": self.triggered_at.isoformat() if self.triggered_at else None,
|
||||||
"finished_at": self.finished_at.isoformat() if self.finished_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(),
|
||||||
|
)
|
||||||
|
|||||||
262
api/services/webhook_service.py
Normal file
262
api/services/webhook_service.py
Normal 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
|
||||||
@ -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()
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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
|
||||||
368
api/tests/unit_tests/services/test_webhook_service.py
Normal file
368
api/tests/unit_tests/services/test_webhook_service.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user