feat: introduce trigger functionality (#27644)

Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: Stream <Stream_2@qq.com>
Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
Co-authored-by: Harry <xh001x@hotmail.com>
Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com>
Co-authored-by: yessenia <yessenia.contact@gmail.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: WTW0313 <twwu@dify.ai>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Yeuoly
2025-11-12 17:59:37 +08:00
committed by GitHub
parent ca7794305b
commit b76e17b25d
785 changed files with 41186 additions and 3725 deletions

View File

@@ -0,0 +1,19 @@
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
def test_should_prepare_user_inputs_defaults_to_true():
args = {"inputs": {}}
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)
def test_should_prepare_user_inputs_skips_when_flag_truthy():
args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True}
assert not WorkflowAppGenerator()._should_prepare_user_inputs(args)
def test_should_prepare_user_inputs_keeps_validation_when_flag_false():
args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: False}
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)

View File

@@ -0,0 +1,655 @@
import pytest
from flask import Request, Response
from core.plugin.utils.http_parser import (
deserialize_request,
deserialize_response,
serialize_request,
serialize_response,
)
class TestSerializeRequest:
def test_serialize_simple_get_request(self):
# Create a simple GET request
environ = {
"REQUEST_METHOD": "GET",
"PATH_INFO": "/api/test",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": None,
"wsgi.url_scheme": "http",
}
request = Request(environ)
raw_data = serialize_request(request)
assert raw_data.startswith(b"GET /api/test HTTP/1.1\r\n")
assert b"\r\n\r\n" in raw_data # Empty line between headers and body
def test_serialize_request_with_query_params(self):
# Create a GET request with query parameters
environ = {
"REQUEST_METHOD": "GET",
"PATH_INFO": "/api/search",
"QUERY_STRING": "q=test&limit=10",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": None,
"wsgi.url_scheme": "http",
}
request = Request(environ)
raw_data = serialize_request(request)
assert raw_data.startswith(b"GET /api/search?q=test&limit=10 HTTP/1.1\r\n")
def test_serialize_post_request_with_body(self):
# Create a POST request with body
from io import BytesIO
body = b'{"name": "test", "value": 123}'
environ = {
"REQUEST_METHOD": "POST",
"PATH_INFO": "/api/data",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "http",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": "application/json",
"HTTP_CONTENT_TYPE": "application/json",
}
request = Request(environ)
raw_data = serialize_request(request)
assert b"POST /api/data HTTP/1.1\r\n" in raw_data
assert b"Content-Type: application/json" in raw_data
assert raw_data.endswith(body)
def test_serialize_request_with_custom_headers(self):
# Create a request with custom headers
environ = {
"REQUEST_METHOD": "GET",
"PATH_INFO": "/api/test",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": None,
"wsgi.url_scheme": "http",
"HTTP_AUTHORIZATION": "Bearer token123",
"HTTP_X_CUSTOM_HEADER": "custom-value",
}
request = Request(environ)
raw_data = serialize_request(request)
assert b"Authorization: Bearer token123" in raw_data
assert b"X-Custom-Header: custom-value" in raw_data
class TestDeserializeRequest:
def test_deserialize_simple_get_request(self):
raw_data = b"GET /api/test HTTP/1.1\r\nHost: localhost:8000\r\n\r\n"
request = deserialize_request(raw_data)
assert request.method == "GET"
assert request.path == "/api/test"
assert request.headers.get("Host") == "localhost:8000"
def test_deserialize_request_with_query_params(self):
raw_data = b"GET /api/search?q=test&limit=10 HTTP/1.1\r\nHost: example.com\r\n\r\n"
request = deserialize_request(raw_data)
assert request.method == "GET"
assert request.path == "/api/search"
assert request.query_string == b"q=test&limit=10"
assert request.args.get("q") == "test"
assert request.args.get("limit") == "10"
def test_deserialize_post_request_with_body(self):
body = b'{"name": "test", "value": 123}'
raw_data = (
b"POST /api/data HTTP/1.1\r\n"
b"Host: localhost\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"\r\n" + body
)
request = deserialize_request(raw_data)
assert request.method == "POST"
assert request.path == "/api/data"
assert request.content_type == "application/json"
assert request.get_data() == body
def test_deserialize_request_with_custom_headers(self):
raw_data = (
b"GET /api/protected HTTP/1.1\r\n"
b"Host: api.example.com\r\n"
b"Authorization: Bearer token123\r\n"
b"X-Custom-Header: custom-value\r\n"
b"User-Agent: TestClient/1.0\r\n"
b"\r\n"
)
request = deserialize_request(raw_data)
assert request.method == "GET"
assert request.headers.get("Authorization") == "Bearer token123"
assert request.headers.get("X-Custom-Header") == "custom-value"
assert request.headers.get("User-Agent") == "TestClient/1.0"
def test_deserialize_request_with_multiline_body(self):
body = b"line1\r\nline2\r\nline3"
raw_data = b"PUT /api/text HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\n\r\n" + body
request = deserialize_request(raw_data)
assert request.method == "PUT"
assert request.get_data() == body
def test_deserialize_invalid_request_line(self):
raw_data = b"INVALID\r\n\r\n" # Only one part, should fail
with pytest.raises(ValueError, match="Invalid request line"):
deserialize_request(raw_data)
def test_roundtrip_request(self):
# Test that serialize -> deserialize produces equivalent request
from io import BytesIO
body = b"test body content"
environ = {
"REQUEST_METHOD": "POST",
"PATH_INFO": "/api/echo",
"QUERY_STRING": "format=json",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8080",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "http",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": "text/plain",
"HTTP_CONTENT_TYPE": "text/plain",
"HTTP_X_REQUEST_ID": "req-123",
}
original_request = Request(environ)
# Serialize and deserialize
raw_data = serialize_request(original_request)
restored_request = deserialize_request(raw_data)
# Verify key properties are preserved
assert restored_request.method == original_request.method
assert restored_request.path == original_request.path
assert restored_request.query_string == original_request.query_string
assert restored_request.get_data() == body
assert restored_request.headers.get("X-Request-Id") == "req-123"
class TestSerializeResponse:
def test_serialize_simple_response(self):
response = Response("Hello, World!", status=200)
raw_data = serialize_response(response)
assert raw_data.startswith(b"HTTP/1.1 200 OK\r\n")
assert b"\r\n\r\n" in raw_data
assert raw_data.endswith(b"Hello, World!")
def test_serialize_response_with_headers(self):
response = Response(
'{"status": "success"}',
status=201,
headers={
"Content-Type": "application/json",
"X-Request-Id": "req-456",
},
)
raw_data = serialize_response(response)
assert b"HTTP/1.1 201 CREATED\r\n" in raw_data
assert b"Content-Type: application/json" in raw_data
assert b"X-Request-Id: req-456" in raw_data
assert raw_data.endswith(b'{"status": "success"}')
def test_serialize_error_response(self):
response = Response(
"Not Found",
status=404,
headers={"Content-Type": "text/plain"},
)
raw_data = serialize_response(response)
assert b"HTTP/1.1 404 NOT FOUND\r\n" in raw_data
assert b"Content-Type: text/plain" in raw_data
assert raw_data.endswith(b"Not Found")
def test_serialize_response_without_body(self):
response = Response(status=204) # No Content
raw_data = serialize_response(response)
assert b"HTTP/1.1 204 NO CONTENT\r\n" in raw_data
assert raw_data.endswith(b"\r\n\r\n") # Should end with empty line
def test_serialize_response_with_binary_body(self):
binary_data = b"\x00\x01\x02\x03\x04\x05"
response = Response(
binary_data,
status=200,
headers={"Content-Type": "application/octet-stream"},
)
raw_data = serialize_response(response)
assert b"HTTP/1.1 200 OK\r\n" in raw_data
assert b"Content-Type: application/octet-stream" in raw_data
assert raw_data.endswith(binary_data)
class TestDeserializeResponse:
def test_deserialize_simple_response(self):
raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello, World!"
response = deserialize_response(raw_data)
assert response.status_code == 200
assert response.get_data() == b"Hello, World!"
assert response.headers.get("Content-Type") == "text/plain"
def test_deserialize_response_with_json(self):
body = b'{"result": "success", "data": [1, 2, 3]}'
raw_data = (
b"HTTP/1.1 201 Created\r\n"
b"Content-Type: application/json\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"X-Custom-Header: test-value\r\n"
b"\r\n" + body
)
response = deserialize_response(raw_data)
assert response.status_code == 201
assert response.get_data() == body
assert response.headers.get("Content-Type") == "application/json"
assert response.headers.get("X-Custom-Header") == "test-value"
def test_deserialize_error_response(self):
raw_data = b"HTTP/1.1 404 Not Found\r\nContent-Type: text/html\r\n\r\n<html><body>Page not found</body></html>"
response = deserialize_response(raw_data)
assert response.status_code == 404
assert response.get_data() == b"<html><body>Page not found</body></html>"
def test_deserialize_response_without_body(self):
raw_data = b"HTTP/1.1 204 No Content\r\n\r\n"
response = deserialize_response(raw_data)
assert response.status_code == 204
assert response.get_data() == b""
def test_deserialize_response_with_multiline_body(self):
body = b"Line 1\r\nLine 2\r\nLine 3"
raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n" + body
response = deserialize_response(raw_data)
assert response.status_code == 200
assert response.get_data() == body
def test_deserialize_response_minimal_status_line(self):
# Test with minimal status line (no status text)
raw_data = b"HTTP/1.1 200\r\n\r\nOK"
response = deserialize_response(raw_data)
assert response.status_code == 200
assert response.get_data() == b"OK"
def test_deserialize_invalid_status_line(self):
raw_data = b"INVALID\r\n\r\n"
with pytest.raises(ValueError, match="Invalid status line"):
deserialize_response(raw_data)
def test_roundtrip_response(self):
# Test that serialize -> deserialize produces equivalent response
original_response = Response(
'{"message": "test"}',
status=200,
headers={
"Content-Type": "application/json",
"X-Request-Id": "abc-123",
"Cache-Control": "no-cache",
},
)
# Serialize and deserialize
raw_data = serialize_response(original_response)
restored_response = deserialize_response(raw_data)
# Verify key properties are preserved
assert restored_response.status_code == original_response.status_code
assert restored_response.get_data() == original_response.get_data()
assert restored_response.headers.get("Content-Type") == "application/json"
assert restored_response.headers.get("X-Request-Id") == "abc-123"
assert restored_response.headers.get("Cache-Control") == "no-cache"
class TestEdgeCases:
def test_request_with_empty_headers(self):
raw_data = b"GET / HTTP/1.1\r\n\r\n"
request = deserialize_request(raw_data)
assert request.method == "GET"
assert request.path == "/"
def test_response_with_empty_headers(self):
raw_data = b"HTTP/1.1 200 OK\r\n\r\nSuccess"
response = deserialize_response(raw_data)
assert response.status_code == 200
assert response.get_data() == b"Success"
def test_request_with_special_characters_in_path(self):
raw_data = b"GET /api/test%20path?key=%26value HTTP/1.1\r\n\r\n"
request = deserialize_request(raw_data)
assert request.method == "GET"
assert "/api/test%20path" in request.full_path
def test_response_with_binary_content(self):
binary_body = bytes(range(256)) # All possible byte values
raw_data = b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n" + binary_body
response = deserialize_response(raw_data)
assert response.status_code == 200
assert response.get_data() == binary_body
class TestFileUploads:
def test_serialize_request_with_text_file_upload(self):
# Test multipart/form-data request with text file
from io import BytesIO
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
text_content = "Hello, this is a test file content!\nWith multiple lines."
body = (
f"------{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n'
f"Content-Type: text/plain\r\n"
f"\r\n"
f"{text_content}\r\n"
f"------{boundary}\r\n"
f'Content-Disposition: form-data; name="description"\r\n'
f"\r\n"
f"Test file upload\r\n"
f"------{boundary}--\r\n"
).encode()
environ = {
"REQUEST_METHOD": "POST",
"PATH_INFO": "/api/upload",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "http",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
}
request = Request(environ)
raw_data = serialize_request(request)
assert b"POST /api/upload HTTP/1.1\r\n" in raw_data
assert f"Content-Type: multipart/form-data; boundary={boundary}".encode() in raw_data
assert b'Content-Disposition: form-data; name="file"; filename="test.txt"' in raw_data
assert text_content.encode() in raw_data
def test_deserialize_request_with_text_file_upload(self):
# Test deserializing multipart/form-data request with text file
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
text_content = "Sample text file content\nLine 2\nLine 3"
body = (
f"------{boundary}\r\n"
f'Content-Disposition: form-data; name="document"; filename="document.txt"\r\n'
f"Content-Type: text/plain\r\n"
f"\r\n"
f"{text_content}\r\n"
f"------{boundary}\r\n"
f'Content-Disposition: form-data; name="title"\r\n'
f"\r\n"
f"My Document\r\n"
f"------{boundary}--\r\n"
).encode()
raw_data = (
b"POST /api/documents HTTP/1.1\r\n"
b"Host: example.com\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary.encode() + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"\r\n" + body
)
request = deserialize_request(raw_data)
assert request.method == "POST"
assert request.path == "/api/documents"
assert "multipart/form-data" in request.content_type
# The body should contain the multipart data
request_body = request.get_data()
assert b"document.txt" in request_body
assert text_content.encode() in request_body
def test_serialize_request_with_binary_file_upload(self):
# Test multipart/form-data request with binary file (e.g., image)
from io import BytesIO
boundary = "----BoundaryString123"
# Simulate a small PNG file header
binary_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x10\x00\x00\x00\x10"
# Build multipart body
body_parts = []
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="image"; filename="test.png"')
body_parts.append(b"Content-Type: image/png")
body_parts.append(b"")
body_parts.append(binary_content)
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="caption"')
body_parts.append(b"")
body_parts.append(b"Test image")
body_parts.append(f"------{boundary}--".encode())
body = b"\r\n".join(body_parts)
environ = {
"REQUEST_METHOD": "POST",
"PATH_INFO": "/api/images",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "http",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
}
request = Request(environ)
raw_data = serialize_request(request)
assert b"POST /api/images HTTP/1.1\r\n" in raw_data
assert f"Content-Type: multipart/form-data; boundary={boundary}".encode() in raw_data
assert b'filename="test.png"' in raw_data
assert b"Content-Type: image/png" in raw_data
assert binary_content in raw_data
def test_deserialize_request_with_binary_file_upload(self):
# Test deserializing multipart/form-data request with binary file
boundary = "----BoundaryABC123"
# Simulate a small JPEG file header
binary_content = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
body_parts = []
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="photo"; filename="photo.jpg"')
body_parts.append(b"Content-Type: image/jpeg")
body_parts.append(b"")
body_parts.append(binary_content)
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="album"')
body_parts.append(b"")
body_parts.append(b"Vacation 2024")
body_parts.append(f"------{boundary}--".encode())
body = b"\r\n".join(body_parts)
raw_data = (
b"POST /api/photos HTTP/1.1\r\n"
b"Host: api.example.com\r\n"
b"Content-Type: multipart/form-data; boundary=" + boundary.encode() + b"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"Accept: application/json\r\n"
b"\r\n" + body
)
request = deserialize_request(raw_data)
assert request.method == "POST"
assert request.path == "/api/photos"
assert "multipart/form-data" in request.content_type
assert request.headers.get("Accept") == "application/json"
# Verify the binary content is preserved
request_body = request.get_data()
assert b"photo.jpg" in request_body
assert b"image/jpeg" in request_body
assert binary_content in request_body
assert b"Vacation 2024" in request_body
def test_serialize_request_with_multiple_files(self):
# Test request with multiple file uploads
from io import BytesIO
boundary = "----MultiFilesBoundary"
text_file = b"Text file contents"
binary_file = b"\x00\x01\x02\x03\x04\x05"
body_parts = []
# First file (text)
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="files"; filename="doc.txt"')
body_parts.append(b"Content-Type: text/plain")
body_parts.append(b"")
body_parts.append(text_file)
# Second file (binary)
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="files"; filename="data.bin"')
body_parts.append(b"Content-Type: application/octet-stream")
body_parts.append(b"")
body_parts.append(binary_file)
# Additional form field
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="folder"')
body_parts.append(b"")
body_parts.append(b"uploads/2024")
body_parts.append(f"------{boundary}--".encode())
body = b"\r\n".join(body_parts)
environ = {
"REQUEST_METHOD": "POST",
"PATH_INFO": "/api/batch-upload",
"QUERY_STRING": "",
"SERVER_NAME": "localhost",
"SERVER_PORT": "8000",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "https",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_X_FORWARDED_PROTO": "https",
}
request = Request(environ)
raw_data = serialize_request(request)
assert b"POST /api/batch-upload HTTP/1.1\r\n" in raw_data
assert b"doc.txt" in raw_data
assert b"data.bin" in raw_data
assert text_file in raw_data
assert binary_file in raw_data
assert b"uploads/2024" in raw_data
def test_roundtrip_file_upload_request(self):
# Test that file upload request survives serialize -> deserialize
from io import BytesIO
boundary = "----RoundTripBoundary"
file_content = b"This is my file content with special chars: \xf0\x9f\x98\x80"
body_parts = []
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="upload"; filename="emoji.txt"')
body_parts.append(b"Content-Type: text/plain; charset=utf-8")
body_parts.append(b"")
body_parts.append(file_content)
body_parts.append(f"------{boundary}".encode())
body_parts.append(b'Content-Disposition: form-data; name="metadata"')
body_parts.append(b"")
body_parts.append(b'{"encoding": "utf-8", "size": 42}')
body_parts.append(f"------{boundary}--".encode())
body = b"\r\n".join(body_parts)
environ = {
"REQUEST_METHOD": "PUT",
"PATH_INFO": "/api/files/123",
"QUERY_STRING": "version=2",
"SERVER_NAME": "storage.example.com",
"SERVER_PORT": "443",
"wsgi.input": BytesIO(body),
"wsgi.url_scheme": "https",
"CONTENT_LENGTH": str(len(body)),
"CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_CONTENT_TYPE": f"multipart/form-data; boundary={boundary}",
"HTTP_AUTHORIZATION": "Bearer token123",
"HTTP_X_FORWARDED_PROTO": "https",
}
original_request = Request(environ)
# Serialize and deserialize
raw_data = serialize_request(original_request)
restored_request = deserialize_request(raw_data)
# Verify the request is preserved
assert restored_request.method == "PUT"
assert restored_request.path == "/api/files/123"
assert restored_request.query_string == b"version=2"
assert "multipart/form-data" in restored_request.content_type
assert boundary in restored_request.content_type
# Verify file content is preserved
restored_body = restored_request.get_data()
assert b"emoji.txt" in restored_body
assert file_content in restored_body
assert b'{"encoding": "utf-8", "size": 42}' in restored_body

