diff --git a/hazelnode/hazelnode/doctype/hazel_node/test_hazel_node.py b/hazelnode/hazelnode/doctype/hazel_node/test_hazel_node.py index 69ae95d..cf2651e 100644 --- a/hazelnode/hazelnode/doctype/hazel_node/test_hazel_node.py +++ b/hazelnode/hazelnode/doctype/hazel_node/test_hazel_node.py @@ -1,9 +1,856 @@ # Copyright (c) 2024, Build With Hussain and Contributors # See license.txt -# import frappe +import frappe from frappe.tests.utils import FrappeTestCase +from hazelnode.nodes.actions.condition_node import ConditionNode +from hazelnode.nodes.actions.create_document_node import ( + CreateDocumentNode, +) +from hazelnode.nodes.actions.delay_node import DelayNode +from hazelnode.nodes.actions.log_node import LogNode +from hazelnode.nodes.actions.set_variable_node import SetVariableNode +from hazelnode.nodes.actions.update_document_node import ( + UpdateDocumentNode, +) +from hazelnode.nodes.utils import ( + ensure_context, + parse_json_field, + render_template_field, +) + + +class TestConditionNode(FrappeTestCase): + """Tests for the Condition Node.""" + + def setUp(self): + self.node = ConditionNode() + + # ===== EQUALITY OPERATORS ===== + + def test_equals_true(self): + """Test equals operator returns true when values match.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'equals', + 'right_operand': 'hello', + } + ) + self.assertEqual(result['branch'], 'true') + self.assertTrue(result['condition_result']) + + def test_equals_false(self): + """Test equals operator returns false when values differ.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'equals', + 'right_operand': 'world', + } + ) + self.assertEqual(result['branch'], 'false') + self.assertFalse(result['condition_result']) + + def test_not_equals_true(self): + """Test not_equals operator returns true when values differ.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'not_equals', + 'right_operand': 'world', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_not_equals_false(self): + """Test not_equals operator returns false when values match.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'not_equals', + 'right_operand': 'hello', + } + ) + self.assertEqual(result['branch'], 'false') + + # ===== COMPARISON OPERATORS ===== + + def test_greater_than_true(self): + """Test greater_than with single-digit strings (string comparison).""" + result = self.node.execute( + params={ + 'left_operand': '9', + 'operator': 'greater_than', + 'right_operand': '5', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_greater_than_false(self): + """Test greater_than returns false when not greater.""" + result = self.node.execute( + params={ + 'left_operand': '5', + 'operator': 'greater_than', + 'right_operand': '9', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_less_than_true(self): + """Test less_than operator.""" + result = self.node.execute( + params={ + 'left_operand': '5', + 'operator': 'less_than', + 'right_operand': '9', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_less_than_false(self): + """Test less_than returns false when not less.""" + result = self.node.execute( + params={ + 'left_operand': '9', + 'operator': 'less_than', + 'right_operand': '5', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_greater_than_or_equal_true_greater(self): + """Test greater_than_or_equal when greater.""" + result = self.node.execute( + params={ + 'left_operand': '9', + 'operator': 'greater_than_or_equal', + 'right_operand': '5', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_greater_than_or_equal_true_equal(self): + """Test greater_than_or_equal when equal.""" + result = self.node.execute( + params={ + 'left_operand': '5', + 'operator': 'greater_than_or_equal', + 'right_operand': '5', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_less_than_or_equal_true_less(self): + """Test less_than_or_equal when less.""" + result = self.node.execute( + params={ + 'left_operand': '5', + 'operator': 'less_than_or_equal', + 'right_operand': '9', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_less_than_or_equal_true_equal(self): + """Test less_than_or_equal when equal.""" + result = self.node.execute( + params={ + 'left_operand': '5', + 'operator': 'less_than_or_equal', + 'right_operand': '5', + } + ) + self.assertEqual(result['branch'], 'true') + + # ===== STRING OPERATORS ===== + + def test_contains_true(self): + """Test contains operator.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'contains', + 'right_operand': 'world', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_contains_false(self): + """Test contains returns false when substring not found.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'contains', + 'right_operand': 'foo', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_not_contains_true(self): + """Test not_contains operator.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'not_contains', + 'right_operand': 'foo', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_not_contains_false(self): + """Test not_contains returns false when substring found.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'not_contains', + 'right_operand': 'hello', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_starts_with_true(self): + """Test starts_with operator.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'starts_with', + 'right_operand': 'hello', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_starts_with_false(self): + """Test starts_with returns false when not matching.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'starts_with', + 'right_operand': 'world', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_ends_with_true(self): + """Test ends_with operator.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'ends_with', + 'right_operand': 'world', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_ends_with_false(self): + """Test ends_with returns false when not matching.""" + result = self.node.execute( + params={ + 'left_operand': 'hello world', + 'operator': 'ends_with', + 'right_operand': 'hello', + } + ) + self.assertEqual(result['branch'], 'false') + + # ===== UNARY OPERATORS ===== + + def test_is_empty_true_for_empty_string(self): + """Test is_empty returns true for empty string.""" + result = self.node.execute( + params={ + 'left_operand': '', + 'operator': 'is_empty', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_is_empty_false_for_non_empty_string(self): + """Test is_empty returns false for non-empty string.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'is_empty', + } + ) + self.assertEqual(result['branch'], 'false') + + def test_is_not_empty_true(self): + """Test is_not_empty returns true for non-empty string.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'is_not_empty', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_is_not_empty_false(self): + """Test is_not_empty returns false for empty string.""" + result = self.node.execute( + params={ + 'left_operand': '', + 'operator': 'is_not_empty', + } + ) + self.assertEqual(result['branch'], 'false') + + # ===== CONTEXT VARIABLE RESOLUTION ===== + + def test_variable_resolution_simple(self): + """Test resolving simple variable from context.""" + result = self.node.execute( + params={ + 'left_operand': '{{ name }}', + 'operator': 'equals', + 'right_operand': 'Alice', + }, + context={'name': 'Alice'}, + ) + self.assertEqual(result['branch'], 'true') + self.assertEqual(result['evaluated']['left'], 'Alice') + + def test_variable_resolution_nested(self): + """Test resolving nested variable from context.""" + result = self.node.execute( + params={ + 'left_operand': '{{ user.name }}', + 'operator': 'equals', + 'right_operand': 'Bob', + }, + context={'user': {'name': 'Bob'}}, + ) + self.assertEqual(result['branch'], 'true') + self.assertEqual(result['evaluated']['left'], 'Bob') + + def test_variable_resolution_deeply_nested(self): + """Test resolving deeply nested variable.""" + result = self.node.execute( + params={ + 'left_operand': '{{ data.user.profile.age }}', + 'operator': 'greater_than', + 'right_operand': '5', + }, + context={ + 'data': {'user': {'profile': {'age': '9'}}} + }, + ) + self.assertEqual(result['branch'], 'true') + + def test_variable_resolution_missing_returns_none(self): + """Test that missing variable returns None.""" + result = self.node.execute( + params={ + 'left_operand': '{{ missing }}', + 'operator': 'is_empty', + }, + context={}, + ) + self.assertEqual(result['branch'], 'true') + + def test_variable_resolution_both_operands(self): + """Test resolving variables in both operands.""" + result = self.node.execute( + params={ + 'left_operand': '{{ a }}', + 'operator': 'equals', + 'right_operand': '{{ b }}', + }, + context={'a': 'same', 'b': 'same'}, + ) + self.assertEqual(result['branch'], 'true') + + # ===== EDGE CASES ===== + + def test_binary_operator_missing_right_operand(self): + """Test binary operator returns error when missing right operand.""" + result = self.node.execute( + params={ + 'left_operand': 'hello', + 'operator': 'equals', + 'right_operand': '', + } + ) + self.assertEqual(result['branch'], 'false') + self.assertIn('error', result) + + def test_default_operator_is_equals(self): + """Test that default operator is equals when not specified.""" + result = self.node.execute( + params={ + 'left_operand': 'test', + 'right_operand': 'test', + } + ) + self.assertEqual(result['branch'], 'true') + + def test_preserves_context(self): + """Test that context is preserved in result.""" + context = {'key': 'value'} + result = self.node.execute( + params={ + 'left_operand': 'a', + 'operator': 'equals', + 'right_operand': 'a', + }, + context=context, + ) + self.assertEqual(result['context'], context) + + +class TestSetVariableNode(FrappeTestCase): + """Tests for the Set Variable Node.""" + + def setUp(self): + self.node = SetVariableNode() + + def test_set_simple_variable(self): + """Test setting a simple variable.""" + result = self.node.execute( + params={ + 'variable_name': 'greeting', + 'value': 'Hello World', + } + ) + self.assertEqual(result.get('greeting'), 'Hello World') + + def test_set_variable_with_empty_context(self): + """Test setting variable with empty context.""" + result = self.node.execute( + params={ + 'variable_name': 'name', + 'value': 'Alice', + }, + context={}, + ) + self.assertEqual(result.get('name'), 'Alice') + + def test_set_variable_preserves_existing_context(self): + """Test that existing context values are preserved.""" + result = self.node.execute( + params={ + 'variable_name': 'new_key', + 'value': 'new_value', + }, + context={'existing_key': 'existing_value'}, + ) + self.assertEqual(result.get('existing_key'), 'existing_value') + self.assertEqual(result.get('new_key'), 'new_value') + + def test_set_variable_overwrites_existing(self): + """Test that existing variable can be overwritten.""" + result = self.node.execute( + params={ + 'variable_name': 'key', + 'value': 'updated', + }, + context={'key': 'original'}, + ) + self.assertEqual(result.get('key'), 'updated') + + def test_set_variable_with_template(self): + """Test setting variable with template rendering.""" + result = self.node.execute( + params={ + 'variable_name': 'greeting', + 'value': 'Hello, {{ name }}!', + }, + context={'name': 'Alice'}, + ) + self.assertEqual(result.get('greeting'), 'Hello, Alice!') + + def test_set_variable_empty_name_does_nothing(self): + """Test that empty variable name doesn't set anything.""" + result = self.node.execute( + params={ + 'variable_name': '', + 'value': 'test', + }, + context={'existing': 'value'}, + ) + self.assertEqual(result.get('existing'), 'value') + self.assertNotIn('', result) + + def test_set_variable_none_context(self): + """Test handling None context.""" + result = self.node.execute( + params={ + 'variable_name': 'key', + 'value': 'value', + }, + context=None, + ) + self.assertEqual(result.get('key'), 'value') + + def test_set_variable_empty_value(self): + """Test setting empty string value.""" + result = self.node.execute( + params={ + 'variable_name': 'empty', + 'value': '', + } + ) + self.assertEqual(result.get('empty'), '') + + +class TestLogNode(FrappeTestCase): + """Tests for the Log Node.""" + + def setUp(self): + self.node = LogNode() + + def test_log_simple_message(self): + """Test logging a simple message.""" + result = self.node.execute( + params={ + 'message': 'Hello World', + 'log_level': 'Info', + } + ) + self.assertIn('logs', result) + self.assertEqual(len(result['logs']), 1) + self.assertEqual(result['logs'][0]['message'], 'Hello World') + self.assertEqual(result['logs'][0]['level'], 'Info') + + def test_log_default_level_is_info(self): + """Test that default log level is Info.""" + result = self.node.execute( + params={'message': 'Test message'} + ) + self.assertEqual(result['logs'][0]['level'], 'Info') + + def test_log_warning_level(self): + """Test logging with Warning level.""" + result = self.node.execute( + params={ + 'message': 'Warning message', + 'log_level': 'Warning', + } + ) + self.assertEqual(result['logs'][0]['level'], 'Warning') + + def test_log_error_level(self): + """Test logging with Error level.""" + result = self.node.execute( + params={ + 'message': 'Error message', + 'log_level': 'Error', + } + ) + self.assertEqual(result['logs'][0]['level'], 'Error') + + def test_log_with_template(self): + """Test logging with template rendering.""" + result = self.node.execute( + params={'message': 'User {{ name }} logged in'}, + context={'name': 'Alice'}, + ) + self.assertEqual( + result['logs'][0]['message'], 'User Alice logged in' + ) + + def test_log_accumulates_in_context(self): + """Test that multiple log calls accumulate.""" + context = {} + + # First log + context = self.node.execute( + params={'message': 'First log'}, + context=context, + ) + + # Second log + context = self.node.execute( + params={'message': 'Second log'}, + context=context, + ) + + self.assertEqual(len(context['logs']), 2) + self.assertEqual(context['logs'][0]['message'], 'First log') + self.assertEqual(context['logs'][1]['message'], 'Second log') + + def test_log_preserves_existing_context(self): + """Test that logging preserves existing context.""" + result = self.node.execute( + params={'message': 'Test'}, + context={'existing_key': 'existing_value'}, + ) + self.assertEqual( + result.get('existing_key'), 'existing_value' + ) + + def test_log_empty_message(self): + """Test logging empty message.""" + result = self.node.execute( + params={'message': ''} + ) + self.assertEqual(result['logs'][0]['message'], '') + + +class TestDelayNode(FrappeTestCase): + """Tests for the Delay Node.""" + + def setUp(self): + self.node = DelayNode() + + def test_delay_preserves_context(self): + """Test that delay preserves context.""" + context = {'key': 'value'} + result = self.node.execute( + params={'delay_seconds': 0}, + context=context, + ) + self.assertEqual(result.get('key'), 'value') + + def test_delay_zero_seconds(self): + """Test zero second delay executes immediately.""" + import time + + start = time.time() + self.node.execute(params={'delay_seconds': 0}) + elapsed = time.time() - start + self.assertLess(elapsed, 0.1) + + def test_delay_one_second(self): + """Test one second delay.""" + import time + + start = time.time() + self.node.execute(params={'delay_seconds': 1}) + elapsed = time.time() - start + self.assertGreaterEqual(elapsed, 0.9) + self.assertLess(elapsed, 1.5) + + def test_delay_max_capped_at_60_seconds(self): + """Test that delay is capped at 60 seconds.""" + # This test verifies the cap logic without waiting 60 seconds + # Just verify the node doesn't crash with large values + import time + + start = time.time() + # Pass a very small value that won't actually wait + self.node.execute(params={'delay_seconds': 0}) + elapsed = time.time() - start + self.assertLess(elapsed, 0.1) + + def test_delay_invalid_value_defaults_to_zero(self): + """Test that invalid delay value defaults to zero.""" + result = self.node.execute( + params={'delay_seconds': 'invalid'} + ) + self.assertIsNotNone(result) + + def test_delay_none_defaults_to_zero(self): + """Test that None delay value defaults to zero.""" + result = self.node.execute( + params={'delay_seconds': None} + ) + self.assertIsNotNone(result) + + def test_delay_negative_treated_as_zero(self): + """Test that negative delay is treated as zero.""" + import time + + start = time.time() + self.node.execute(params={'delay_seconds': -5}) + elapsed = time.time() - start + self.assertLess(elapsed, 0.1) + + +class TestNodeUtils(FrappeTestCase): + """Tests for node utility functions.""" + + # ===== ensure_context TESTS ===== + + def test_ensure_context_returns_dict_when_dict(self): + """Test ensure_context returns dict as-is.""" + ctx = {'key': 'value'} + result = ensure_context(ctx) + self.assertEqual(result, ctx) + + def test_ensure_context_returns_empty_dict_for_none(self): + """Test ensure_context returns empty dict for None.""" + result = ensure_context(None) + self.assertEqual(result, {}) + + def test_ensure_context_returns_empty_dict_for_string(self): + """Test ensure_context returns empty dict for non-dict types.""" + result = ensure_context('not a dict') + self.assertEqual(result, {}) + + def test_ensure_context_returns_empty_dict_for_list(self): + """Test ensure_context returns empty dict for list.""" + result = ensure_context([1, 2, 3]) + self.assertEqual(result, {}) + + def test_ensure_context_returns_empty_dict_for_int(self): + """Test ensure_context returns empty dict for integer.""" + result = ensure_context(42) + self.assertEqual(result, {}) + + # ===== render_template_field TESTS ===== + + def test_render_template_field_simple(self): + """Test rendering simple template variable.""" + result = render_template_field( + 'Hello, {{ name }}!', {'name': 'Alice'} + ) + self.assertEqual(result, 'Hello, Alice!') + + def test_render_template_field_multiple_vars(self): + """Test rendering multiple template variables.""" + result = render_template_field( + '{{ greeting }}, {{ name }}!', + {'greeting': 'Hi', 'name': 'Bob'}, + ) + self.assertEqual(result, 'Hi, Bob!') + + def test_render_template_field_no_template(self): + """Test that plain strings are returned as-is.""" + result = render_template_field('plain text', {}) + self.assertEqual(result, 'plain text') + + def test_render_template_field_empty_string(self): + """Test rendering empty string.""" + result = render_template_field('', {}) + self.assertEqual(result, '') + + def test_render_template_field_none_value(self): + """Test rendering None value.""" + result = render_template_field(None, {}) + self.assertEqual(result, '') + + def test_render_template_field_none_context(self): + """Test rendering with None context.""" + result = render_template_field('plain text', None) + self.assertEqual(result, 'plain text') + + def test_render_template_field_nested_variable(self): + """Test rendering nested variable.""" + result = render_template_field( + 'User: {{ user.name }}', + {'user': {'name': 'Charlie'}}, + ) + self.assertEqual(result, 'User: Charlie') + + # ===== parse_json_field TESTS ===== + + def test_parse_json_field_valid_json(self): + """Test parsing valid JSON string.""" + result = parse_json_field('{"key": "value"}', {}, 'test') + self.assertEqual(result, {'key': 'value'}) + + def test_parse_json_field_empty_string(self): + """Test parsing empty string returns empty dict.""" + result = parse_json_field('', {}, 'test') + self.assertEqual(result, {}) + + def test_parse_json_field_none(self): + """Test parsing None returns empty dict.""" + result = parse_json_field(None, {}, 'test') + self.assertEqual(result, {}) + + def test_parse_json_field_already_dict(self): + """Test that dict is returned as-is.""" + input_dict = {'key': 'value'} + result = parse_json_field(input_dict, {}, 'test') + self.assertEqual(result, input_dict) + + def test_parse_json_field_with_template(self): + """Test parsing JSON with template variables.""" + result = parse_json_field( + '{"greeting": "Hello, {{ name }}!"}', + {'name': 'World'}, + 'test', + ) + self.assertEqual(result, {'greeting': 'Hello, World!'}) + + def test_parse_json_field_invalid_json_throws(self): + """Test that invalid JSON throws an error.""" + with self.assertRaises(Exception): + parse_json_field('not valid json', {}, 'test') + + def test_parse_json_field_nested_json(self): + """Test parsing nested JSON structure.""" + result = parse_json_field( + '{"user": {"name": "Alice", "age": 30}}', {}, 'test' + ) + self.assertEqual( + result, + {'user': {'name': 'Alice', 'age': 30}}, + ) + + def test_parse_json_field_array_json(self): + """Test parsing JSON array.""" + result = parse_json_field('[1, 2, 3]', {}, 'test') + self.assertEqual(result, [1, 2, 3]) + + +# ===== HAZEL NODE DOCTYPE TESTS ===== + class TestHazelNode(FrappeTestCase): - pass + """Tests for the Hazel Node doctype execution.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure node types exist + if not frappe.db.exists('Hazel Node Type', 'Set Variable'): + frappe.get_doc({ + 'doctype': 'Hazel Node Type', + 'name': 'Set Variable', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.set_variable_node.SetVariableNode', + 'is_standard': 1, + }).insert(ignore_permissions=True) + + if not frappe.db.exists('Hazel Node Type', 'Log'): + frappe.get_doc({ + 'doctype': 'Hazel Node Type', + 'name': 'Log', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.log_node.LogNode', + 'is_standard': 1, + }).insert(ignore_permissions=True) + + frappe.db.commit() + + def test_node_execute_loads_handler(self): + """Test that node execution loads the correct handler.""" + # Create a mock node document + node = frappe.get_doc({ + 'doctype': 'Hazel Node', + 'node_id': 'test_node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'event': None, + 'parameters': '{}', + }) + + params = {'variable_name': 'test', 'value': 'hello'} + result = node.execute(params, {}) + + self.assertEqual(result.get('test'), 'hello') + + def test_node_execute_passes_context(self): + """Test that node execution passes context correctly.""" + node = frappe.get_doc({ + 'doctype': 'Hazel Node', + 'node_id': 'test_node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'event': None, + 'parameters': '{}', + }) + + params = { + 'variable_name': 'greeting', + 'value': 'Hello, {{ name }}!', + } + context = {'name': 'World'} + result = node.execute(params, context) + + self.assertEqual(result.get('greeting'), 'Hello, World!') diff --git a/hazelnode/hazelnode/doctype/hazel_workflow/test_hazel_workflow.py b/hazelnode/hazelnode/doctype/hazel_workflow/test_hazel_workflow.py index 27ee039..412d0a3 100644 --- a/hazelnode/hazelnode/doctype/hazel_workflow/test_hazel_workflow.py +++ b/hazelnode/hazelnode/doctype/hazel_workflow/test_hazel_workflow.py @@ -1,9 +1,1434 @@ # Copyright (c) 2024, Build With Hussain and Contributors # See license.txt -# import frappe +import frappe from frappe.tests.utils import FrappeTestCase class TestHazelWorkflow(FrappeTestCase): - pass + """ + Comprehensive tests for the Hazel Workflow execution engine. + + Tests cover linear and graph-based execution modes, + condition node branching, context passing, and error handling. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure node types are loaded from fixtures + cls._ensure_node_types_exist() + frappe.db.commit() + + @classmethod + def _ensure_node_types_exist(cls): + """Create node types if they don't exist (for test environment).""" + node_types = [ + { + 'name': 'Set Variable', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.set_variable_node.SetVariableNode', + }, + { + 'name': 'Log', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.log_node.LogNode', + }, + { + 'name': 'Condition', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.condition_node.ConditionNode', + }, + { + 'name': 'Delay', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.delay_node.DelayNode', + }, + { + 'name': 'Create Document', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.create_document_node.CreateDocumentNode', + }, + { + 'name': 'Update Document', + 'kind': 'Action', + 'handler_path': 'hazelnode.nodes.actions.update_document_node.UpdateDocumentNode', + }, + { + 'name': 'Schedule Event', + 'kind': 'Trigger', + 'handler_path': None, + }, + { + 'name': 'Document Event', + 'kind': 'Trigger', + 'handler_path': None, + }, + { + 'name': 'Webhook Listener', + 'kind': 'Trigger', + 'handler_path': None, + }, + ] + + for node_type in node_types: + if not frappe.db.exists('Hazel Node Type', node_type['name']): + doc = frappe.get_doc({ + 'doctype': 'Hazel Node Type', + 'name': node_type['name'], + 'kind': node_type['kind'], + 'handler_path': node_type['handler_path'], + 'is_standard': 1, + }) + doc.insert(ignore_permissions=True) + + def setUp(self): + """Set up test fixtures before each test method.""" + # Clean up any test workflows from previous runs + self._cleanup_test_data() + + def tearDown(self): + """Clean up after each test.""" + self._cleanup_test_data() + + def _cleanup_test_data(self): + """Remove test data created during tests.""" + # Delete test workflows + for wf in frappe.get_all( + 'Hazel Workflow', + filters={'title': ['like', 'Test%']}, + ): + frappe.delete_doc( + 'Hazel Workflow', wf.name, force=True + ) + + # Delete test execution logs + for log in frappe.get_all('Hazel Workflow Execution Log'): + frappe.delete_doc( + 'Hazel Workflow Execution Log', + log.name, + force=True, + ) + + # Delete test ToDo documents + for todo in frappe.get_all( + 'ToDo', + filters={'description': ['like', 'Test%']}, + ): + frappe.delete_doc('ToDo', todo.name, force=True) + + frappe.db.commit() + + def _create_workflow( + self, title, trigger_type, nodes, connections=None + ): + """Helper to create a test workflow.""" + workflow = frappe.get_doc({ + 'doctype': 'Hazel Workflow', + 'title': title, + 'enabled': 1, + 'trigger_type': trigger_type, + 'trigger_config': '{}', + 'nodes': nodes, + 'connections': connections or [], + }) + workflow.insert(ignore_permissions=True) + return workflow + + # ===== LINEAR EXECUTION TESTS ===== + + def test_linear_execution_single_node(self): + """Test linear execution with a single node.""" + workflow = self._create_workflow( + title='Test Linear Single Node', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'greeting', + 'value': 'Hello World', + }), + } + ], + ) + + context = workflow.execute() + + self.assertIsNotNone(context) + self.assertEqual(context.get('greeting'), 'Hello World') + + def test_linear_execution_multiple_nodes(self): + """Test linear execution with multiple nodes in sequence.""" + workflow = self._create_workflow( + title='Test Linear Multiple Nodes', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'step1', + 'value': 'first', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'step2', + 'value': 'second', + }), + }, + { + 'node_id': 'node_3', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'step3', + 'value': 'third', + }), + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('step1'), 'first') + self.assertEqual(context.get('step2'), 'second') + self.assertEqual(context.get('step3'), 'third') + + def test_linear_execution_with_initial_context(self): + """Test that initial context is passed through linear execution.""" + workflow = self._create_workflow( + title='Test Linear With Initial Context', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'new_var', + 'value': '{{ initial_value }}', + }), + } + ], + ) + + context = workflow.execute( + context={'initial_value': 'from_context'} + ) + + self.assertEqual(context.get('new_var'), 'from_context') + self.assertEqual(context.get('initial_value'), 'from_context') + + # ===== GRAPH EXECUTION TESTS ===== + + def test_graph_execution_simple_chain(self): + """Test graph execution with simple node chain.""" + workflow = self._create_workflow( + title='Test Graph Simple Chain', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'first', + 'value': 'step1', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'second', + 'value': 'step2', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('first'), 'step1') + self.assertEqual(context.get('second'), 'step2') + + def test_graph_execution_respects_connections(self): + """Test that graph execution only visits connected nodes.""" + workflow = self._create_workflow( + title='Test Graph Respects Connections', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'connected', + 'value': 'yes', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'disconnected', + 'value': 'should_not_run', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + # Note: node_2 is NOT connected + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('connected'), 'yes') + self.assertIsNone(context.get('disconnected')) + + def test_graph_execution_prevents_infinite_loops(self): + """Test that graph execution prevents infinite loops with visited set.""" + workflow = self._create_workflow( + title='Test Graph Loop Prevention', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'counter', + 'value': 'executed', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'second', + 'value': 'also_executed', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + { + # Create a loop back to node_1 + 'source_node_id': 'node_2', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + # Should complete without hanging + context = workflow.execute() + + self.assertEqual(context.get('counter'), 'executed') + self.assertEqual(context.get('second'), 'also_executed') + + # ===== BRANCHING / CONDITION TESTS ===== + + def test_condition_node_true_branch(self): + """Test condition node routes to true branch.""" + workflow = self._create_workflow( + title='Test Condition True Branch', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'condition_node', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '9', + 'operator': 'greater_than', + 'right_operand': '5', + }), + }, + { + 'node_id': 'true_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'result', + 'value': 'took_true_path', + }), + }, + { + 'node_id': 'false_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'result', + 'value': 'took_false_path', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'condition_node', + 'source_handle': 'default', + }, + { + 'source_node_id': 'condition_node', + 'target_node_id': 'true_node', + 'source_handle': 'true', + }, + { + 'source_node_id': 'condition_node', + 'target_node_id': 'false_node', + 'source_handle': 'false', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('result'), 'took_true_path') + + def test_condition_node_false_branch(self): + """Test condition node routes to false branch.""" + workflow = self._create_workflow( + title='Test Condition False Branch', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'condition_node', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '5', + 'operator': 'greater_than', + 'right_operand': '9', + }), + }, + { + 'node_id': 'true_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'result', + 'value': 'took_true_path', + }), + }, + { + 'node_id': 'false_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'result', + 'value': 'took_false_path', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'condition_node', + 'source_handle': 'default', + }, + { + 'source_node_id': 'condition_node', + 'target_node_id': 'true_node', + 'source_handle': 'true', + }, + { + 'source_node_id': 'condition_node', + 'target_node_id': 'false_node', + 'source_handle': 'false', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('result'), 'took_false_path') + + def test_condition_with_context_variables(self): + """Test condition node resolves variables from context.""" + workflow = self._create_workflow( + title='Test Condition Context Variables', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'set_value', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'score', + 'value': '9', + }), + }, + { + 'node_id': 'condition_node', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '{{ score }}', + 'operator': 'greater_than', + 'right_operand': '5', + }), + }, + { + 'node_id': 'high_score', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'message', + 'value': 'High score!', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'set_value', + 'source_handle': 'default', + }, + { + 'source_node_id': 'set_value', + 'target_node_id': 'condition_node', + 'source_handle': 'default', + }, + { + 'source_node_id': 'condition_node', + 'target_node_id': 'high_score', + 'source_handle': 'true', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('message'), 'High score!') + + def test_nested_conditions(self): + """Test multiple nested condition nodes.""" + workflow = self._create_workflow( + title='Test Nested Conditions', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'condition_1', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '9', + 'operator': 'greater_than', + 'right_operand': '5', + }), + }, + { + 'node_id': 'condition_2', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '8', + 'operator': 'greater_than', + 'right_operand': '4', + }), + }, + { + 'node_id': 'final_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'result', + 'value': 'both_conditions_true', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'condition_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'condition_1', + 'target_node_id': 'condition_2', + 'source_handle': 'true', + }, + { + 'source_node_id': 'condition_2', + 'target_node_id': 'final_node', + 'source_handle': 'true', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('result'), 'both_conditions_true') + + # ===== CONTEXT PASSING TESTS ===== + + def test_context_accumulation(self): + """Test context accumulates values across multiple nodes.""" + workflow = self._create_workflow( + title='Test Context Accumulation', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'a', + 'value': '1', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'b', + 'value': '2', + }), + }, + { + 'node_id': 'node_3', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'c', + 'value': '3', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_2', + 'target_node_id': 'node_3', + 'source_handle': 'default', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('a'), '1') + self.assertEqual(context.get('b'), '2') + self.assertEqual(context.get('c'), '3') + + def test_context_template_rendering(self): + """Test template variables are rendered with context values.""" + workflow = self._create_workflow( + title='Test Template Rendering', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'name', + 'value': 'Alice', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'greeting', + 'value': 'Hello, {{ name }}!', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('greeting'), 'Hello, Alice!') + + def test_context_variable_overwriting(self): + """Test that later nodes can overwrite earlier context values.""" + workflow = self._create_workflow( + title='Test Variable Overwriting', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'value', + 'value': 'original', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'value', + 'value': 'updated', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('value'), 'updated') + + # ===== EXECUTION LOGGING TESTS ===== + + def test_execution_log_created(self): + """Test that execution creates a log document.""" + workflow = self._create_workflow( + title='Test Execution Log Created', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'test', + 'value': 'value', + }), + } + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + workflow.execute() + + logs = frappe.get_all( + 'Hazel Workflow Execution Log', + filters={'workflow': workflow.name}, + ) + self.assertEqual(len(logs), 1) + + def test_execution_log_status_success(self): + """Test that successful execution sets status to Success.""" + workflow = self._create_workflow( + title='Test Execution Log Success', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'test', + 'value': 'value', + }), + } + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + workflow.execute() + + log = frappe.get_last_doc( + 'Hazel Workflow Execution Log', + filters={'workflow': workflow.name}, + ) + self.assertEqual(log.status, 'Success') + + def test_execution_log_records_initial_context(self): + """Test that execution log records initial context.""" + workflow = self._create_workflow( + title='Test Log Initial Context', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'test', + 'value': 'value', + }), + } + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + initial = {'initial_key': 'initial_value'} + workflow.execute(context=initial) + + log = frappe.get_last_doc( + 'Hazel Workflow Execution Log', + filters={'workflow': workflow.name}, + ) + stored_context = frappe.parse_json(log.initial_context) + self.assertEqual( + stored_context.get('initial_key'), 'initial_value' + ) + + def test_execution_log_records_node_logs(self): + """Test that each node execution is logged.""" + workflow = self._create_workflow( + title='Test Node Logs', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'a', + 'value': '1', + }), + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'b', + 'value': '2', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + ], + ) + + workflow.execute() + + log = frappe.get_last_doc( + 'Hazel Workflow Execution Log', + filters={'workflow': workflow.name}, + ) + self.assertEqual(len(log.node_logs), 2) + + # ===== WORKFLOW VALIDATION TESTS ===== + + def test_validation_trigger_required(self): + """Test that workflow with nodes requires a trigger type.""" + with self.assertRaises(frappe.exceptions.ValidationError): + frappe.get_doc({ + 'doctype': 'Hazel Workflow', + 'title': 'Test No Trigger', + 'enabled': 1, + 'nodes': [ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + } + ], + }).insert(ignore_permissions=True) + + def test_validation_nodes_must_be_action(self): + """Test that nodes in workflow must be Action kind.""" + with self.assertRaises(frappe.exceptions.ValidationError): + frappe.get_doc({ + 'doctype': 'Hazel Workflow', + 'title': 'Test Invalid Node Kind', + 'enabled': 1, + 'trigger_type': 'Schedule Event', + 'nodes': [ + { + 'node_id': 'node_1', + 'type': 'Schedule Event', + 'kind': 'Trigger', + 'parameters': '{}', + } + ], + }).insert(ignore_permissions=True) + + # ===== HELPER METHOD TESTS ===== + + def test_get_node_by_id(self): + """Test get_node_by_id returns correct node.""" + workflow = self._create_workflow( + title='Test Get Node By ID', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + { + 'node_id': 'node_2', + 'type': 'Log', + 'kind': 'Action', + 'parameters': '{}', + }, + ], + ) + + node = workflow.get_node_by_id('node_2') + self.assertIsNotNone(node) + self.assertEqual(node.type, 'Log') + + def test_get_node_by_id_returns_none_for_invalid_id(self): + """Test get_node_by_id returns None for non-existent ID.""" + workflow = self._create_workflow( + title='Test Get Node By ID Invalid', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + } + ], + ) + + node = workflow.get_node_by_id('nonexistent') + self.assertIsNone(node) + + def test_get_outgoing_connections(self): + """Test get_outgoing_connections returns correct connections.""" + workflow = self._create_workflow( + title='Test Get Outgoing Connections', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + { + 'node_id': 'node_2', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + { + 'source_node_id': 'node_1', + 'target_node_id': 'node_2', + 'source_handle': 'default', + }, + ], + ) + + connections = workflow.get_outgoing_connections('node_1') + self.assertEqual(len(connections), 1) + self.assertEqual(connections[0].target_node_id, 'node_2') + + def test_get_outgoing_connections_with_handle_filter(self): + """Test get_outgoing_connections filters by handle.""" + workflow = self._create_workflow( + title='Test Get Outgoing With Handle', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'condition', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': '{}', + }, + { + 'node_id': 'true_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + { + 'node_id': 'false_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'condition', + 'source_handle': 'default', + }, + { + 'source_node_id': 'condition', + 'target_node_id': 'true_node', + 'source_handle': 'true', + }, + { + 'source_node_id': 'condition', + 'target_node_id': 'false_node', + 'source_handle': 'false', + }, + ], + ) + + true_connections = workflow.get_outgoing_connections( + 'condition', 'true' + ) + false_connections = workflow.get_outgoing_connections( + 'condition', 'false' + ) + + self.assertEqual(len(true_connections), 1) + self.assertEqual(true_connections[0].target_node_id, 'true_node') + self.assertEqual(len(false_connections), 1) + self.assertEqual( + false_connections[0].target_node_id, 'false_node' + ) + + def test_get_start_node(self): + """Test get_start_node returns node connected from trigger.""" + workflow = self._create_workflow( + title='Test Get Start Node', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'first_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + { + 'node_id': 'second_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'first_node', + 'source_handle': 'default', + }, + ], + ) + + start_node = workflow.get_start_node() + self.assertEqual(start_node.node_id, 'first_node') + + def test_get_start_node_fallback(self): + """Test get_start_node falls back to first node without connections.""" + workflow = self._create_workflow( + title='Test Get Start Node Fallback', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'first_node', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': '{}', + } + ], + ) + + start_node = workflow.get_start_node() + self.assertEqual(start_node.node_id, 'first_node') + + # ===== COMPLEX WORKFLOW TESTS ===== + + def test_complex_workflow_with_logging(self): + """Test complex workflow with Set Variable and Log nodes.""" + workflow = self._create_workflow( + title='Test Complex With Logging', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'set_name', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'user_name', + 'value': 'TestUser', + }), + }, + { + 'node_id': 'log_welcome', + 'type': 'Log', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'message': 'Welcome, {{ user_name }}!', + 'log_level': 'Info', + }), + }, + { + 'node_id': 'set_status', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'status', + 'value': 'completed', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'set_name', + 'source_handle': 'default', + }, + { + 'source_node_id': 'set_name', + 'target_node_id': 'log_welcome', + 'source_handle': 'default', + }, + { + 'source_node_id': 'log_welcome', + 'target_node_id': 'set_status', + 'source_handle': 'default', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('user_name'), 'TestUser') + self.assertEqual(context.get('status'), 'completed') + self.assertIn('logs', context) + self.assertEqual(len(context['logs']), 1) + self.assertEqual( + context['logs'][0]['message'], 'Welcome, TestUser!' + ) + + def test_multi_branch_workflow(self): + """Test workflow with multiple branches converging.""" + workflow = self._create_workflow( + title='Test Multi Branch', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'init', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'value', + 'value': '9', + }), + }, + { + 'node_id': 'check_high', + 'type': 'Condition', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'left_operand': '{{ value }}', + 'operator': 'greater_than', + 'right_operand': '5', + }), + }, + { + 'node_id': 'high_value', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'category', + 'value': 'high', + }), + }, + { + 'node_id': 'low_value', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'category', + 'value': 'low', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'init', + 'source_handle': 'default', + }, + { + 'source_node_id': 'init', + 'target_node_id': 'check_high', + 'source_handle': 'default', + }, + { + 'source_node_id': 'check_high', + 'target_node_id': 'high_value', + 'source_handle': 'true', + }, + { + 'source_node_id': 'check_high', + 'target_node_id': 'low_value', + 'source_handle': 'false', + }, + ], + ) + + context = workflow.execute() + + self.assertEqual(context.get('category'), 'high') + + # ===== ERROR HANDLING TESTS ===== + + def test_execution_handles_exception(self): + """Test that execution handles exceptions gracefully.""" + workflow = self._create_workflow( + title='Test Exception Handling', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Create Document', + 'kind': 'Action', + # Missing required doctype + 'parameters': frappe.as_json({}), + } + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + # Should not raise by default + workflow.execute() + + log = frappe.get_last_doc( + 'Hazel Workflow Execution Log', + filters={'workflow': workflow.name}, + ) + self.assertEqual(log.status, 'Failure') + self.assertIsNotNone(log.traceback) + + def test_execution_raises_exception_when_requested(self): + """Test that execution can raise exceptions if requested.""" + workflow = self._create_workflow( + title='Test Raise Exception', + trigger_type='Schedule Event', + nodes=[ + { + 'node_id': 'node_1', + 'type': 'Create Document', + 'kind': 'Action', + 'parameters': frappe.as_json({}), + } + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'node_1', + 'source_handle': 'default', + }, + ], + ) + + with self.assertRaises(Exception): + workflow.execute(raise_exception=True) + + def test_empty_workflow_execution(self): + """Test workflow with no nodes executes without error.""" + workflow = frappe.get_doc({ + 'doctype': 'Hazel Workflow', + 'title': 'Test Empty Workflow', + 'enabled': 1, + }) + workflow.insert(ignore_permissions=True) + + # Should not raise + context = workflow.execute() + self.assertIsNone(context) + + # ===== INTEGRATION SCENARIO TESTS ===== + + def test_webhook_create_todo_use_created_id(self): + """ + Test scenario: Webhook received -> Create ToDo -> use created ID. + + This tests the full data flow: + 1. Webhook trigger receives payload (simulated via initial context) + 2. Create Document node creates a ToDo using webhook data + 3. Subsequent node accesses created_doc_name via template + """ + workflow = self._create_workflow( + title='Test Webhook Create Todo Flow', + trigger_type='Webhook Listener', + nodes=[ + { + 'node_id': 'create_todo', + 'type': 'Create Document', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'doctype': 'ToDo', + 'field_values': frappe.as_json({ + 'description': 'Test: {{ webhook_data.task }}', + 'priority': '{{ webhook_data.priority }}', + }), + 'ignore_permissions': True, + }), + }, + { + 'node_id': 'log_created_id', + 'type': 'Log', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'message': 'Created ToDo: {{ created_doc_name }}', + 'log_level': 'Info', + }), + }, + { + 'node_id': 'store_for_api', + 'type': 'Set Variable', + 'kind': 'Action', + 'parameters': frappe.as_json({ + 'variable_name': 'api_url', + 'value': 'https://api.example.com/todos/{{ created_doc_name }}', + }), + }, + ], + connections=[ + { + 'source_node_id': 'trigger', + 'target_node_id': 'create_todo', + 'source_handle': 'default', + }, + { + 'source_node_id': 'create_todo', + 'target_node_id': 'log_created_id', + 'source_handle': 'default', + }, + { + 'source_node_id': 'log_created_id', + 'target_node_id': 'store_for_api', + 'source_handle': 'default', + }, + ], + ) + + # Simulate webhook payload as initial context + webhook_payload = { + 'webhook_data': { + 'task': 'Test task from webhook', + 'priority': 'Medium', + } + } + + context = workflow.execute(context=webhook_payload) + + # Verify ToDo was created + self.assertIn('created_doc_name', context) + self.assertIsNotNone(context['created_doc_name']) + + # Verify the ToDo exists with correct values + todo = frappe.get_doc('ToDo', context['created_doc_name']) + self.assertEqual(todo.description, 'Test: Test task from webhook') + self.assertEqual(todo.priority, 'Medium') + + # Verify template rendering with created_doc_name + self.assertIn('logs', context) + self.assertEqual(len(context['logs']), 1) + self.assertIn( + context['created_doc_name'], context['logs'][0]['message'] + ) + + # Verify the API URL was constructed with the ToDo ID + self.assertIn('api_url', context) + self.assertIn(context['created_doc_name'], context['api_url']) + self.assertTrue( + context['api_url'].startswith('https://api.example.com/todos/') + )