797 lines
31 KiB
Python
797 lines
31 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
from app.services.workflow_execution import WorkflowExecutionService
|
|
from app.models.workflow_node import WorkflowNode, NodeType, ExitCondition
|
|
from app.agents.chat_agent import ChatResponse
|
|
from app.models.workflow import Workflow, WorkflowStatus
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
return Mock()
|
|
|
|
|
|
@pytest.fixture
|
|
def workflow_service(mock_db):
|
|
return WorkflowExecutionService(mock_db)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_workflow():
|
|
workflow = MagicMock(spec=Workflow)
|
|
workflow.id = uuid4()
|
|
workflow.name = "Test Workflow"
|
|
workflow.nodes = [] # List to store nodes
|
|
workflow.connections = [] # List to store connections
|
|
workflow.status = WorkflowStatus.PUBLISHED
|
|
return workflow
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_workflow_node():
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.LLM
|
|
config_dict = {
|
|
"system_prompt": "You are a helpful assistant.",
|
|
"exit_condition": ExitCondition.CONTINUOUS_EXECUTION,
|
|
"model_config": {
|
|
"temperature": 0.7,
|
|
"max_tokens": 150
|
|
}
|
|
}
|
|
node.config = MagicMock()
|
|
node.config.__getitem__.side_effect = config_dict.__getitem__
|
|
node.config.get.side_effect = config_dict.get
|
|
return node
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_chat_history():
|
|
"""Create sample chat history for testing"""
|
|
from datetime import datetime
|
|
history = [
|
|
Mock(),
|
|
Mock(),
|
|
Mock()
|
|
]
|
|
history[0].message = "Hello! How can I help you today?"
|
|
history[0].message_type = "assistant"
|
|
history[0].created_at = datetime.now()
|
|
history[0].attributes = {}
|
|
history[1].message = "I need help with my order"
|
|
history[1].message_type = "user"
|
|
history[1].created_at = datetime.now()
|
|
history[1].attributes = {}
|
|
history[2].message = "I'll help you with your order. What's your order number?"
|
|
history[2].message_type = "assistant"
|
|
history[2].created_at = datetime.now()
|
|
history[2].attributes = {}
|
|
return history
|
|
|
|
|
|
class TestWorkflowExecutionService:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_message_with_full_history(
|
|
self, workflow_service, sample_chat_history
|
|
):
|
|
"""Test building context message with full chat history"""
|
|
|
|
# Mock repositories
|
|
mock_chat_repo = Mock()
|
|
mock_chat_repo.get_session_history = AsyncMock(return_value=sample_chat_history)
|
|
|
|
with patch('app.services.workflow_execution.ChatRepository', return_value=mock_chat_repo), \
|
|
patch.object(workflow_service.session_repo, 'get_workflow_history', return_value=[]):
|
|
|
|
# Build context message
|
|
context_message = await workflow_service._build_context_message("test-session", {})
|
|
|
|
# Verify context message contains expected structure and chat history
|
|
assert "CONTEXT on previous workflow messages" in context_message
|
|
assert "Hello! How can I help you today?" in context_message
|
|
assert "I need help with my order" in context_message
|
|
assert "I'll help you with your order. What's your order number?" in context_message
|
|
assert "CONVERSATION HISTORY:" in context_message
|
|
# Instruction header changed in implementation; detailed analysis instructions are implicit now
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_message_empty_history(self, workflow_service):
|
|
"""Test building context message with empty chat history"""
|
|
|
|
# Mock repositories with empty history
|
|
mock_chat_repo = Mock()
|
|
mock_chat_repo.get_session_history = AsyncMock(return_value=[])
|
|
|
|
with patch('app.services.workflow_execution.ChatRepository', return_value=mock_chat_repo), \
|
|
patch.object(workflow_service.session_repo, 'get_workflow_history', return_value=[]):
|
|
|
|
# Build context message
|
|
context_message = await workflow_service._build_context_message("test-session", {})
|
|
|
|
# Verify context message structure for empty history
|
|
assert "CONTEXT on previous workflow messages" in context_message
|
|
assert "No previous messages" in context_message
|
|
assert "No previous workflow interactions" in context_message
|
|
# Instruction header changed in implementation; detailed analysis instructions are implicit now
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_context_message_error_handling(self, workflow_service):
|
|
"""Test building context message with error in repository"""
|
|
|
|
# Mock repositories to raise exception
|
|
mock_chat_repo = Mock()
|
|
mock_chat_repo.get_session_history = AsyncMock(side_effect=Exception("Test error"))
|
|
|
|
with patch('app.services.workflow_execution.ChatRepository', return_value=mock_chat_repo), \
|
|
patch.object(workflow_service.session_repo, 'get_workflow_history', return_value=[]):
|
|
|
|
# Build context message
|
|
context_message = await workflow_service._build_context_message("test-session", {})
|
|
|
|
# Verify fallback context message on error
|
|
assert "Please analyze the current conversation context" in context_message
|
|
assert "provide an appropriate response" in context_message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_llm_node_with_empty_message(
|
|
self, workflow_service, sample_workflow_node, sample_workflow, sample_chat_history
|
|
):
|
|
"""Test LLM node execution with empty user message"""
|
|
|
|
# Mock chat agent and its response
|
|
mock_chat_agent = AsyncMock()
|
|
mock_response = Mock()
|
|
mock_response.message = "Based on our conversation, I can see you're asking about order ORDER123. Let me check that for you."
|
|
mock_response.transfer_to_human = False
|
|
mock_response.end_chat = False
|
|
mock_response.transfer_reason = None
|
|
mock_response.transfer_description = None
|
|
mock_response.success = True # Add success attribute
|
|
|
|
# Set up async method return value
|
|
mock_chat_agent._get_llm_response_only = AsyncMock(return_value=mock_response)
|
|
|
|
# Mock repositories
|
|
mock_chat_repo = Mock()
|
|
mock_chat_repo.get_session_history = AsyncMock(return_value=sample_chat_history)
|
|
|
|
with patch('app.services.workflow_execution.ChatAgent') as mock_chat_agent_class:
|
|
mock_chat_agent_class.create_async = AsyncMock(return_value=mock_chat_agent)
|
|
with patch('app.services.workflow_execution.ChatRepository', return_value=mock_chat_repo), \
|
|
patch.object(workflow_service.session_repo, 'get_workflow_history', return_value=[]):
|
|
|
|
# Test with empty user message
|
|
result = await workflow_service._execute_llm_node(
|
|
node=sample_workflow_node,
|
|
workflow=sample_workflow,
|
|
workflow_state={},
|
|
user_message="", # Empty message
|
|
api_key="test-key",
|
|
model_name="gpt-4",
|
|
model_type="openai",
|
|
base_url=None,
|
|
org_id="test-org",
|
|
agent_id="test-agent",
|
|
customer_id="test-customer",
|
|
session_id="test-session"
|
|
)
|
|
|
|
# Verify that context message was built and used
|
|
assert result.success is True
|
|
assert result.message == mock_response.message
|
|
assert result.next_node_id is None
|
|
assert result.should_continue is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_llm_node_with_normal_message(
|
|
self, workflow_service, sample_workflow_node, sample_workflow
|
|
):
|
|
"""Test LLM node execution with normal user message (should not use context building)"""
|
|
|
|
# Mock chat agent and its response
|
|
mock_chat_agent = AsyncMock()
|
|
mock_response = Mock()
|
|
mock_response.message = "I understand you want to check your order status."
|
|
mock_response.transfer_to_human = False
|
|
mock_response.end_chat = False
|
|
mock_response.transfer_reason = None
|
|
mock_response.transfer_description = None
|
|
mock_response.success = True # Add success attribute
|
|
|
|
# Set up async method return value
|
|
mock_chat_agent._get_llm_response_only = AsyncMock(return_value=mock_response)
|
|
|
|
with patch('app.services.workflow_execution.ChatAgent') as mock_chat_agent_class:
|
|
mock_chat_agent_class.create_async = AsyncMock(return_value=mock_chat_agent)
|
|
with patch.object(workflow_service.session_repo, 'get_workflow_history', return_value=[]):
|
|
|
|
# Test with normal user message
|
|
user_message = "I want to check my order status"
|
|
result = await workflow_service._execute_llm_node(
|
|
node=sample_workflow_node,
|
|
workflow=sample_workflow,
|
|
workflow_state={},
|
|
user_message=user_message,
|
|
api_key="test-key",
|
|
model_name="gpt-4",
|
|
model_type="openai",
|
|
base_url=None,
|
|
org_id="test-org",
|
|
agent_id="test-agent",
|
|
customer_id="test-customer",
|
|
session_id="test-session"
|
|
)
|
|
|
|
# Verify that original message was used
|
|
assert result.success is True
|
|
assert result.message == mock_response.message
|
|
assert result.next_node_id is None
|
|
assert result.should_continue is False
|
|
|
|
def test_find_start_node(self, workflow_service, sample_workflow):
|
|
"""Test finding the start node in a workflow"""
|
|
# Create start node with no incoming connections
|
|
start_node = Mock(spec=WorkflowNode)
|
|
start_node.id = uuid4()
|
|
start_node.node_type = NodeType.MESSAGE
|
|
start_node.incoming_connections = []
|
|
start_node.config = {}
|
|
|
|
# Create other nodes with incoming connections
|
|
other_node = Mock(spec=WorkflowNode)
|
|
other_node.id = uuid4()
|
|
other_node.node_type = NodeType.LLM
|
|
other_node.incoming_connections = [Mock()] # Has incoming connection
|
|
|
|
sample_workflow.nodes = [other_node, start_node]
|
|
|
|
# Find start node (should return the one with no incoming connections)
|
|
result = workflow_service._find_start_node(sample_workflow)
|
|
|
|
assert result.id == start_node.id
|
|
|
|
def test_find_node_by_id(self, workflow_service, sample_workflow):
|
|
"""Test finding a node by ID"""
|
|
node_id = uuid4()
|
|
target_node = Mock(spec=WorkflowNode)
|
|
target_node.id = node_id
|
|
|
|
other_node = Mock(spec=WorkflowNode)
|
|
other_node.id = uuid4()
|
|
|
|
sample_workflow.nodes = [other_node, target_node]
|
|
|
|
# Find existing node
|
|
result = workflow_service._find_node_by_id(sample_workflow, node_id)
|
|
assert result.id == node_id
|
|
|
|
# Try to find non-existent node
|
|
result = workflow_service._find_node_by_id(sample_workflow, uuid4())
|
|
assert result is None
|
|
|
|
def test_execute_message_node(self, workflow_service):
|
|
"""Test executing a MESSAGE node"""
|
|
next_node_id = uuid4()
|
|
connection = Mock()
|
|
connection.target_node_id = next_node_id
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.MESSAGE
|
|
node.config = {"message_text": "Hello {{user_name}}!"}
|
|
node.outgoing_connections = [connection]
|
|
|
|
node_id_str = str(node.id)
|
|
workflow_state = {
|
|
"variables": {
|
|
node_id_str: {"user_name": "John"}
|
|
}
|
|
}
|
|
|
|
result = workflow_service._execute_message_node(node, workflow_state)
|
|
|
|
assert result.success is True
|
|
assert "Hello John!" in result.message
|
|
assert result.should_continue is True
|
|
assert result.next_node_id == next_node_id
|
|
|
|
def test_execute_condition_node_true(self, workflow_service, sample_workflow):
|
|
"""Test condition node with true condition"""
|
|
next_node_id = uuid4()
|
|
connection = Mock()
|
|
connection.target_node_id = next_node_id
|
|
connection.label = "true"
|
|
connection.condition = "true"
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.CONDITION
|
|
node.config = {"condition_expression": "{{order_total}} > 100"}
|
|
node.outgoing_connections = [connection]
|
|
|
|
node_id_str = str(node.id)
|
|
workflow_state = {
|
|
"variables": {
|
|
node_id_str: {"order_total": "150"}
|
|
}
|
|
}
|
|
|
|
result = workflow_service._execute_condition_node(node, sample_workflow, workflow_state)
|
|
|
|
assert result.success is True
|
|
assert result.should_continue is True
|
|
|
|
def test_execute_condition_node_false(self, workflow_service, sample_workflow):
|
|
"""Test condition node with false condition"""
|
|
next_node_id = uuid4()
|
|
connection = Mock()
|
|
connection.target_node_id = next_node_id
|
|
connection.label = "false"
|
|
connection.condition = "false"
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.CONDITION
|
|
node.config = {"condition_expression": "{{order_total}} > 200"}
|
|
node.outgoing_connections = [connection]
|
|
|
|
node_id_str = str(node.id)
|
|
workflow_state = {
|
|
"variables": {
|
|
node_id_str: {"order_total": "150"}
|
|
}
|
|
}
|
|
|
|
result = workflow_service._execute_condition_node(node, sample_workflow, workflow_state)
|
|
|
|
assert result.success is True
|
|
assert result.should_continue is True
|
|
|
|
def test_execute_end_node(self, workflow_service):
|
|
"""Test executing an END node"""
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.END
|
|
node.config = {"message_text": "Thank you for using our service!"}
|
|
|
|
result = workflow_service._execute_end_node(node, {})
|
|
|
|
assert result.success is True
|
|
assert "Thank you" in result.message
|
|
assert result.end_chat is True
|
|
assert result.should_continue is False
|
|
|
|
def test_execute_human_transfer_node(self, workflow_service):
|
|
"""Test executing a HUMAN_TRANSFER node"""
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.HUMAN_TRANSFER
|
|
node.config = {
|
|
"transfer_rules": {
|
|
"message": "Transferring you to a human agent. Please wait..."
|
|
},
|
|
"transfer_group_id": "support_team"
|
|
}
|
|
|
|
result = workflow_service._execute_human_transfer_node(node, {})
|
|
|
|
assert result.success is True
|
|
assert result.transfer_to_human is True
|
|
assert result.transfer_group_id == "support_team"
|
|
assert result.should_continue is False
|
|
|
|
def test_execute_wait_node(self, workflow_service):
|
|
"""Test executing a WAIT node"""
|
|
next_node_id = uuid4()
|
|
connection = Mock()
|
|
connection.target_node_id = next_node_id
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.WAIT
|
|
node.config = {"wait_duration": 1} # unit defaults to seconds
|
|
node.outgoing_connections = [connection]
|
|
|
|
result = workflow_service._execute_wait_node(node, {})
|
|
|
|
assert result.success is True
|
|
assert result.should_continue is True
|
|
assert result.next_node_id == next_node_id
|
|
|
|
def test_process_variables(self, workflow_service):
|
|
"""Test variable replacement in text"""
|
|
node_id = str(uuid4())
|
|
text = "Hello {{user_name}}, your order {{order_id}} is ready!"
|
|
workflow_state = {
|
|
"variables": {
|
|
node_id: {
|
|
"user_name": "Alice",
|
|
"order_id": "ORDER123"
|
|
}
|
|
}
|
|
}
|
|
|
|
result = workflow_service._process_variables(text, workflow_state)
|
|
|
|
assert result == "Hello Alice, your order ORDER123 is ready!"
|
|
|
|
def test_process_variables_missing_var(self, workflow_service):
|
|
"""Test variable replacement with missing variable"""
|
|
text = "Hello {{user_name}}"
|
|
workflow_state = {"variables": {}}
|
|
|
|
result = workflow_service._process_variables(text, workflow_state)
|
|
|
|
# Should leave placeholder as is or handle gracefully
|
|
assert "{{user_name}}" in result or "user_name" in result
|
|
|
|
def test_evaluate_condition_greater_than(self, workflow_service):
|
|
"""Test evaluating > condition"""
|
|
condition = "100 > 50"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is True
|
|
|
|
condition = "50 > 100"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is False
|
|
|
|
def test_evaluate_condition_equals(self, workflow_service):
|
|
"""Test evaluating === condition"""
|
|
condition = "active === active"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is True
|
|
|
|
condition = "active === inactive"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is False
|
|
|
|
def test_evaluate_condition_contains(self, workflow_service):
|
|
"""Test evaluating contains condition"""
|
|
condition = "hello contains ell"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is True
|
|
|
|
condition = "hello contains xyz"
|
|
result = workflow_service._evaluate_condition(condition, {})
|
|
assert result is False
|
|
|
|
def test_find_next_node(self, workflow_service):
|
|
"""Test finding the next node from connections"""
|
|
from app.models.workflow_connection import WorkflowConnection
|
|
|
|
current_node_id = uuid4()
|
|
next_node_id = uuid4()
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = current_node_id
|
|
node.workflow_id = uuid4()
|
|
|
|
# Create a connection
|
|
connection = Mock(spec=WorkflowConnection)
|
|
connection.source_node_id = current_node_id
|
|
connection.target_node_id = next_node_id
|
|
connection.condition_type = None
|
|
|
|
node.outgoing_connections = [connection]
|
|
|
|
result = workflow_service._find_next_node(node)
|
|
|
|
assert result == next_node_id
|
|
|
|
def test_find_next_node_no_connections(self, workflow_service):
|
|
"""Test finding next node when no connections exist"""
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.outgoing_connections = []
|
|
|
|
result = workflow_service._find_next_node(node)
|
|
|
|
assert result is None
|
|
|
|
def test_interpolate_variables(self, workflow_service):
|
|
"""Test interpolating variables in text"""
|
|
node_id = str(uuid4())
|
|
text = "Order {{order_id}} for {{customer_name}}"
|
|
workflow_state = {
|
|
"variables": {
|
|
node_id: {
|
|
"order_id": "12345",
|
|
"customer_name": "Bob"
|
|
}
|
|
}
|
|
}
|
|
|
|
result = workflow_service._interpolate_variables(text, workflow_state)
|
|
|
|
assert result == "Order 12345 for Bob"
|
|
|
|
def test_store_form_variables(self, workflow_service):
|
|
"""Test storing form data in workflow state"""
|
|
workflow_state = {"variables": {}}
|
|
node_id = uuid4()
|
|
form_data = {
|
|
"name": "John Doe",
|
|
"email": "john@example.com",
|
|
"phone": "123-456-7890"
|
|
}
|
|
|
|
workflow_service._store_form_variables(workflow_state, node_id, form_data)
|
|
|
|
# Variables are stored under the node_id
|
|
node_id_str = str(node_id)
|
|
assert node_id_str in workflow_state["variables"]
|
|
assert workflow_state["variables"][node_id_str]["name"] == "John Doe"
|
|
assert workflow_state["variables"][node_id_str]["email"] == "john@example.com"
|
|
assert workflow_state["variables"][node_id_str]["phone"] == "123-456-7890"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_workflow_not_found(self, workflow_service):
|
|
"""Test executing non-existent workflow"""
|
|
with patch.object(workflow_service.workflow_repo, 'get_workflow_with_nodes_and_connections', return_value=None):
|
|
result = await workflow_service.execute_workflow(
|
|
session_id="test-session",
|
|
user_message="Hello",
|
|
workflow_id=uuid4()
|
|
)
|
|
|
|
assert result.success is False
|
|
# The error occurs when trying to access workflow.id on None
|
|
assert "error" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_workflow_not_published(self, workflow_service, sample_workflow):
|
|
"""Test executing unpublished workflow"""
|
|
sample_workflow.status = WorkflowStatus.DRAFT
|
|
|
|
with patch.object(workflow_service.workflow_repo, 'get_workflow_with_nodes_and_connections', return_value=sample_workflow):
|
|
result = await workflow_service.execute_workflow(
|
|
session_id="test-session",
|
|
user_message="Hello",
|
|
workflow_id=sample_workflow.id
|
|
)
|
|
|
|
assert result.success is False
|
|
assert "not published" in result.message.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_workflow_no_start_node(self, workflow_service, sample_workflow):
|
|
"""Test executing workflow with no start node"""
|
|
sample_workflow.nodes = []
|
|
|
|
with patch.object(workflow_service.workflow_repo, 'get_workflow_with_nodes_and_connections', return_value=sample_workflow):
|
|
result = await workflow_service.execute_workflow(
|
|
session_id="test-session",
|
|
user_message="Hello",
|
|
workflow_id=sample_workflow.id
|
|
)
|
|
|
|
assert result.success is False
|
|
# The error occurs when trying to access current_node.id on None
|
|
assert "error" in result.message.lower()
|
|
|
|
def test_execute_form_node(self, workflow_service):
|
|
"""Test executing a FORM node"""
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.name = "Contact Form"
|
|
node.node_type = NodeType.FORM
|
|
node.config = {
|
|
"form_fields": [
|
|
{"name": "name", "type": "text", "label": "Name", "required": True},
|
|
{"name": "email", "type": "email", "label": "Email", "required": True}
|
|
],
|
|
"form_title": "Contact Us",
|
|
"form_description": "Please fill out the form",
|
|
"submit_button_text": "Submit"
|
|
}
|
|
|
|
result = workflow_service._execute_form_node(node, {}, None, "test-session")
|
|
|
|
assert result.success is True
|
|
assert result.form_data is not None
|
|
assert "fields" in result.form_data
|
|
assert result.should_continue is False
|
|
|
|
def test_execute_landing_page_node(self, workflow_service):
|
|
"""Test executing a LANDING_PAGE node"""
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.LANDING_PAGE
|
|
node.config = {
|
|
"landing_page_heading": "Welcome",
|
|
"landing_page_content": "Welcome to our service"
|
|
}
|
|
|
|
result = workflow_service._execute_landing_page_node(node, {}, None)
|
|
|
|
assert result.success is True
|
|
assert result.landing_page_data is not None
|
|
assert "heading" in result.landing_page_data
|
|
assert result.should_continue is False
|
|
|
|
def test_execute_action_node(self, workflow_service):
|
|
"""Test executing an ACTION node"""
|
|
next_node_id = uuid4()
|
|
connection = Mock()
|
|
connection.target_node_id = next_node_id
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.ACTION
|
|
node.config = {
|
|
"action_type": "set_variable",
|
|
"action_config": {
|
|
"variable_name": "status",
|
|
"variable_value": "active"
|
|
}
|
|
}
|
|
node.outgoing_connections = [connection]
|
|
|
|
workflow_state = {"variables": {}}
|
|
result = workflow_service._execute_action_node(node, workflow_state)
|
|
|
|
assert result.success is True
|
|
assert result.should_continue is True
|
|
assert result.next_node_id == next_node_id
|
|
|
|
|
|
def _linked_nodes(*nodes):
|
|
"""Chain mock nodes in order with mock connections; the first is the start."""
|
|
for node in nodes:
|
|
node.incoming_connections = []
|
|
node.outgoing_connections = []
|
|
for source, target in zip(nodes, nodes[1:]):
|
|
connection = Mock()
|
|
connection.target_node_id = target.id
|
|
source.outgoing_connections.append(connection)
|
|
target.incoming_connections.append(connection)
|
|
return list(nodes)
|
|
|
|
|
|
def _message_node(text):
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.MESSAGE
|
|
node.config = {"message_text": text}
|
|
return node
|
|
|
|
|
|
async def _run_workflow(workflow_service, workflow, nodes, user_message="hi"):
|
|
workflow.nodes = nodes
|
|
with patch.object(workflow_service.workflow_repo, 'get_workflow_with_nodes_and_connections',
|
|
return_value=workflow), \
|
|
patch.object(workflow_service, '_update_session_workflow_state'):
|
|
return await workflow_service.execute_workflow(
|
|
session_id="test-session",
|
|
user_message=user_message,
|
|
workflow_id=workflow.id,
|
|
)
|
|
|
|
|
|
class TestTerminalMessageNotDuplicated:
|
|
"""The node execution stops on is returned as the final message, so it must
|
|
not also be reported as an intermediate one: every caller emits and stores
|
|
both, which sent the visitor the same text twice."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_node_is_only_the_final_message(self, workflow_service, sample_workflow):
|
|
result = await _run_workflow(workflow_service, sample_workflow, _linked_nodes(_message_node("Hello")))
|
|
|
|
assert result.success is True
|
|
assert result.message == "Hello"
|
|
assert result.intermediate_messages == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_chain_keeps_earlier_messages_as_intermediate(self, workflow_service, sample_workflow):
|
|
nodes = _linked_nodes(_message_node("First"), _message_node("Second"))
|
|
|
|
result = await _run_workflow(workflow_service, sample_workflow, nodes)
|
|
|
|
assert result.message == "Second"
|
|
assert result.intermediate_messages == ["First"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_before_user_input_stays_intermediate(self, workflow_service, sample_workflow):
|
|
user_input = Mock(spec=WorkflowNode)
|
|
user_input.id = uuid4()
|
|
user_input.name = "Ask"
|
|
user_input.node_type = NodeType.USER_INPUT
|
|
user_input.config = {"prompt_message": "What is your name?"}
|
|
nodes = _linked_nodes(_message_node("Welcome"), user_input)
|
|
|
|
result = await _run_workflow(workflow_service, sample_workflow, nodes, user_message=None)
|
|
|
|
assert result.message == "What is your name?"
|
|
assert result.intermediate_messages == ["Welcome"]
|
|
|
|
|
|
class TestUserMessageVariable:
|
|
"""{{user_message}} sits at the top level of variables, not under a node."""
|
|
|
|
def test_user_message_is_interpolated(self, workflow_service):
|
|
workflow_state = {"variables": {"user_message": "where is my order"}}
|
|
|
|
result = workflow_service._process_variables("You said: {{user_message}}", workflow_state)
|
|
|
|
assert result == "You said: where is my order"
|
|
|
|
def test_user_message_does_not_hide_node_variables(self, workflow_service):
|
|
# The user message was searched as if it were a node's variables, so a
|
|
# message containing a field's name broke the lookup for that field.
|
|
node_id = str(uuid4())
|
|
workflow_state = {"variables": {
|
|
node_id: {"contact_email": "a@example.com"},
|
|
"user_message": "my contact_email changed",
|
|
}}
|
|
|
|
result = workflow_service._process_variables("Email: {{contact_email}}", workflow_state)
|
|
|
|
assert result == "Email: a@example.com"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_node_sees_the_current_user_message(self, workflow_service, sample_workflow):
|
|
nodes = _linked_nodes(_message_node("Echo: {{user_message}}"))
|
|
|
|
result = await _run_workflow(workflow_service, sample_workflow, nodes, user_message="hello there")
|
|
|
|
assert result.message == "Echo: hello there"
|
|
|
|
|
|
class TestWaitNodeConfig:
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wait_node_reads_duration_from_config(self, workflow_service, sample_workflow):
|
|
# A real node: wait settings live in config; there is no wait_duration column.
|
|
node = WorkflowNode(
|
|
id=uuid4(),
|
|
node_type=NodeType.WAIT,
|
|
name="Wait",
|
|
config={"wait_duration": 5, "wait_unit": "minutes"},
|
|
)
|
|
|
|
result = await workflow_service._execute_node(
|
|
node, sample_workflow, {}, None, None, None, None, None, None, None, None, "test-session")
|
|
|
|
assert result.success is True
|
|
assert result.message == "Please wait 5 minutes..."
|
|
assert result.next_node_id is None
|
|
|
|
|
|
class TestUnknownConditionFormat:
|
|
|
|
def test_unparseable_condition_is_false(self, workflow_service):
|
|
assert workflow_service._evaluate_condition("this is not a condition", {}) is False
|
|
|
|
def test_unparseable_condition_takes_the_false_branch(self, workflow_service, sample_workflow):
|
|
true_target, false_target = uuid4(), uuid4()
|
|
true_conn = Mock(target_node_id=true_target, label="true", condition="true")
|
|
false_conn = Mock(target_node_id=false_target, label="false", condition="false")
|
|
|
|
node = Mock(spec=WorkflowNode)
|
|
node.id = uuid4()
|
|
node.node_type = NodeType.CONDITION
|
|
node.config = {"condition_expression": "{{missing_field}}"}
|
|
node.outgoing_connections = [true_conn, false_conn]
|
|
|
|
result = workflow_service._execute_condition_node(node, sample_workflow, {"variables": {}})
|
|
|
|
assert result.next_node_id == false_target |