View File

@@ -0,0 +1,102 @@
import hashlib
import json
from datetime import UTC, datetime
import pytest
import pytz
from core.trigger.debug import event_selectors
from core.workflow.nodes.trigger_schedule.entities import ScheduleConfig
class _DummyRedis:
def __init__(self):
self.store: dict[str, str] = {}
def get(self, key: str):
return self.store.get(key)
def setex(self, name: str, time: int, value: str):
self.store[name] = value
def expire(self, name: str, ttl: int):
# Expiration not required for these tests.
pass
def delete(self, name: str):
self.store.pop(name, None)
@pytest.fixture
def dummy_schedule_config() -> ScheduleConfig:
return ScheduleConfig(
node_id="node-1",
cron_expression="* * * * *",
timezone="Asia/Shanghai",
)
@pytest.fixture(autouse=True)
def patch_schedule_service(monkeypatch: pytest.MonkeyPatch, dummy_schedule_config: ScheduleConfig):
# Ensure poller always receives the deterministic config.
monkeypatch.setattr(
"services.trigger.schedule_service.ScheduleService.to_schedule_config",
staticmethod(lambda *_args, **_kwargs: dummy_schedule_config),
)
def _make_poller(
monkeypatch: pytest.MonkeyPatch, redis_client: _DummyRedis
) -> event_selectors.ScheduleTriggerDebugEventPoller:
monkeypatch.setattr(event_selectors, "redis_client", redis_client)
return event_selectors.ScheduleTriggerDebugEventPoller(
tenant_id="tenant-1",
user_id="user-1",
app_id="app-1",
node_config={"id": "node-1", "data": {"mode": "cron"}},
node_id="node-1",
)
def test_schedule_poller_handles_aware_next_run(monkeypatch: pytest.MonkeyPatch):
redis_client = _DummyRedis()
poller = _make_poller(monkeypatch, redis_client)
base_now = datetime(2025, 1, 1, 12, 0, 10)
aware_next_run = datetime(2025, 1, 1, 12, 0, 5, tzinfo=UTC)
monkeypatch.setattr(event_selectors, "naive_utc_now", lambda: base_now)
monkeypatch.setattr(event_selectors, "calculate_next_run_at", lambda *_: aware_next_run)
event = poller.poll()
assert event is not None
assert event.node_id == "node-1"
assert event.workflow_args["inputs"] == {}
def test_schedule_runtime_cache_normalizes_timezone(
monkeypatch: pytest.MonkeyPatch, dummy_schedule_config: ScheduleConfig
):
redis_client = _DummyRedis()
poller = _make_poller(monkeypatch, redis_client)
localized_time = pytz.timezone("Asia/Shanghai").localize(datetime(2025, 1, 1, 20, 0, 0))
cron_hash = hashlib.sha256(dummy_schedule_config.cron_expression.encode()).hexdigest()
cache_key = poller.schedule_debug_runtime_key(cron_hash)
redis_client.store[cache_key] = json.dumps(
{
"cache_key": cache_key,
"timezone": dummy_schedule_config.timezone,
"cron_expression": dummy_schedule_config.cron_expression,
"next_run_at": localized_time.isoformat(),
}
)
runtime = poller.get_or_create_schedule_debug_runtime()
expected = localized_time.astimezone(UTC).replace(tzinfo=None)
assert runtime.next_run_at == expected
assert runtime.next_run_at.tzinfo is None

