From b2a7cec644e79c5c5e38f983d9466254414a7b5d Mon Sep 17 00:00:00 2001 From: Satoshi Dev <162055292+0xsatoshi99@users.noreply.github.com> Date: Wed, 26 Nov 2025 06:50:20 -0800 Subject: [PATCH] add unit tests for template transform node (#28595) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .../nodes/template_transform/__init__.py | 1 + .../nodes/template_transform/entities_spec.py | 225 ++++++++++ .../template_transform_node_spec.py | 414 ++++++++++++++++++ 3 files changed, 640 insertions(+) create mode 100644 api/tests/unit_tests/core/workflow/nodes/template_transform/__init__.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/template_transform/entities_spec.py create mode 100644 api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/__init__.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/__init__.py @@ -0,0 +1 @@ + diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/entities_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/entities_spec.py new file mode 100644 index 0000000000..5eb302798f --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/entities_spec.py @@ -0,0 +1,225 @@ +import pytest +from pydantic import ValidationError + +from core.workflow.enums import ErrorStrategy +from core.workflow.nodes.template_transform.entities import TemplateTransformNodeData + + +class TestTemplateTransformNodeData: + """Test suite for TemplateTransformNodeData entity.""" + + def test_valid_template_transform_node_data(self): + """Test creating valid TemplateTransformNodeData.""" + data = { + "title": "Template Transform", + "desc": "Transform data using Jinja2 template", + "variables": [ + {"variable": "name", "value_selector": ["sys", "user_name"]}, + {"variable": "age", "value_selector": ["sys", "user_age"]}, + ], + "template": "Hello {{ name }}, you are {{ age }} years old!", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.title == "Template Transform" + assert node_data.desc == "Transform data using Jinja2 template" + assert len(node_data.variables) == 2 + assert node_data.variables[0].variable == "name" + assert node_data.variables[0].value_selector == ["sys", "user_name"] + assert node_data.variables[1].variable == "age" + assert node_data.variables[1].value_selector == ["sys", "user_age"] + assert node_data.template == "Hello {{ name }}, you are {{ age }} years old!" + + def test_template_transform_node_data_with_empty_variables(self): + """Test TemplateTransformNodeData with no variables.""" + data = { + "title": "Static Template", + "variables": [], + "template": "This is a static template with no variables.", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.title == "Static Template" + assert len(node_data.variables) == 0 + assert node_data.template == "This is a static template with no variables." + + def test_template_transform_node_data_with_complex_template(self): + """Test TemplateTransformNodeData with complex Jinja2 template.""" + data = { + "title": "Complex Template", + "variables": [ + {"variable": "items", "value_selector": ["sys", "item_list"]}, + {"variable": "total", "value_selector": ["sys", "total_count"]}, + ], + "template": ( + "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}. Total: {{ total }}" + ), + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.title == "Complex Template" + assert len(node_data.variables) == 2 + assert "{% for item in items %}" in node_data.template + assert "{{ total }}" in node_data.template + + def test_template_transform_node_data_with_error_strategy(self): + """Test TemplateTransformNodeData with error handling strategy.""" + data = { + "title": "Template with Error Handling", + "variables": [{"variable": "value", "value_selector": ["sys", "input"]}], + "template": "{{ value }}", + "error_strategy": "fail-branch", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.error_strategy == ErrorStrategy.FAIL_BRANCH + + def test_template_transform_node_data_with_retry_config(self): + """Test TemplateTransformNodeData with retry configuration.""" + data = { + "title": "Template with Retry", + "variables": [{"variable": "data", "value_selector": ["sys", "data"]}], + "template": "{{ data }}", + "retry_config": {"enabled": True, "max_retries": 3, "retry_interval": 1000}, + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.retry_config.enabled is True + assert node_data.retry_config.max_retries == 3 + assert node_data.retry_config.retry_interval == 1000 + + def test_template_transform_node_data_missing_required_fields(self): + """Test that missing required fields raises ValidationError.""" + data = { + "title": "Incomplete Template", + # Missing 'variables' and 'template' + } + + with pytest.raises(ValidationError) as exc_info: + TemplateTransformNodeData.model_validate(data) + + errors = exc_info.value.errors() + assert len(errors) >= 2 + error_fields = {error["loc"][0] for error in errors} + assert "variables" in error_fields + assert "template" in error_fields + + def test_template_transform_node_data_invalid_variable_selector(self): + """Test that invalid variable selector format raises ValidationError.""" + data = { + "title": "Invalid Variable", + "variables": [ + {"variable": "name", "value_selector": "invalid_format"} # Should be list + ], + "template": "{{ name }}", + } + + with pytest.raises(ValidationError): + TemplateTransformNodeData.model_validate(data) + + def test_template_transform_node_data_with_default_value_dict(self): + """Test TemplateTransformNodeData with default value dictionary.""" + data = { + "title": "Template with Defaults", + "variables": [ + {"variable": "name", "value_selector": ["sys", "user_name"]}, + {"variable": "greeting", "value_selector": ["sys", "greeting"]}, + ], + "template": "{{ greeting }} {{ name }}!", + "default_value_dict": {"greeting": "Hello", "name": "Guest"}, + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.default_value_dict == {"greeting": "Hello", "name": "Guest"} + + def test_template_transform_node_data_with_nested_selectors(self): + """Test TemplateTransformNodeData with nested variable selectors.""" + data = { + "title": "Nested Selectors", + "variables": [ + {"variable": "user_info", "value_selector": ["sys", "user", "profile", "name"]}, + {"variable": "settings", "value_selector": ["sys", "config", "app", "theme"]}, + ], + "template": "User: {{ user_info }}, Theme: {{ settings }}", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert len(node_data.variables) == 2 + assert node_data.variables[0].value_selector == ["sys", "user", "profile", "name"] + assert node_data.variables[1].value_selector == ["sys", "config", "app", "theme"] + + def test_template_transform_node_data_with_multiline_template(self): + """Test TemplateTransformNodeData with multiline template.""" + data = { + "title": "Multiline Template", + "variables": [ + {"variable": "title", "value_selector": ["sys", "title"]}, + {"variable": "content", "value_selector": ["sys", "content"]}, + ], + "template": """ +# {{ title }} + +{{ content }} + +--- +Generated by Template Transform Node + """, + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert "# {{ title }}" in node_data.template + assert "{{ content }}" in node_data.template + assert "Generated by Template Transform Node" in node_data.template + + def test_template_transform_node_data_serialization(self): + """Test that TemplateTransformNodeData can be serialized and deserialized.""" + original_data = { + "title": "Serialization Test", + "desc": "Test serialization", + "variables": [{"variable": "test", "value_selector": ["sys", "test"]}], + "template": "{{ test }}", + } + + node_data = TemplateTransformNodeData.model_validate(original_data) + serialized = node_data.model_dump() + deserialized = TemplateTransformNodeData.model_validate(serialized) + + assert deserialized.title == node_data.title + assert deserialized.desc == node_data.desc + assert len(deserialized.variables) == len(node_data.variables) + assert deserialized.template == node_data.template + + def test_template_transform_node_data_with_special_characters(self): + """Test TemplateTransformNodeData with special characters in template.""" + data = { + "title": "Special Characters", + "variables": [{"variable": "text", "value_selector": ["sys", "input"]}], + "template": "Special: {{ text }} | Symbols: @#$%^&*() | Unicode: 你好 🎉", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert "@#$%^&*()" in node_data.template + assert "你好" in node_data.template + assert "🎉" in node_data.template + + def test_template_transform_node_data_empty_template(self): + """Test TemplateTransformNodeData with empty template string.""" + data = { + "title": "Empty Template", + "variables": [], + "template": "", + } + + node_data = TemplateTransformNodeData.model_validate(data) + + assert node_data.template == "" + assert len(node_data.variables) == 0 diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py new file mode 100644 index 0000000000..1a67d5c3e3 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/template_transform/template_transform_node_spec.py @@ -0,0 +1,414 @@ +from unittest.mock import MagicMock, patch + +import pytest +from core.workflow.graph_engine.entities.graph import Graph +from core.workflow.graph_engine.entities.graph_init_params import GraphInitParams +from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState + +from core.helper.code_executor.code_executor import CodeExecutionError +from core.workflow.enums import ErrorStrategy, NodeType, WorkflowNodeExecutionStatus +from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode +from models.workflow import WorkflowType + + +class TestTemplateTransformNode: + """Comprehensive test suite for TemplateTransformNode.""" + + @pytest.fixture + def mock_graph_runtime_state(self): + """Create a mock GraphRuntimeState with variable pool.""" + mock_state = MagicMock(spec=GraphRuntimeState) + mock_variable_pool = MagicMock() + mock_state.variable_pool = mock_variable_pool + return mock_state + + @pytest.fixture + def mock_graph(self): + """Create a mock Graph.""" + return MagicMock(spec=Graph) + + @pytest.fixture + def graph_init_params(self): + """Create a mock GraphInitParams.""" + return GraphInitParams( + tenant_id="test_tenant", + app_id="test_app", + workflow_type=WorkflowType.WORKFLOW, + workflow_id="test_workflow", + graph_config={}, + user_id="test_user", + user_from="test", + invoke_from="test", + call_depth=0, + ) + + @pytest.fixture + def basic_node_data(self): + """Create basic node data for testing.""" + return { + "title": "Template Transform", + "desc": "Transform data using template", + "variables": [ + {"variable": "name", "value_selector": ["sys", "user_name"]}, + {"variable": "age", "value_selector": ["sys", "user_age"]}, + ], + "template": "Hello {{ name }}, you are {{ age }} years old!", + } + + def test_node_initialization(self, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test that TemplateTransformNode initializes correctly.""" + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + assert node.node_type == NodeType.TEMPLATE_TRANSFORM + assert node._node_data.title == "Template Transform" + assert len(node._node_data.variables) == 2 + assert node._node_data.template == "Hello {{ name }}, you are {{ age }} years old!" + + def test_get_title(self, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _get_title method.""" + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + assert node._get_title() == "Template Transform" + + def test_get_description(self, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _get_description method.""" + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + assert node._get_description() == "Transform data using template" + + def test_get_error_strategy(self, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _get_error_strategy method.""" + node_data = { + "title": "Test", + "variables": [], + "template": "test", + "error_strategy": "fail-branch", + } + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + assert node._get_error_strategy() == ErrorStrategy.FAIL_BRANCH + + def test_get_default_config(self): + """Test get_default_config class method.""" + config = TemplateTransformNode.get_default_config() + + assert config["type"] == "template-transform" + assert "config" in config + assert "variables" in config["config"] + assert "template" in config["config"] + assert config["config"]["template"] == "{{ arg1 }}" + + def test_version(self): + """Test version class method.""" + assert TemplateTransformNode.version() == "1" + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_simple_template( + self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run with simple template transformation.""" + # Setup mock variable pool + mock_name_value = MagicMock() + mock_name_value.to_object.return_value = "Alice" + mock_age_value = MagicMock() + mock_age_value.to_object.return_value = 30 + + variable_map = { + ("sys", "user_name"): mock_name_value, + ("sys", "user_age"): mock_age_value, + } + mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) + + # Setup mock executor + mock_execute.return_value = {"result": "Hello Alice, you are 30 years old!"} + + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Hello Alice, you are 30 years old!" + assert result.inputs["name"] == "Alice" + assert result.inputs["age"] == 30 + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_none_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with None variable values.""" + node_data = { + "title": "Test", + "variables": [{"variable": "value", "value_selector": ["sys", "missing"]}], + "template": "Value: {{ value }}", + } + + mock_graph_runtime_state.variable_pool.get.return_value = None + mock_execute.return_value = {"result": "Value: "} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.inputs["value"] is None + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_code_execution_error( + self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run when code execution fails.""" + mock_graph_runtime_state.variable_pool.get.return_value = MagicMock() + mock_execute.side_effect = CodeExecutionError("Template syntax error") + + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert "Template syntax error" in result.error + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + @patch("core.workflow.nodes.template_transform.template_transform_node.MAX_TEMPLATE_TRANSFORM_OUTPUT_LENGTH", 10) + def test_run_output_length_exceeds_limit( + self, mock_execute, basic_node_data, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run when output exceeds maximum length.""" + mock_graph_runtime_state.variable_pool.get.return_value = MagicMock() + mock_execute.return_value = {"result": "This is a very long output that exceeds the limit"} + + node = TemplateTransformNode( + id="test_node", + config=basic_node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.FAILED + assert "Output length exceeds" in result.error + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_complex_jinja2_template( + self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params + ): + """Test _run with complex Jinja2 template including loops and conditions.""" + node_data = { + "title": "Complex Template", + "variables": [ + {"variable": "items", "value_selector": ["sys", "items"]}, + {"variable": "show_total", "value_selector": ["sys", "show_total"]}, + ], + "template": ( + "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}" + "{% if show_total %} (Total: {{ items|length }}){% endif %}" + ), + } + + mock_items = MagicMock() + mock_items.to_object.return_value = ["apple", "banana", "orange"] + mock_show_total = MagicMock() + mock_show_total.to_object.return_value = True + + variable_map = { + ("sys", "items"): mock_items, + ("sys", "show_total"): mock_show_total, + } + mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) + mock_execute.return_value = {"result": "apple, banana, orange (Total: 3)"} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "apple, banana, orange (Total: 3)" + + def test_extract_variable_selector_to_variable_mapping(self): + """Test _extract_variable_selector_to_variable_mapping class method.""" + node_data = { + "title": "Test", + "variables": [ + {"variable": "var1", "value_selector": ["sys", "input1"]}, + {"variable": "var2", "value_selector": ["sys", "input2"]}, + ], + "template": "{{ var1 }} {{ var2 }}", + } + + mapping = TemplateTransformNode._extract_variable_selector_to_variable_mapping( + graph_config={}, node_id="node_123", node_data=node_data + ) + + assert "node_123.var1" in mapping + assert "node_123.var2" in mapping + assert mapping["node_123.var1"] == ["sys", "input1"] + assert mapping["node_123.var2"] == ["sys", "input2"] + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_empty_variables(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with no variables (static template).""" + node_data = { + "title": "Static Template", + "variables": [], + "template": "This is a static message.", + } + + mock_execute.return_value = {"result": "This is a static message."} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "This is a static message." + assert result.inputs == {} + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_numeric_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with numeric variable values.""" + node_data = { + "title": "Numeric Template", + "variables": [ + {"variable": "price", "value_selector": ["sys", "price"]}, + {"variable": "quantity", "value_selector": ["sys", "quantity"]}, + ], + "template": "Total: ${{ price * quantity }}", + } + + mock_price = MagicMock() + mock_price.to_object.return_value = 10.5 + mock_quantity = MagicMock() + mock_quantity.to_object.return_value = 3 + + variable_map = { + ("sys", "price"): mock_price, + ("sys", "quantity"): mock_quantity, + } + mock_graph_runtime_state.variable_pool.get.side_effect = lambda selector: variable_map.get(tuple(selector)) + mock_execute.return_value = {"result": "Total: $31.5"} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert result.outputs["output"] == "Total: $31.5" + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_dict_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with dictionary variable values.""" + node_data = { + "title": "Dict Template", + "variables": [{"variable": "user", "value_selector": ["sys", "user_data"]}], + "template": "Name: {{ user.name }}, Email: {{ user.email }}", + } + + mock_user = MagicMock() + mock_user.to_object.return_value = {"name": "John Doe", "email": "john@example.com"} + + mock_graph_runtime_state.variable_pool.get.return_value = mock_user + mock_execute.return_value = {"result": "Name: John Doe, Email: john@example.com"} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "John Doe" in result.outputs["output"] + assert "john@example.com" in result.outputs["output"] + + @patch("core.workflow.nodes.template_transform.template_transform_node.CodeExecutor.execute_workflow_code_template") + def test_run_with_list_values(self, mock_execute, mock_graph, mock_graph_runtime_state, graph_init_params): + """Test _run with list variable values.""" + node_data = { + "title": "List Template", + "variables": [{"variable": "tags", "value_selector": ["sys", "tags"]}], + "template": "Tags: {% for tag in tags %}#{{ tag }} {% endfor %}", + } + + mock_tags = MagicMock() + mock_tags.to_object.return_value = ["python", "ai", "workflow"] + + mock_graph_runtime_state.variable_pool.get.return_value = mock_tags + mock_execute.return_value = {"result": "Tags: #python #ai #workflow "} + + node = TemplateTransformNode( + id="test_node", + config=node_data, + graph_init_params=graph_init_params, + graph=mock_graph, + graph_runtime_state=mock_graph_runtime_state, + ) + + result = node._run() + + assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert "#python" in result.outputs["output"] + assert "#ai" in result.outputs["output"] + assert "#workflow" in result.outputs["output"]