Files
replypier/backend/tests/services/test_workflow_node.py
T
WangShen007 20fef2b9f3 chore: settle product naming across app, widget and deployments
One word everywhere: ReplyPier in UI copy, package and image names, the
replypier.site domains (app/api/docs/help), REPLYPIER_* settings and the
--rp-* design tokens.

- public widget API is window.ReplyPier with replypierId / replypierBaseUrl /
  replypierConfig / replypierToken, loader at webclient/replypier.min.js;
  both bundles rebuilt from source
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and the demo runtime
  config rewritten; the demo config ships no third-party keys
- migration adds the ReplyPier managed model value to the AI model enum
- local compose project, database, role, volume and Docker network carry the
  same name
2026-10-26 00:57:22 +08:00

730 lines
29 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, MagicMock, patch
from uuid import uuid4, UUID
from datetime import datetime
from app.services.workflow_node import WorkflowNodeService, sanitize_utf8_text
from app.models.workflow_node import WorkflowNode, NodeType
from app.models.workflow_connection import WorkflowConnection
from app.models.workflow import Workflow, WorkflowStatus
@pytest.fixture
def mock_db():
"""Create a mock database session."""
db = MagicMock()
db.add = MagicMock()
db.commit = MagicMock()
db.refresh = MagicMock()
db.delete = MagicMock()
db.rollback = MagicMock()
db.query = MagicMock()
return db
@pytest.fixture
def test_organization_id():
"""Create a test organization ID."""
return uuid4()
@pytest.fixture
def test_user_id():
"""Create a test user ID."""
return uuid4()
@pytest.fixture
def test_workflow(test_organization_id, test_user_id):
"""Create a mock workflow."""
workflow = Mock(spec=Workflow)
workflow.id = uuid4()
workflow.name = "Test Workflow"
workflow.description = "Test workflow"
workflow.status = WorkflowStatus.PUBLISHED
workflow.version = 1
workflow.is_template = False
workflow.default_language = "en"
workflow.canvas_data = {
"nodes": [
{
"id": "start",
"type": "message",
"data": {"label": "Start"}
}
]
}
workflow.settings = {}
workflow.organization_id = test_organization_id
workflow.created_by = test_user_id
workflow.nodes = [] # Empty list for node iteration
return workflow
@pytest.fixture
def test_workflow_node(test_workflow):
"""Create a mock workflow node."""
node = Mock(spec=WorkflowNode)
node.id = uuid4()
node.workflow_id = test_workflow.id
node.node_type = NodeType.MESSAGE
node.name = "Start Node"
node.description = None
node.position_x = 100
node.position_y = 100
node.config = {"label": "Start Node"}
node.created_at = datetime.utcnow()
node.updated_at = datetime.utcnow()
node.workflow = test_workflow
node.outgoing_connections = []
node.incoming_connections = []
return node
@pytest.fixture
def test_workflow_connection(test_workflow):
"""Create a mock workflow connection."""
source_node = Mock(spec=WorkflowNode)
source_node.id = uuid4()
source_node.workflow_id = test_workflow.id
source_node.node_type = NodeType.MESSAGE
source_node.name = "Source Node"
target_node = Mock(spec=WorkflowNode)
target_node.id = uuid4()
target_node.workflow_id = test_workflow.id
target_node.node_type = NodeType.END
target_node.name = "Target Node"
connection = Mock(spec=WorkflowConnection)
connection.id = uuid4()
connection.workflow_id = test_workflow.id
connection.source_node_id = source_node.id
connection.target_node_id = target_node.id
connection.label = "Test Connection"
return connection
@pytest.fixture
def workflow_node_service(mock_db):
"""Create WorkflowNodeService instance with mocked repositories."""
with patch('app.services.workflow_node.WorkflowNodeRepository') as MockNodeRepo, \
patch('app.services.workflow_node.WorkflowRepository') as MockWorkflowRepo:
mock_node_repo = MagicMock()
mock_workflow_repo = MagicMock()
MockNodeRepo.return_value = mock_node_repo
MockWorkflowRepo.return_value = mock_workflow_repo
service = WorkflowNodeService(mock_db)
service.node_repo = mock_node_repo
service.workflow_repo = mock_workflow_repo
return service
class TestSanitizeUtf8Text:
"""Test the sanitize_utf8_text utility function"""
def test_sanitize_utf8_text_normal_text(self):
"""Test sanitization with normal text"""
text = "Hello World"
result = sanitize_utf8_text(text)
assert result == "Hello World"
def test_sanitize_utf8_text_with_surrogates(self):
"""Test sanitization with surrogate characters"""
text = "Hello\udcac\udd16World"
result = sanitize_utf8_text(text)
assert result == "HelloWorld"
def test_sanitize_utf8_text_empty_string(self):
"""Test sanitization with empty string"""
text = ""
result = sanitize_utf8_text(text)
assert result == ""
def test_sanitize_utf8_text_none(self):
"""Test sanitization with None"""
text = None
result = sanitize_utf8_text(text)
assert result is None
def test_sanitize_utf8_text_with_unicode_error(self):
"""Test sanitization when unicode error occurs during encoding"""
# Test with a string that will trigger the UnicodeError exception handling
text = "test with unicode issues"
# Test that the function handles encoding errors gracefully by testing the fallback path
# We create a mock that will fail on encode but allow re.sub to work
with patch('app.services.workflow_node.re.sub', side_effect=lambda pattern, replacement, text: text):
# Use a text object that will raise UnicodeError when encode() is called
class MockString(str):
def encode(self, encoding='utf-8', errors='strict'):
if errors == 'ignore':
return b'fallback_text'
else:
raise UnicodeError("Mock encoding error")
def decode(self, encoding='utf-8'):
return "fallback_text"
mock_text = MockString(text)
result = sanitize_utf8_text(mock_text)
# Function should handle the error gracefully and return a string
assert isinstance(result, str)
class TestWorkflowNodeService:
def test_workflow_node_created_successfully(self, test_workflow_node, test_workflow):
"""Test that workflow node has the right properties"""
assert test_workflow_node.workflow_id == test_workflow.id
assert test_workflow_node.node_type == NodeType.MESSAGE
assert test_workflow_node.name == "Start Node"
assert test_workflow_node.position_x == 100
assert test_workflow_node.position_y == 100
def test_node_type_enum_values(self):
"""Test that NodeType enum has the expected values"""
assert hasattr(NodeType, 'MESSAGE')
assert hasattr(NodeType, 'LLM')
assert hasattr(NodeType, 'CONDITION')
assert hasattr(NodeType, 'FORM')
assert hasattr(NodeType, 'ACTION')
assert hasattr(NodeType, 'HUMAN_TRANSFER')
assert hasattr(NodeType, 'WAIT')
assert hasattr(NodeType, 'END')
assert hasattr(NodeType, 'LANDING_PAGE')
def test_workflow_node_service_initialization(self, workflow_node_service):
"""Test that WorkflowNodeService can be initialized"""
assert workflow_node_service is not None
assert hasattr(workflow_node_service, 'db')
def test_workflow_node_model_fields(self, test_workflow_node):
"""Test that WorkflowNode model has expected fields"""
assert hasattr(test_workflow_node, 'id')
assert hasattr(test_workflow_node, 'workflow_id')
assert hasattr(test_workflow_node, 'node_type')
assert hasattr(test_workflow_node, 'name')
assert hasattr(test_workflow_node, 'description')
assert hasattr(test_workflow_node, 'position_x')
assert hasattr(test_workflow_node, 'position_y')
assert hasattr(test_workflow_node, 'config')
assert hasattr(test_workflow_node, 'created_at')
assert hasattr(test_workflow_node, 'updated_at')
def test_workflow_node_relationships(self, test_workflow_node):
"""Test that WorkflowNode model has expected relationships"""
assert hasattr(test_workflow_node, 'workflow')
assert hasattr(test_workflow_node, 'outgoing_connections')
assert hasattr(test_workflow_node, 'incoming_connections')
def test_workflow_node_config_structure(self, test_workflow_node):
"""Test that the workflow node config is properly structured"""
assert isinstance(test_workflow_node.config, dict)
assert test_workflow_node.config.get("label") == "Start Node"
def test_get_workflow_nodes_and_connections_success(
self, workflow_node_service, test_workflow, test_organization_id
):
"""Test getting workflow nodes and connections successfully"""
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_nodes_by_workflow.return_value = []
workflow_node_service.node_repo.get_connections_by_workflow.return_value = []
result = workflow_node_service.get_workflow_nodes_and_connections(
test_workflow.id, test_organization_id
)
assert "nodes" in result
assert "connections" in result
assert isinstance(result["nodes"], list)
assert isinstance(result["connections"], list)
def test_get_workflow_nodes_and_connections_workflow_not_found(
self, workflow_node_service, test_organization_id
):
"""Test getting workflow nodes and connections with non-existent workflow"""
workflow_node_service.workflow_repo.get_by_id.return_value = None
non_existent_id = uuid4()
with pytest.raises(ValueError, match="Workflow not found"):
workflow_node_service.get_workflow_nodes_and_connections(
non_existent_id, test_organization_id
)
def test_get_workflow_nodes_and_connections_wrong_organization(
self, workflow_node_service, test_workflow
):
"""Test getting workflow nodes and connections with wrong organization"""
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
wrong_org_id = uuid4()
with pytest.raises(ValueError, match="Workflow does not belong to your organization"):
workflow_node_service.get_workflow_nodes_and_connections(
test_workflow.id, wrong_org_id
)
def test_update_single_node_success(
self, workflow_node_service, test_workflow_node, test_workflow, test_organization_id
):
"""Test updating a single node successfully"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = test_workflow_node
updated_node = Mock(spec=WorkflowNode)
updated_node.name = "Updated Node Name"
updated_node.description = "Updated description"
workflow_node_service.node_repo.update_node.return_value = updated_node
update_data = {
"name": "Updated Node Name",
"description": "Updated description",
"config": {"new_field": "new_value"}
}
result = workflow_node_service.update_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
update_data,
test_organization_id
)
assert result is not None
assert result.name == "Updated Node Name"
assert result.description == "Updated description"
def test_update_single_node_workflow_not_found(
self, workflow_node_service, test_workflow_node, test_organization_id
):
"""Test updating node with non-existent workflow"""
workflow_node_service.workflow_repo.get_by_id.return_value = None
non_existent_workflow_id = uuid4()
with pytest.raises(ValueError, match="Workflow not found"):
workflow_node_service.update_single_node(
non_existent_workflow_id,
test_workflow_node.id,
{"name": "New Name"},
test_organization_id
)
def test_update_single_node_wrong_organization(
self, workflow_node_service, test_workflow_node, test_workflow
):
"""Test updating node with wrong organization"""
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
wrong_org_id = uuid4()
with pytest.raises(ValueError, match="Workflow does not belong to your organization"):
workflow_node_service.update_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
{"name": "New Name"},
wrong_org_id
)
def test_update_single_node_node_not_found(
self, workflow_node_service, test_workflow, test_organization_id
):
"""Test updating non-existent node"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = None
non_existent_node_id = uuid4()
with pytest.raises(ValueError, match="Node not found"):
workflow_node_service.update_single_node(
test_workflow.id,
non_existent_node_id,
{"name": "New Name"},
test_organization_id
)
def test_update_single_node_wrong_workflow(
self, workflow_node_service, test_workflow_node, test_workflow, test_organization_id
):
"""Test updating node that belongs to different workflow"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
# Create a node that belongs to a different workflow
node_from_other_workflow = Mock(spec=WorkflowNode)
node_from_other_workflow.id = uuid4()
node_from_other_workflow.workflow_id = uuid4() # Different workflow
workflow_node_service.node_repo.get_node_by_id.return_value = node_from_other_workflow
with pytest.raises(ValueError, match="Node does not belong to this workflow"):
workflow_node_service.update_single_node(
test_workflow.id,
node_from_other_workflow.id,
{"name": "New Name"},
test_organization_id
)
def test_get_single_node_success(
self, workflow_node_service, test_workflow_node, test_workflow, test_organization_id
):
"""Test getting a single node successfully"""
test_workflow.organization_id = test_organization_id
test_workflow_node.workflow_id = test_workflow.id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = test_workflow_node
result = workflow_node_service.get_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
test_organization_id
)
assert result is not None
assert result.id == test_workflow_node.id
assert result.name == test_workflow_node.name
def test_get_single_node_workflow_not_found(
self, workflow_node_service, test_workflow_node, test_organization_id
):
"""Test getting node with non-existent workflow"""
workflow_node_service.workflow_repo.get_by_id.return_value = None
non_existent_workflow_id = uuid4()
with pytest.raises(ValueError, match="Workflow not found"):
workflow_node_service.get_single_node(
non_existent_workflow_id,
test_workflow_node.id,
test_organization_id
)
def test_get_single_node_wrong_organization(
self, workflow_node_service, test_workflow_node, test_workflow
):
"""Test getting node with wrong organization"""
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
wrong_org_id = uuid4()
with pytest.raises(ValueError, match="Workflow does not belong to your organization"):
workflow_node_service.get_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
wrong_org_id
)
def test_get_single_node_node_not_found(
self, workflow_node_service, test_workflow, test_organization_id
):
"""Test getting non-existent node"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = None
non_existent_node_id = uuid4()
with pytest.raises(ValueError, match="Node not found"):
workflow_node_service.get_single_node(
test_workflow.id,
non_existent_node_id,
test_organization_id
)
def test_replace_workflow_nodes_and_connections_success(
self, workflow_node_service, test_workflow, test_organization_id
):
"""Test replacing workflow nodes and connections successfully"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_connections_by_workflow.return_value = []
workflow_node_service.node_repo.get_nodes_by_workflow.return_value = []
# Mock node creation
created_nodes = []
def mock_create_node(**kwargs):
node = Mock(spec=WorkflowNode)
node.id = kwargs.get('id', uuid4())
node.name = kwargs.get('name', 'Node')
node.node_type = kwargs.get('node_type', NodeType.MESSAGE)
node.config = kwargs.get('config', {})
created_nodes.append(node)
return node
workflow_node_service.node_repo.create_node.side_effect = mock_create_node
# Mock connection creation
created_connections = []
def mock_create_connection(**kwargs):
conn = Mock(spec=WorkflowConnection)
conn.id = kwargs.get('id', uuid4())
conn.source_node_id = kwargs.get('source_node_id')
conn.target_node_id = kwargs.get('target_node_id')
conn.label = kwargs.get('label')
created_connections.append(conn)
return conn
workflow_node_service.node_repo.create_connection.side_effect = mock_create_connection
# After creation, return the created items
def get_final_nodes(workflow_id):
return created_nodes
def get_final_connections(workflow_id):
return created_connections
# Override returns for final fetch
workflow_node_service.node_repo.get_nodes_by_workflow.side_effect = [[], get_final_nodes(test_workflow.id)]
workflow_node_service.node_repo.get_connections_by_workflow.side_effect = [[], get_final_connections(test_workflow.id)]
nodes_data = [
{
"id": str(uuid4()),
"node_type": "message",
"name": "Test Message Node",
"position_x": 100,
"position_y": 100,
"message_text": "Hello World",
"config": {}
},
{
"id": str(uuid4()),
"node_type": "end",
"name": "Test End Node",
"position_x": 200,
"position_y": 200,
"final_message": "Goodbye",
"config": {}
}
]
connections_data = [
{
"id": str(uuid4()),
"source_node_id": nodes_data[0]["id"],
"target_node_id": nodes_data[1]["id"],
"label": "Next"
}
]
result = workflow_node_service.replace_workflow_nodes_and_connections(
test_workflow.id, nodes_data, connections_data, test_organization_id
)
assert "nodes" in result
assert "connections" in result
def test_replace_workflow_nodes_and_connections_workflow_not_found(
self, workflow_node_service, test_organization_id
):
"""Test replacing nodes with non-existent workflow"""
workflow_node_service.workflow_repo.get_by_id.return_value = None
non_existent_workflow_id = uuid4()
with pytest.raises(ValueError, match="Workflow not found"):
workflow_node_service.replace_workflow_nodes_and_connections(
non_existent_workflow_id, [], [], test_organization_id
)
def test_replace_workflow_nodes_and_connections_wrong_organization(
self, workflow_node_service, test_workflow
):
"""Test replacing nodes with wrong organization"""
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
wrong_org_id = uuid4()
with pytest.raises(ValueError, match="Workflow does not belong to your organization"):
workflow_node_service.replace_workflow_nodes_and_connections(
test_workflow.id, [], [], wrong_org_id
)
def test_replace_workflow_nodes_with_sanitization(
self, workflow_node_service, test_workflow, test_organization_id
):
"""Test replacing workflow nodes with text that needs sanitization"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_connections_by_workflow.return_value = []
workflow_node_service.node_repo.get_nodes_by_workflow.return_value = []
created_nodes = []
def mock_create_node(**kwargs):
node = Mock(spec=WorkflowNode)
node.id = kwargs.get('id', uuid4())
# The name should be sanitized before create_node is called
node.name = kwargs.get('name', 'Node')
node.config = kwargs.get('config', {})
created_nodes.append(node)
return node
workflow_node_service.node_repo.create_node.side_effect = mock_create_node
workflow_node_service.node_repo.get_nodes_by_workflow.side_effect = [[], lambda wf_id: created_nodes]
workflow_node_service.node_repo.get_connections_by_workflow.side_effect = [[], []]
nodes_data = [
{
"id": str(uuid4()),
"node_type": "message",
"name": "Test\udcacNode\udd16", # Contains surrogate characters
"message_text": "Hello\udcac World\udd16",
"position_x": 100,
"position_y": 100
}
]
result = workflow_node_service.replace_workflow_nodes_and_connections(
test_workflow.id, nodes_data, [], test_organization_id
)
# Verify the create_node was called (sanitization happens before the call)
workflow_node_service.node_repo.create_node.assert_called_once()
# Verify the call arguments were sanitized
call_kwargs = workflow_node_service.node_repo.create_node.call_args[1]
assert "\udcac" not in call_kwargs.get('name', '')
assert "\udd16" not in call_kwargs.get('name', '')
def test_replace_workflow_with_database_rollback(
self, workflow_node_service, test_workflow, test_organization_id, mock_db
):
"""Test that database rollback works when error occurs during replacement"""
test_workflow.organization_id = test_organization_id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_connections_by_workflow.return_value = []
workflow_node_service.node_repo.get_nodes_by_workflow.return_value = []
workflow_node_service.node_repo.create_node.side_effect = Exception("Database error")
nodes_data = [{"id": "node-1", "node_type": "message", "name": "Test", "position_x": 0, "position_y": 0}]
with pytest.raises(Exception):
workflow_node_service.replace_workflow_nodes_and_connections(
test_workflow.id, nodes_data, [], test_organization_id
)
def test_update_single_node_with_sanitization(
self, workflow_node_service, test_workflow_node, test_workflow, test_organization_id
):
"""Test updating node with text that needs sanitization"""
test_workflow.organization_id = test_organization_id
test_workflow_node.workflow_id = test_workflow.id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = test_workflow_node
updated_node = Mock(spec=WorkflowNode)
updated_node.name = "Updated Node" # Sanitized name
updated_node.description = "Updated description" # Sanitized description
workflow_node_service.node_repo.update_node.return_value = updated_node
update_data = {
"name": "Updated\udcac Node\udd16",
"description": "Updated\udcac description\udd16"
}
result = workflow_node_service.update_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
update_data,
test_organization_id
)
# Verify update_node was called with sanitized data
workflow_node_service.node_repo.update_node.assert_called_once()
call_kwargs = workflow_node_service.node_repo.update_node.call_args[1]
assert "\udcac" not in call_kwargs.get('name', '')
assert "\udd16" not in call_kwargs.get('name', '')
def test_update_single_node_database_rollback(
self, workflow_node_service, test_workflow_node, test_workflow, test_organization_id
):
"""Test that database rollback works when error occurs during node update"""
test_workflow.organization_id = test_organization_id
test_workflow_node.workflow_id = test_workflow.id
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
workflow_node_service.node_repo.get_node_by_id.return_value = test_workflow_node
workflow_node_service.node_repo.update_node.side_effect = Exception("Database error")
with pytest.raises(Exception):
workflow_node_service.update_single_node(
test_workflow_node.workflow_id,
test_workflow_node.id,
{"name": "New Name"},
test_organization_id
)
class TestUniqueNodeNameGeneration:
"""Tests for unique node name generation."""
def test_generate_unique_node_name_no_conflict(
self, workflow_node_service, test_workflow
):
"""Test name generation when there's no conflict."""
test_workflow.nodes = []
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
result = workflow_node_service._generate_unique_node_name(
test_workflow.id, "New Node"
)
assert result == "New Node"
def test_generate_unique_node_name_with_conflict(
self, workflow_node_service, test_workflow
):
"""Test name generation when there's a conflict."""
existing_node = Mock(spec=WorkflowNode)
existing_node.id = uuid4()
existing_node.name = "New Node"
test_workflow.nodes = [existing_node]
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
result = workflow_node_service._generate_unique_node_name(
test_workflow.id, "New Node"
)
assert result == "New Node_001"
def test_validate_node_name_uniqueness_true(
self, workflow_node_service, test_workflow
):
"""Test validation returns True for unique name."""
test_workflow.nodes = []
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
result = workflow_node_service._validate_node_name_uniqueness(
test_workflow.id, "Unique Name"
)
assert result is True
def test_validate_node_name_uniqueness_false(
self, workflow_node_service, test_workflow
):
"""Test validation returns False for duplicate name."""
existing_node = Mock(spec=WorkflowNode)
existing_node.id = uuid4()
existing_node.name = "Duplicate Name"
test_workflow.nodes = [existing_node]
workflow_node_service.workflow_repo.get_by_id.return_value = test_workflow
result = workflow_node_service._validate_node_name_uniqueness(
test_workflow.id, "Duplicate Name"
)
assert result is False