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:
@@ -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)
|
||||
655
api/tests/unit_tests/core/plugin/utils/test_http_parser.py
Normal file
655
api/tests/unit_tests/core/plugin/utils/test_http_parser.py
Normal 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
|
||||
102
api/tests/unit_tests/core/test_trigger_debug_event_selectors.py
Normal file
102
api/tests/unit_tests/core/test_trigger_debug_event_selectors.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user