View File

@@ -4,7 +4,7 @@ from unittest.mock import patch
import pytest
from core.entities.provider_entities import BasicProviderConfig
from core.tools.utils.encryption import ProviderConfigEncrypter
from core.helper.provider_encryption import ProviderConfigEncrypter
# ---------------------------
@@ -70,7 +70,7 @@ def test_encrypt_only_secret_is_encrypted_and_non_secret_unchanged(encrypter_obj
data_in = {"username": "alice", "password": "plain_pwd"}
data_copy = copy.deepcopy(data_in)
with patch("core.tools.utils.encryption.encrypter.encrypt_token", return_value="CIPHERTEXT") as mock_encrypt:
with patch("core.helper.provider_encryption.encrypter.encrypt_token", return_value="CIPHERTEXT") as mock_encrypt:
out = encrypter_obj.encrypt(data_in)
assert out["username"] == "alice"
@@ -81,14 +81,14 @@ def test_encrypt_only_secret_is_encrypted_and_non_secret_unchanged(encrypter_obj
def test_encrypt_missing_secret_key_is_ok(encrypter_obj):
"""If secret field missing in input, no error and no encryption called."""
with patch("core.tools.utils.encryption.encrypter.encrypt_token") as mock_encrypt:
with patch("core.helper.provider_encryption.encrypter.encrypt_token") as mock_encrypt:
out = encrypter_obj.encrypt({"username": "alice"})
assert out["username"] == "alice"
mock_encrypt.assert_not_called()
# ============================================================
# ProviderConfigEncrypter.mask_tool_credentials()
# ProviderConfigEncrypter.mask_plugin_credentials()
# ============================================================
@@ -107,7 +107,7 @@ def test_mask_tool_credentials_long_secret(encrypter_obj, raw, prefix, suffix):
data_in = {"username": "alice", "password": raw}
data_copy = copy.deepcopy(data_in)
out = encrypter_obj.mask_tool_credentials(data_in)
out = encrypter_obj.mask_plugin_credentials(data_in)
masked = out["password"]
assert masked.startswith(prefix)
@@ -122,7 +122,7 @@ def test_mask_tool_credentials_short_secret(encrypter_obj, raw):
"""
For length <= 6: fully mask with '*' of same length.
"""
out = encrypter_obj.mask_tool_credentials({"password": raw})
out = encrypter_obj.mask_plugin_credentials({"password": raw})
assert out["password"] == ("*" * len(raw))
@@ -131,7 +131,7 @@ def test_mask_tool_credentials_missing_key_noop(encrypter_obj):
data_in = {"username": "alice"}
data_copy = copy.deepcopy(data_in)
out = encrypter_obj.mask_tool_credentials(data_in)
out = encrypter_obj.mask_plugin_credentials(data_in)
assert out["username"] == "alice"
assert data_in == data_copy
@@ -151,7 +151,7 @@ def test_decrypt_normal_flow(encrypter_obj):
data_in = {"username": "alice", "password": "ENC"}
data_copy = copy.deepcopy(data_in)
with patch("core.tools.utils.encryption.encrypter.decrypt_token", return_value="PLAIN") as mock_decrypt:
with patch("core.helper.provider_encryption.encrypter.decrypt_token", return_value="PLAIN") as mock_decrypt:
out = encrypter_obj.decrypt(data_in)
assert out["username"] == "alice"
@@ -163,7 +163,7 @@ def test_decrypt_normal_flow(encrypter_obj):
@pytest.mark.parametrize("empty_val", ["", None])
def test_decrypt_skip_empty_values(encrypter_obj, empty_val):
"""Skip decrypt if value is empty or None, keep original."""
with patch("core.tools.utils.encryption.encrypter.decrypt_token") as mock_decrypt:
with patch("core.helper.provider_encryption.encrypter.decrypt_token") as mock_decrypt:
out = encrypter_obj.decrypt({"password": empty_val})
mock_decrypt.assert_not_called()
@@ -175,7 +175,7 @@ def test_decrypt_swallow_exception_and_keep_original(encrypter_obj):
If decrypt_token raises, exception should be swallowed,
and original value preserved.
"""
with patch("core.tools.utils.encryption.encrypter.decrypt_token", side_effect=Exception("boom")):
with patch("core.helper.provider_encryption.encrypter.decrypt_token", side_effect=Exception("boom")):
out = encrypter_obj.decrypt({"password": "ENC_ERR"})
assert out["password"] == "ENC_ERR"

View File

@@ -64,6 +64,15 @@ class _TestNode(Node):
)
self.data = dict(data)
node_type_value = data.get("type")
if isinstance(node_type_value, NodeType):
self.node_type = node_type_value
elif isinstance(node_type_value, str):
try:
self.node_type = NodeType(node_type_value)
except ValueError:
pass
def _run(self):
raise NotImplementedError
@@ -179,3 +188,22 @@ def test_graph_promotes_fail_branch_nodes_to_branch_execution_type(
graph = Graph.init(graph_config=graph_config, node_factory=node_factory)
assert graph.nodes["branch"].execution_type == NodeExecutionType.BRANCH
def test_graph_validation_blocks_start_and_trigger_coexistence(
graph_init_dependencies: tuple[_SimpleNodeFactory, dict[str, object]],
) -> None:
node_factory, graph_config = graph_init_dependencies
graph_config["nodes"] = [
{"id": "start", "data": {"type": NodeType.START, "title": "Start", "execution_type": NodeExecutionType.ROOT}},
{
"id": "trigger",
"data": {"type": NodeType.TRIGGER_WEBHOOK, "title": "Webhook", "execution_type": NodeExecutionType.ROOT},
},
]
graph_config["edges"] = []
with pytest.raises(GraphValidationError) as exc_info:
Graph.init(graph_config=graph_config, node_factory=node_factory)
assert any(issue.code == "TRIGGER_START_NODE_CONFLICT" for issue in exc_info.value.issues)

View File

@@ -0,0 +1,308 @@
import pytest
from pydantic import ValidationError
from core.workflow.nodes.trigger_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.BINARY == "application/octet-stream"
# 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[string]",
"array[number]",
"array[boolean]",
"array[object]",
"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 accepts both strings and enum values."""
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_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
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[string]",
"array[number]",
"array[boolean]",
"array[object]",
"file",
]:
param = WebhookBodyParameter(name=f"test_{param_type}", type=param_type)
assert param.type == param_type
def test_webhook_data_inheritance():
"""Test WebhookData inherits from BaseNodeData correctly."""
from core.workflow.nodes.base import BaseNodeData
# Test that WebhookData is a subclass of BaseNodeData
assert issubclass(WebhookData, BaseNodeData)
# Test that instances have BaseNodeData properties
data = WebhookData(title="Test")
assert hasattr(data, "title")
assert hasattr(data, "desc") # Inherited from BaseNodeData

View File

@@ -0,0 +1,195 @@
import pytest
from core.workflow.nodes.base.exc import BaseNodeError
from core.workflow.nodes.trigger_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.trigger_webhook.exc"
assert WebhookNodeError.__module__ == expected_module
assert WebhookTimeoutError.__module__ == expected_module
assert WebhookNotFoundError.__module__ == expected_module
assert WebhookConfigError.__module__ == expected_module
def test_webhook_error_docstrings():
"""Test webhook error class docstrings."""
assert WebhookNodeError.__doc__ == "Base webhook node error."
assert WebhookTimeoutError.__doc__ == "Webhook timeout error."
assert WebhookNotFoundError.__doc__ == "Webhook not found error."
assert WebhookConfigError.__doc__ == "Webhook configuration error."
def test_webhook_error_repr_and_str():
"""Test webhook error string representations."""
error = WebhookNodeError("Test message")
# Test __str__ method
assert str(error) == "Test message"
# Test __repr__ method (should include class name)
repr_str = repr(error)
assert "WebhookNodeError" in repr_str
assert "Test message" in repr_str
def test_webhook_error_with_no_message():
"""Test webhook errors with no message."""
# Test that errors can be instantiated without messages
errors = [
WebhookNodeError(),
WebhookTimeoutError(),
WebhookNotFoundError(),
WebhookConfigError(),
]
for error in errors:
# Should be instances of their respective classes
assert isinstance(error, type(error))
# Should be able to be raised
with pytest.raises(type(error)):
raise error

View File

@@ -0,0 +1,468 @@
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.graph_init_params import GraphInitParams
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.trigger_webhook.entities import (
ContentType,
Method,
WebhookBodyParameter,
WebhookData,
WebhookParameter,
)
from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode
from core.workflow.runtime.graph_runtime_state import GraphRuntimeState
from core.workflow.runtime.variable_pool import VariablePool
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) -> TriggerWebhookNode:
"""Helper function to create a webhook node with proper initialization."""
node_config = {
"id": "1",
"data": webhook_data.model_dump(),
}
node = TriggerWebhookNode(
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_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 == "trigger-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 = TriggerWebhookNode.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_content_type_field():
"""Test that content_type accepts both raw strings and enum values."""
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