2026-10-08 22:35:00 +08:00
|
|
|
"""
|
2026-10-26 00:50:43 +08:00
|
|
|
Copyright 2024-2026 ReplyPier
|
2026-10-08 22:35:00 +08:00
|
|
|
|
|
|
|
|
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
|