mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 08:55:44 +08:00
Apply group response rules before busy-session steering. Initialize usable private-message and group-mention routes for new bots, including Wizard-created pipelines, and preserve member-configured rules across saves and retries. Fix empty pipeline configuration, refresh the processor sidebar after automatic creation, and show creation feedback. Align routing regressions and use Node 24 for the browser CI job. Correct the task's erroneous agent attribution while preserving every code tree and the original human contribution. Co-authored-by: multica-agent <[email protected]>
1014 lines
39 KiB
Python
1014 lines
39 KiB
Python
"""
|
|
Unit tests for PipelineService.
|
|
|
|
Tests pipeline CRUD operations including:
|
|
- Pipeline listing with sorting
|
|
- Pipeline creation with default config
|
|
- Pipeline update with bot sync
|
|
- Pipeline copy functionality
|
|
- Extensions preferences management
|
|
|
|
Source: src/langbot/pkg/api/http/service/pipeline.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, Mock, patch, mock_open
|
|
from types import SimpleNamespace
|
|
import uuid
|
|
import sqlalchemy
|
|
import json
|
|
|
|
from langbot.pkg.api.http.service.pipeline import PipelineService, default_stage_order
|
|
from langbot.pkg.entity.persistence.pipeline import LegacyPipeline
|
|
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
|
|
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
WORKSPACE_UUID = 'workspace-a'
|
|
|
|
|
|
def _create_mock_pipeline(
|
|
pipeline_uuid: str = None,
|
|
name: str = 'Test Pipeline',
|
|
description: str = 'Test Description',
|
|
is_default: bool = False,
|
|
stages: list = None,
|
|
config: dict = None,
|
|
extensions_preferences: dict = None,
|
|
) -> Mock:
|
|
"""Helper to create mock LegacyPipeline entity."""
|
|
pipeline = Mock(spec=LegacyPipeline)
|
|
pipeline.uuid = pipeline_uuid or str(uuid.uuid4())
|
|
pipeline.name = name
|
|
pipeline.description = description
|
|
pipeline.emoji = '⚙️'
|
|
pipeline.is_default = is_default
|
|
pipeline.for_version = '1.0.0'
|
|
pipeline.stages = stages or default_stage_order.copy()
|
|
pipeline.config = config or {}
|
|
pipeline.extensions_preferences = extensions_preferences or {
|
|
'enable_all_plugins': True,
|
|
'enable_all_mcp_servers': True,
|
|
'plugins': [],
|
|
'mcp_servers': [],
|
|
}
|
|
return pipeline
|
|
|
|
|
|
def _create_mock_result(items: list = None, first_item=None):
|
|
"""Create mock result object for persistence queries."""
|
|
result = Mock()
|
|
result.all = Mock(return_value=items or [])
|
|
result.first = Mock(return_value=first_item)
|
|
return result
|
|
|
|
|
|
class TestPipelineServiceGetPipelineMetadata:
|
|
"""Tests for get_pipeline_metadata method."""
|
|
|
|
async def test_get_pipeline_metadata_returns_list(self):
|
|
"""Returns list of pipeline metadata configs."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.pipeline_config_meta_trigger = {'trigger': {}}
|
|
ap.pipeline_config_meta_safety = {'safety': {}}
|
|
ap.pipeline_config_meta_ai = {'ai': {}}
|
|
ap.pipeline_config_meta_output = {'output': {}}
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_pipeline_metadata(
|
|
WORKSPACE_UUID,
|
|
)
|
|
|
|
# Verify
|
|
assert len(result) == 4
|
|
assert 'trigger' in result[0]
|
|
assert 'safety' in result[1]
|
|
assert 'ai' in result[2]
|
|
assert 'output' in result[3]
|
|
|
|
|
|
class TestPipelineServiceGetPipelines:
|
|
"""Tests for get_pipelines method."""
|
|
|
|
async def test_get_pipelines_empty_list(self):
|
|
"""Returns empty list when no pipelines exist."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
side_effect=lambda model_cls, entity: {
|
|
'uuid': entity.uuid,
|
|
'name': entity.name,
|
|
}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_pipelines(
|
|
WORKSPACE_UUID,
|
|
)
|
|
|
|
# Verify
|
|
assert result == []
|
|
|
|
async def test_get_pipelines_returns_sorted_by_created_at_desc(self):
|
|
"""Returns pipelines sorted by created_at descending by default."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
pipeline1 = _create_mock_pipeline(pipeline_uuid='uuid-1', name='Pipeline 1')
|
|
pipeline2 = _create_mock_pipeline(pipeline_uuid='uuid-2', name='Pipeline 2')
|
|
|
|
mock_result = _create_mock_result([pipeline1, pipeline2])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
side_effect=lambda model_cls, entity: {
|
|
'uuid': entity.uuid,
|
|
'name': entity.name,
|
|
}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_pipelines(
|
|
WORKSPACE_UUID,
|
|
)
|
|
|
|
# Verify
|
|
assert result == [{'uuid': 'uuid-1', 'name': 'Pipeline 1'}, {'uuid': 'uuid-2', 'name': 'Pipeline 2'}]
|
|
ap.persistence_mgr.execute_async.assert_awaited_once()
|
|
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
|
assert len(statement._order_by_clauses) == 1
|
|
assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.created_at.desc())
|
|
assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
|
|
|
|
async def test_get_pipelines_sort_by_updated_at_asc(self):
|
|
"""Returns pipelines sorted by updated_at ascending."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
mock_result = _create_mock_result([])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
await service.get_pipelines(WORKSPACE_UUID, sort_by='updated_at', sort_order='ASC')
|
|
|
|
# Verify - execute was called with sort parameters
|
|
ap.persistence_mgr.execute_async.assert_awaited_once()
|
|
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
|
assert len(statement._order_by_clauses) == 1
|
|
assert next(iter(statement._order_by_clauses)).compare(LegacyPipeline.updated_at.asc())
|
|
assert statement.whereclause.compare(LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
|
|
|
|
|
|
class TestPipelineServiceGetPipeline:
|
|
"""Tests for get_pipeline method."""
|
|
|
|
async def test_get_pipeline_by_uuid_found(self):
|
|
"""Returns pipeline when found by UUID."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
pipeline = _create_mock_pipeline(pipeline_uuid='test-uuid', name='Found Pipeline')
|
|
mock_result = _create_mock_result(first_item=pipeline)
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={
|
|
'uuid': 'test-uuid',
|
|
'name': 'Found Pipeline',
|
|
'stages': default_stage_order,
|
|
}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_pipeline(WORKSPACE_UUID, 'test-uuid')
|
|
|
|
# Verify
|
|
assert result is not None
|
|
assert result['uuid'] == 'test-uuid'
|
|
assert result['name'] == 'Found Pipeline'
|
|
|
|
async def test_get_pipeline_by_uuid_not_found(self):
|
|
"""Returns None when pipeline not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
|
|
mock_result = _create_mock_result(first_item=None)
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
result = await service.get_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
|
|
|
|
# Verify
|
|
assert result is None
|
|
|
|
|
|
class TestPipelineServiceCreatePipeline:
|
|
"""Tests for create_pipeline method."""
|
|
|
|
async def test_create_pipeline_max_limit_reached_raises(self):
|
|
"""Raises ValueError when max_pipelines limit reached."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
mock_result = _create_mock_result([_create_mock_pipeline(), _create_mock_pipeline()])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'uuid-1', 'name': 'Pipeline 1'})
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
|
await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
|
|
|
|
@pytest.mark.parametrize('config_fields', [{}, {'config': {}}])
|
|
async def test_create_pipeline_no_limit(self, config_fields):
|
|
"""Creates pipeline without limit when max_pipelines=-1."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
service = PipelineService(ap)
|
|
# Override get_pipelines to return empty list (no limit check issue)
|
|
service.get_pipelines = AsyncMock(return_value=[])
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})
|
|
|
|
# Mock persistence for insert
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid', 'name': 'New Pipeline'})
|
|
|
|
# Mock the file read for default config - patch at the utils module level
|
|
default_config = {'trigger': {}, 'safety': {}, 'ai': {}, 'output': {}}
|
|
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
|
with patch(
|
|
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
|
):
|
|
bot_uuid = await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline', **config_fields})
|
|
|
|
# Verify
|
|
assert bot_uuid is not None
|
|
assert uuid.UUID(bot_uuid).version == 4
|
|
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
|
assert statement.is_insert
|
|
assert statement.table.name == LegacyPipeline.__tablename__
|
|
values = statement.compile().params
|
|
assert values['uuid'] == bot_uuid
|
|
assert values['workspace_uuid'] == WORKSPACE_UUID
|
|
assert values['name'] == 'New Pipeline'
|
|
assert values['config'] == default_config
|
|
assert values['for_version'] == '1.0.0'
|
|
assert values['is_default'] is False
|
|
service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, bot_uuid, include_secret=True)
|
|
ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value)
|
|
|
|
async def test_create_pipeline_as_default(self):
|
|
"""Creates pipeline with is_default=True."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipelines = AsyncMock(return_value=[])
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
|
|
)
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={'uuid': 'new-uuid', 'name': 'Default Pipeline', 'is_default': True}
|
|
)
|
|
|
|
# Mock the file read
|
|
default_config = {}
|
|
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
|
with patch(
|
|
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
|
):
|
|
await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)
|
|
|
|
# Verify - execute was called
|
|
ap.persistence_mgr.execute_async.assert_awaited_once()
|
|
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
|
assert statement.is_insert
|
|
assert statement.compile().params['is_default'] is True
|
|
|
|
async def test_create_pipeline_sets_default_extensions_preferences(self):
|
|
"""Sets default extensions_preferences when not provided."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipelines = AsyncMock(return_value=[])
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={
|
|
'uuid': 'new-uuid',
|
|
'extensions_preferences': {},
|
|
}
|
|
)
|
|
|
|
insert_params = []
|
|
|
|
async def mock_execute(query):
|
|
params = query.compile().params
|
|
if 'extensions_preferences' in params:
|
|
insert_params.append(params)
|
|
return Mock()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={
|
|
'uuid': 'new-uuid',
|
|
'extensions_preferences': {},
|
|
}
|
|
)
|
|
|
|
default_config = {}
|
|
with patch('builtins.open', mock_open(read_data=json.dumps(default_config))):
|
|
with patch(
|
|
'langbot.pkg.utils.paths.get_resource_path', return_value='templates/default-pipeline-config.json'
|
|
):
|
|
await service.create_pipeline(WORKSPACE_UUID, {'name': 'New Pipeline'})
|
|
|
|
assert len(insert_params) == 1
|
|
assert insert_params[0]['extensions_preferences'] == {
|
|
'enable_all_plugins': True,
|
|
'enable_all_mcp_servers': True,
|
|
'plugins': [],
|
|
'mcp_servers': [],
|
|
'mcp_resources': [],
|
|
'mcp_resource_agent_read_enabled': True,
|
|
}
|
|
|
|
|
|
class _MockResultWithBots:
|
|
"""Helper class to mock SQLAlchemy result with iterable .all() method."""
|
|
|
|
def __init__(self, bots_list):
|
|
self._bots_list = bots_list
|
|
|
|
def all(self):
|
|
return self._bots_list
|
|
|
|
def first(self):
|
|
return self._bots_list[0] if self._bots_list else None
|
|
|
|
|
|
class TestPipelineServiceUpdatePipeline:
|
|
"""Tests for update_pipeline method."""
|
|
|
|
async def test_update_pipeline_removes_protected_fields(self):
|
|
"""Does not persist protected fields from update data."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.sess_mgr = SimpleNamespace()
|
|
ap.sess_mgr.session_list = []
|
|
ap.bot_service = None # No bot_service when not updating name
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'Updated'})
|
|
|
|
# Execute with protected fields - no name change, so no bot sync
|
|
pipeline_data = {
|
|
'uuid': 'should-be-removed',
|
|
'for_version': 'should-be-removed',
|
|
'stages': ['should-be-removed'],
|
|
'is_default': True,
|
|
'description': 'New description', # Not name change, so no bot_service needed
|
|
}
|
|
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', pipeline_data)
|
|
|
|
update_params = ap.persistence_mgr.execute_async.await_args_list[0].args[0].compile().params
|
|
assert update_params['description'] == 'New description'
|
|
assert 'should-be-removed' not in update_params.values()
|
|
assert ['should-be-removed'] not in update_params.values()
|
|
assert not any(value is True for value in update_params.values())
|
|
|
|
async def test_update_pipeline_syncs_bot_names(self):
|
|
"""Updates bot use_pipeline_name when pipeline name changes."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.sess_mgr = SimpleNamespace()
|
|
ap.sess_mgr.session_list = []
|
|
ap.bot_service = SimpleNamespace()
|
|
ap.bot_service.update_bot = AsyncMock()
|
|
|
|
# Create proper mock Bot entities with uuid attribute
|
|
mock_bot1 = Mock()
|
|
mock_bot1.uuid = 'bot-uuid-1'
|
|
mock_bot2 = Mock()
|
|
mock_bot2.uuid = 'bot-uuid-2'
|
|
|
|
# Create bot list
|
|
bot_list = [mock_bot1, mock_bot2]
|
|
|
|
# Create mock result using helper class
|
|
bot_result = _MockResultWithBots(bot_list)
|
|
|
|
# The order of calls in update_pipeline:
|
|
# 1. UPDATE (line 125) - returns Mock (no result needed)
|
|
# 2. SELECT bots (line 136) - returns bot_result with .all()
|
|
call_count = 0
|
|
|
|
async def mock_execute(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
# First call is the UPDATE - just return a Mock
|
|
return Mock()
|
|
elif call_count == 2:
|
|
# Second call is the SELECT bots - return proper result
|
|
return bot_result
|
|
return Mock() # Any additional calls
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid', 'name': 'New Name'})
|
|
|
|
# Execute with name change
|
|
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'name': 'New Name'})
|
|
|
|
# Bots no longer denormalize Pipeline names in the EventBindings model.
|
|
ap.bot_service.update_bot.assert_not_awaited()
|
|
|
|
async def test_update_pipeline_clears_conversations(self):
|
|
"""Clears session conversations using this pipeline."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.sess_mgr = SimpleNamespace()
|
|
|
|
# Mock session with conversation using this pipeline
|
|
session = SimpleNamespace()
|
|
session.using_conversation = SimpleNamespace()
|
|
session.using_conversation.pipeline_uuid = 'test-uuid'
|
|
ap.sess_mgr.session_list = [session]
|
|
ap.bot_service = SimpleNamespace()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
|
|
|
# Execute
|
|
await service.update_pipeline(WORKSPACE_UUID, 'test-uuid', {'description': 'Updated'})
|
|
|
|
# Verify - conversation was cleared
|
|
assert session.using_conversation is None
|
|
|
|
|
|
class TestPipelineServiceDeletePipeline:
|
|
"""Tests for delete_pipeline method."""
|
|
|
|
async def test_delete_pipeline_calls_remove_and_delete(self):
|
|
"""Calls both pipeline_mgr.remove_pipeline and persistence delete."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.persistence_mgr.execute_async = AsyncMock()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute
|
|
await service.delete_pipeline(WORKSPACE_UUID, 'test-uuid')
|
|
|
|
# Verify
|
|
ap.pipeline_mgr.remove_pipeline.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
|
|
ap.persistence_mgr.execute_async.assert_called_once()
|
|
|
|
async def test_delete_pipeline_nonexistent_uuid(self):
|
|
"""Missing persisted Pipelines do not remove runtime instances."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0))
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
|
|
service = PipelineService(ap)
|
|
|
|
with pytest.raises(WorkspaceNotFoundError, match='Pipeline not found'):
|
|
await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
|
|
ap.pipeline_mgr.remove_pipeline.assert_not_awaited()
|
|
|
|
|
|
class TestPipelineServiceCopyPipeline:
|
|
"""Tests for copy_pipeline method."""
|
|
|
|
async def test_copy_pipeline_max_limit_reached_raises(self):
|
|
"""Raises ValueError when max_pipelines limit reached."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': 2}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
service = PipelineService(ap)
|
|
# Mock get_pipelines to return 2 pipelines
|
|
service.get_pipelines = AsyncMock(
|
|
return_value=[
|
|
{'uuid': 'uuid-1', 'name': 'Pipeline 1'},
|
|
{'uuid': 'uuid-2', 'name': 'Pipeline 2'},
|
|
]
|
|
)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(ValueError, match='Maximum number of pipelines'):
|
|
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
|
|
|
async def test_copy_pipeline_not_found_raises(self):
|
|
"""Raises ValueError when original pipeline not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipelines = AsyncMock(return_value=[]) # No limit check issue
|
|
ap.persistence_mgr.execute_async = AsyncMock(
|
|
return_value=_create_mock_result(first_item=None) # Original not found
|
|
)
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(WorkspaceNotFoundError, match='Pipeline original-uuid not found'):
|
|
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
|
|
|
async def test_copy_pipeline_creates_copy(self):
|
|
"""Creates a copy with (Copy) suffix."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
original = _create_mock_pipeline(
|
|
pipeline_uuid='original-uuid',
|
|
name='Original Pipeline',
|
|
description='Original description',
|
|
stages=['Stage1', 'Stage2'],
|
|
config={'key': 'value'},
|
|
extensions_preferences={'enable_all_plugins': False, 'plugins': ['plugin1']},
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipelines = AsyncMock(return_value=[]) # No limit check issue
|
|
|
|
# Mock persistence - get original, then insert, then get new
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={
|
|
'uuid': 'new-copy-uuid',
|
|
'name': 'Original Pipeline (Copy)',
|
|
}
|
|
)
|
|
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={
|
|
'uuid': 'new-copy-uuid',
|
|
'name': 'Original Pipeline (Copy)',
|
|
}
|
|
)
|
|
|
|
# Execute
|
|
new_uuid = await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
|
|
|
# Verify
|
|
assert new_uuid is not None
|
|
assert uuid.UUID(new_uuid).version == 4
|
|
insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
|
assert insert.is_insert
|
|
values = insert.compile().params
|
|
assert values['uuid'] == new_uuid
|
|
assert values['workspace_uuid'] == WORKSPACE_UUID
|
|
assert values['name'] == 'Original Pipeline (Copy)'
|
|
assert values['description'] == 'Original description'
|
|
assert values['stages'] == ['Stage1', 'Stage2']
|
|
assert values['config'] == {'key': 'value'}
|
|
assert values['extensions_preferences']['enable_all_plugins'] is False
|
|
assert values['extensions_preferences']['plugins'] == []
|
|
query = ap.persistence_mgr.execute_async.await_args_list[0].args[0]
|
|
assert query.whereclause.compare(
|
|
sqlalchemy.and_(LegacyPipeline.uuid == 'original-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
|
|
)
|
|
service.get_pipeline.assert_awaited_once_with(WORKSPACE_UUID, new_uuid, include_secret=True)
|
|
|
|
async def test_copy_pipeline_is_not_default(self):
|
|
"""Copy is never set as default."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.instance_config = SimpleNamespace()
|
|
ap.instance_config.data = {'system': {'limitation': {'max_pipelines': -1}}}
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
ap.ver_mgr = SimpleNamespace()
|
|
ap.ver_mgr.get_current_version = Mock(return_value='1.0.0')
|
|
|
|
# Original is default
|
|
original = _create_mock_pipeline(
|
|
pipeline_uuid='original-uuid',
|
|
name='Default Pipeline',
|
|
is_default=True,
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipelines = AsyncMock(return_value=[])
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=original))
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'copy-uuid', 'is_default': False})
|
|
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'copy-uuid', 'is_default': False})
|
|
|
|
# Execute
|
|
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
|
|
|
|
# Verify - pipeline_mgr.load_pipeline called (copy created)
|
|
ap.pipeline_mgr.load_pipeline.assert_awaited_once_with(WORKSPACE_UUID, service.get_pipeline.return_value)
|
|
insert = ap.persistence_mgr.execute_async.await_args_list[1].args[0]
|
|
assert insert.is_insert
|
|
assert insert.compile().params['is_default'] is False
|
|
|
|
|
|
def _persisted_extensions(ap):
|
|
updates = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_update]
|
|
assert len(updates) == 1
|
|
statement = updates[0]
|
|
assert statement.whereclause.compare(
|
|
sqlalchemy.and_(LegacyPipeline.uuid == 'test-uuid', LegacyPipeline.workspace_uuid == WORKSPACE_UUID)
|
|
)
|
|
return statement.compile().params['extensions_preferences']
|
|
|
|
|
|
class TestPipelineServiceUpdatePipelineExtensions:
|
|
"""Tests for update_pipeline_extensions method."""
|
|
|
|
async def test_update_extensions_pipeline_not_found_raises(self):
|
|
"""Raises ValueError when pipeline not found."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
mock_result = _create_mock_result(first_item=None)
|
|
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
|
|
|
|
service = PipelineService(ap)
|
|
|
|
# Execute & Verify
|
|
with pytest.raises(WorkspaceNotFoundError, match='Pipeline nonexistent-uuid not found'):
|
|
await service.update_pipeline_extensions(WORKSPACE_UUID, 'nonexistent-uuid', [])
|
|
|
|
async def test_update_extensions_sets_plugins(self):
|
|
"""Updates plugins in extensions_preferences."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
|
|
original_pipeline = _create_mock_pipeline(extensions_preferences={'enable_all_plugins': True, 'plugins': []})
|
|
|
|
call_count = 0
|
|
|
|
async def mock_execute(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return _create_mock_result(first_item=original_pipeline)
|
|
return Mock()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={
|
|
'uuid': 'test-uuid',
|
|
'extensions_preferences': {
|
|
'enable_all_plugins': False,
|
|
'plugins': [{'author': 'test', 'name': 'plugin-1'}],
|
|
},
|
|
}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={
|
|
'uuid': 'test-uuid',
|
|
'extensions_preferences': {
|
|
'enable_all_plugins': False,
|
|
'plugins': [{'author': 'test', 'name': 'plugin-1'}],
|
|
},
|
|
}
|
|
)
|
|
|
|
# Execute
|
|
bound_plugins = [{'author': 'test', 'name': 'plugin-1'}]
|
|
await service.update_pipeline_extensions(
|
|
WORKSPACE_UUID,
|
|
'test-uuid',
|
|
bound_plugins=bound_plugins,
|
|
enable_all_plugins=False,
|
|
)
|
|
|
|
# Verify
|
|
persisted = _persisted_extensions(ap)
|
|
assert persisted['enable_all_plugins'] is False
|
|
assert persisted['plugins'] == [{'author': 'test', 'name': 'plugin-1'}]
|
|
|
|
async def test_update_extensions_sets_mcp_servers(self):
|
|
"""Updates MCP servers in extensions_preferences."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
|
|
original_pipeline = _create_mock_pipeline()
|
|
|
|
call_count = 0
|
|
|
|
async def mock_execute(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return _create_mock_result(first_item=original_pipeline)
|
|
return Mock()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={
|
|
'uuid': 'test-uuid',
|
|
'extensions_preferences': {
|
|
'enable_all_mcp_servers': False,
|
|
'mcp_servers': ['mcp-server-1'],
|
|
},
|
|
}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={
|
|
'uuid': 'test-uuid',
|
|
'extensions_preferences': {'mcp_servers': ['mcp-server-1']},
|
|
}
|
|
)
|
|
|
|
# Execute
|
|
await service.update_pipeline_extensions(
|
|
WORKSPACE_UUID,
|
|
'test-uuid',
|
|
bound_plugins=[],
|
|
bound_mcp_servers=['mcp-server-1'],
|
|
enable_all_mcp_servers=False,
|
|
)
|
|
|
|
# Verify
|
|
persisted = _persisted_extensions(ap)
|
|
assert persisted['enable_all_mcp_servers'] is False
|
|
assert persisted['mcp_servers'] == ['mcp-server-1']
|
|
|
|
async def test_update_extensions_none_mcp_servers_keeps_existing(self):
|
|
"""Does not modify mcp_servers when bound_mcp_servers is None."""
|
|
# Setup
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
|
|
original_pipeline = _create_mock_pipeline(
|
|
extensions_preferences={
|
|
'enable_all_plugins': True,
|
|
'enable_all_mcp_servers': True,
|
|
'plugins': [],
|
|
'mcp_servers': ['existing-server'],
|
|
}
|
|
)
|
|
|
|
call_count = 0
|
|
|
|
async def mock_execute(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return _create_mock_result(first_item=original_pipeline)
|
|
return Mock()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(
|
|
return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
|
|
)
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(
|
|
return_value={'uuid': 'test-uuid', 'extensions_preferences': {'mcp_servers': ['existing-server']}}
|
|
)
|
|
|
|
# Execute - bound_mcp_servers is None (not provided)
|
|
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
|
|
|
|
# Verify - persistence was called
|
|
assert _persisted_extensions(ap)['mcp_servers'] == ['existing-server']
|
|
|
|
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
|
|
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
|
|
ap = SimpleNamespace()
|
|
ap.persistence_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr = SimpleNamespace()
|
|
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
|
|
original_pipeline = _create_mock_pipeline(
|
|
extensions_preferences={
|
|
'enable_all_plugins': True,
|
|
'enable_all_mcp_servers': True,
|
|
'plugins': [],
|
|
'mcp_servers': [],
|
|
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
|
|
'mcp_resource_agent_read_enabled': False,
|
|
}
|
|
)
|
|
|
|
call_count = 0
|
|
|
|
async def mock_execute(query):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
return _create_mock_result(first_item=original_pipeline)
|
|
return Mock()
|
|
|
|
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})
|
|
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
|
|
|
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
|
|
|
|
persisted = _persisted_extensions(ap)
|
|
assert persisted['mcp_resource_agent_read_enabled'] is False
|
|
assert persisted['mcp_resources'] == [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}]
|
|
|
|
|
|
class TestPipelineSecretRoundtrip:
|
|
async def test_resource_view_redacts_runner_secrets_without_mutating_serialized_data(self):
|
|
raw = {
|
|
'uuid': 'pipeline-secret',
|
|
'config': {
|
|
'ai': {
|
|
'n8n': {
|
|
'webhook-url': 'https://hook.invalid/bearer-secret',
|
|
'headers': {'Authorization': 'Bearer secret'},
|
|
}
|
|
}
|
|
},
|
|
}
|
|
pipeline = _create_mock_pipeline(pipeline_uuid='pipeline-secret')
|
|
ap = SimpleNamespace(
|
|
persistence_mgr=SimpleNamespace(
|
|
execute_async=AsyncMock(return_value=_create_mock_result([pipeline])),
|
|
serialize_model=Mock(return_value=raw),
|
|
)
|
|
)
|
|
|
|
redacted = await PipelineService(ap).get_pipelines(WORKSPACE_UUID)
|
|
|
|
assert redacted[0]['config']['ai']['n8n']['webhook-url'] == '***'
|
|
assert redacted[0]['config']['ai']['n8n']['headers']['Authorization'] == '***'
|
|
assert raw['config']['ai']['n8n']['webhook-url'] == 'https://hook.invalid/bearer-secret'
|
|
|
|
async def test_masked_runner_config_update_restores_existing_secret(self):
|
|
raw_config = {
|
|
'ai': {
|
|
'n8n': {
|
|
'webhook-url': 'https://hook.invalid/bearer-secret',
|
|
'headers': {'Authorization': 'Bearer secret'},
|
|
'timeout': 30,
|
|
}
|
|
}
|
|
}
|
|
current_pipeline = {'uuid': 'pipeline-secret', 'config': raw_config}
|
|
write_result = Mock(rowcount=1)
|
|
ap = SimpleNamespace(
|
|
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=write_result)),
|
|
pipeline_mgr=SimpleNamespace(remove_pipeline=AsyncMock(), load_pipeline=AsyncMock()),
|
|
sess_mgr=SimpleNamespace(session_list=[]),
|
|
)
|
|
service = PipelineService(ap)
|
|
service.get_pipeline = AsyncMock(side_effect=[current_pipeline, current_pipeline])
|
|
|
|
await service.update_pipeline(
|
|
WORKSPACE_UUID,
|
|
'pipeline-secret',
|
|
{
|
|
'config': {
|
|
'ai': {
|
|
'n8n': {
|
|
'webhook-url': '***',
|
|
'headers': {'Authorization': '***'},
|
|
'timeout': 60,
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
|
|
statement = ap.persistence_mgr.execute_async.await_args.args[0]
|
|
stored_config = next(value.value for column, value in statement._values.items() if column.key == 'config')
|
|
assert stored_config == {
|
|
'ai': {
|
|
'n8n': {
|
|
'webhook-url': 'https://hook.invalid/bearer-secret',
|
|
'headers': {'Authorization': 'Bearer secret'},
|
|
'timeout': 60,
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
class TestDefaultStageOrder:
|
|
"""Tests for default_stage_order constant."""
|
|
|
|
def test_default_stage_order_not_empty(self):
|
|
"""Default stage order is not empty."""
|
|
assert len(default_stage_order) > 0
|
|
|
|
def test_default_stage_order_contains_key_stages(self):
|
|
"""Default stage order contains key processing stages."""
|
|
assert 'MessageProcessor' in default_stage_order
|
|
assert 'SendResponseBackStage' in default_stage_order
|