chore(merge): sync master and preserve monitoring test coverage

This commit is contained in:
BiFangKNT
2026-10-18 12:43:44 +08:00
174 changed files with 7645 additions and 3218 deletions
@@ -868,6 +868,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters(
service = AgentService(app)
with pytest.raises(ValueError, match='Select an installed'):
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'invalid:a/b/c'})
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_not_awaited()
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
@@ -882,6 +884,8 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters(
await service.create_agent(
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c', 'parameters': {}}
)
app.persistence_mgr.execute_async.assert_not_awaited()
app.runner_registry.get.assert_awaited_once_with(WORKSPACE_UUID, 'plugin:a/b/c')
async def test_unavailable_event_processor_can_still_be_renamed():
@@ -347,11 +347,6 @@ class TestApiKeyServiceVerifyApiKey:
assert await service.verify_api_key('lbk_invalid_key') is False
async def test_verify_api_key_empty_string(self, api_key_context):
_application, service, _context, _engine = api_key_context
assert await service.verify_api_key('') is False
async def test_verify_api_key_unknown_key(self, api_key_context):
_application, service, _context, _engine = api_key_context
@@ -11,12 +11,15 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from types import SimpleNamespace
import hashlib
import hmac
import json
import sqlalchemy
import uuid
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.entity.persistence.bot import Bot
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.asyncio
@@ -182,6 +185,11 @@ class TestBotServiceGetBot:
assert result is not None
assert result['uuid'] == 'test-uuid'
assert result['name'] == 'Found Bot'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID)
)
ap.persistence_mgr.serialize_model.assert_called_once_with(Bot, bot, ['adapter_config'])
async def test_get_bot_by_uuid_not_found(self):
"""Returns None when bot not found."""
@@ -361,6 +369,8 @@ class TestBotServiceCreateBot:
# Execute & Verify
with pytest.raises(ValueError, match='Maximum number of bots'):
await service.create_bot(WORKSPACE_UUID, {'name': 'New Bot'})
assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list)
ap.platform_mgr.load_bot.assert_not_awaited()
async def test_create_bot_no_limit(self):
"""Creates bot without limit check when max_bots=-1."""
@@ -408,7 +418,22 @@ class TestBotServiceCreateBot:
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
assert uuid.UUID(bot_uuid).version == 4
statement = ap.persistence_mgr.execute_async.await_args_list[0].args[0]
assert statement.is_insert
assert statement.table.name == Bot.__tablename__
values = statement.compile().params
expected = {
'uuid': bot_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Bot',
'adapter': 'telegram',
'adapter_config': {},
'event_bindings': [],
'plugin_processors': [],
}
assert {key: values.get(key) for key in expected} == expected
ap.platform_mgr.load_bot.assert_not_awaited()
async def test_create_bot_scopes_insert_without_legacy_pipeline_fields(self):
"""Creates a Workspace-owned Bot without restoring removed pipeline columns."""
@@ -647,26 +672,28 @@ class TestBotServiceDeleteBot:
await service.delete_bot(WORKSPACE_UUID, 'test-uuid')
# Verify
ap.platform_mgr.remove_bot.assert_called_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_called_once()
ap.platform_mgr.remove_bot.assert_awaited_once_with(WORKSPACE_UUID, 'test-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_delete
assert statement.table.name == Bot.__tablename__
assert statement.whereclause.compare(
sqlalchemy.and_(Bot.uuid == 'test-uuid', Bot.workspace_uuid == WORKSPACE_UUID)
)
async def test_delete_bot_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
"""A missing persisted Bot is rejected before removing its runtime."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0))
ap.platform_mgr = SimpleNamespace()
ap.platform_mgr.remove_bot = AsyncMock()
service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'bot-uuid'})
# Execute - should not raise
await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify - both called regardless
ap.platform_mgr.remove_bot.assert_called_once()
with pytest.raises(WorkspaceNotFoundError, match='Bot not found'):
await service.delete_bot(WORKSPACE_UUID, 'nonexistent-uuid')
ap.platform_mgr.remove_bot.assert_not_awaited()
class TestBotServiceListEventLogs:
@@ -811,7 +838,9 @@ class TestBotServiceHttpBotInboundTest:
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
headers = request.kwargs['headers']
assert headers['X-LB-Timestamp']
assert headers['X-LB-Signature'].startswith('sha256=')
signing_input = headers['X-LB-Timestamp'].encode() + b'.' + request.kwargs['data']
digest = hmac.new(b'test-secret', signing_input, hashlib.sha256).hexdigest()
assert headers['X-LB-Signature'] == f'sha256={digest}'
async def test_rejects_non_http_bot(self):
service = BotService(SimpleNamespace())
@@ -236,6 +236,8 @@ async def test_extension_discovery_preserves_empty_bindings_and_redacts_plugins(
ap.plugin_connector.require_workspace_context.assert_awaited_once_with(request_context())
@pytest.mark.compiled_cli
@pytest.mark.contract('cli_http')
async def test_compiled_cli_against_core_http(service, tmp_path): # noqa: F811
"""Optional cross-repo check: real HTTP/SQLite/Pipeline, synthetic runtime providers."""
import json
@@ -73,7 +73,7 @@ async def test_all_model_types_reject_creation_under_managed_provider(
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, create_method)(WORKSPACE, model_data)
guard.assert_awaited_once()
guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER)
@pytest.mark.parametrize(
@@ -109,4 +109,4 @@ async def test_all_model_types_reject_update_and_delete_for_managed_provider(
with pytest.raises(ValueError, match='managed by Cloud'):
await getattr(service, write_method)(*args)
guard.assert_awaited_once()
guard.assert_awaited_once_with(application, WORKSPACE, PROVIDER)
@@ -390,6 +390,7 @@ class TestGetKnowledgeBase:
result = await KnowledgeService(app).get_knowledge_base(CONTEXT, 'kb1')
assert result == {'uuid': 'kb1', 'name': 'KB1'}
app.rag_mgr.get_knowledge_base_details.assert_awaited_once_with(CONTEXT, 'kb1')
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self):
@@ -555,7 +556,11 @@ class TestListParsers:
result = await KnowledgeService(app).list_parsers(CONTEXT)
assert len(result) == 2
assert result == [
{'id': 'parser1', 'supported_mime_types': ['text/plain']},
{'id': 'parser2', 'supported_mime_types': ['application/pdf']},
]
app.plugin_connector.list_parsers.assert_awaited_once_with()
@pytest.mark.asyncio
async def test_filters_by_mime_type(self):
@@ -589,7 +594,8 @@ class TestGetEngineSchemas:
'author/engine',
)
assert 'properties' in result
assert result == {'properties': {'name': {'type': 'string'}}}
app.plugin_connector.get_rag_creation_schema.assert_awaited_once_with('author/engine')
@pytest.mark.asyncio
async def test_returns_retrieval_schema(self):
@@ -601,7 +607,8 @@ class TestGetEngineSchemas:
'author/engine',
)
assert 'properties' in result
assert result == {'properties': {'top_k': {'type': 'integer'}}}
app.plugin_connector.get_rag_retrieval_schema.assert_awaited_once_with('author/engine')
@pytest.mark.asyncio
async def test_reports_schema_runtime_exception(self):
@@ -14,8 +14,9 @@ Source: src/langbot/pkg/api/http/service/maintenance.py
from __future__ import annotations
import contextlib
import os
import pytest
from unittest.mock import AsyncMock, Mock, patch, MagicMock
from unittest.mock import AsyncMock, Mock, patch, MagicMock, call
from types import SimpleNamespace
import datetime
from pathlib import Path
@@ -91,6 +92,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7)
service._cleanup_expired_log_files.assert_called_once_with(3)
# Verify - returns counts
assert 'uploaded_files' in result
@@ -128,6 +131,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 14)
service._cleanup_expired_log_files.assert_called_once_with(7)
# Verify
assert result['uploaded_files'] == 2
@@ -152,7 +157,10 @@ class TestMaintenanceServiceCleanupExpiredFiles:
service = MaintenanceService(ap)
# Mock the internal cleanup methods
service._cleanup_expired_uploaded_files = AsyncMock(return_value=1)
service._expired_s3_upload_candidates = AsyncMock(return_value=[{'key': 'scoped/expired-upload'}])
protected = Mock()
protected.scalars.return_value.all.return_value = []
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock(return_value=protected))
service._cleanup_expired_log_files = Mock(return_value=0) # NOT async
# Execute
@@ -161,6 +169,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Verify
assert result['uploaded_files'] == 1
assert result['log_files'] == 0
service._expired_s3_upload_candidates.assert_awaited_once_with(TEST_CONTEXT, 7)
s3_provider.delete.assert_awaited_once_with('scoped/expired-upload')
async def test_cleanup_expired_files_invalid_retention(self):
"""Uses default for invalid retention config."""
@@ -192,6 +202,8 @@ class TestMaintenanceServiceCleanupExpiredFiles:
# Execute
result = await service.cleanup_expired_files(TEST_CONTEXT)
service._cleanup_expired_uploaded_files.assert_awaited_once_with(TEST_CONTEXT, 7)
service._cleanup_expired_log_files.assert_called_once_with(3)
# Verify - warning logged, defaults used
assert ap.logger.warning.called
@@ -280,7 +292,25 @@ class TestMaintenanceServiceGetStorageAnalysis:
assert 'cleanup_policy' in result
assert 'sections' in result
assert 'database' in result
assert 'cleanup_candidates' in result
assert result['cleanup_policy'] == {'uploaded_file_retention_days': 7, 'log_retention_days': 3}
assert {
section['key']: (Path(section['path']).as_posix(), section['size_bytes'], section['file_count'])
for section in result['sections']
} == {
'database': ('data/langbot.db', 1000, 5),
'logs': ('data/logs', 1000, 5),
'storage': ('data/storage', 1000, 5),
'vector_store': ('data/chroma', 1000, 5),
'temp': ('data/temp', 1000, 5),
}
assert result['total_size_bytes'] == 5000
assert result['database'] == {
'type': 'sqlite',
'monitoring_counts': {'messages': 10, 'errors': 0},
'binary_storage': {'count': 5, 'size_bytes': 500},
}
assert result['cleanup_candidates'] == {'uploaded_files': [], 'log_files': []}
assert result['tasks'] == {'running': 0}
async def test_get_storage_analysis_sections(self):
"""Returns all storage sections."""
@@ -462,8 +492,12 @@ class TestMaintenanceServiceGetStorageAnalysis:
result = await service.get_storage_analysis(TEST_CONTEXT)
# Verify
assert len(result['cleanup_candidates']['uploaded_files']) == 1
assert len(result['cleanup_candidates']['log_files']) == 1
assert result['cleanup_candidates'] == {
'uploaded_files': [{'key': 'old_file', 'size_bytes': 100}],
'log_files': [{'name': 'old_log', 'size_bytes': 50}],
}
service._expired_uploaded_candidates.assert_awaited_once_with(TEST_CONTEXT, 7)
service._expired_log_candidates.assert_called_once_with(3)
class TestMaintenanceServiceMonitoringCounts:
@@ -484,12 +518,9 @@ class TestMaintenanceServiceMonitoringCounts:
result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all table keys present
assert 'messages' in result
assert 'llm_calls' in result
assert 'embedding_calls' in result
assert 'errors' in result
assert 'sessions' in result
assert 'feedback' in result
assert result == dict.fromkeys(
['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 42
)
async def test_monitoring_counts_zero_results(self):
"""Returns zero counts when tables empty."""
@@ -506,7 +537,9 @@ class TestMaintenanceServiceMonitoringCounts:
result = await service._monitoring_counts(TEST_CONTEXT)
# Verify - all zero
assert all(v == 0 for v in result.values())
assert result == dict.fromkeys(
['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'errors', 'sessions', 'feedback'], 0
)
class TestMaintenanceServiceBinaryStorageStats:
@@ -573,7 +606,7 @@ class TestMaintenanceServiceBinaryStorageStats:
# Verify - warning logged, size_bytes None or 0
assert ap.logger.warning.called
assert result['count'] == 5
assert result == {'count': 5, 'size_bytes': None}
class TestMaintenanceServicePathSize:
@@ -788,11 +821,17 @@ class TestMaintenanceServiceIsUploadedFileKey:
service = MaintenanceService(ap)
# Execute - simple filename without path
key = f'{ap.storage_mgr.scoped_prefix(TEST_CONTEXT, owner_type="upload")}uploaded_file.txt'
ap.storage_mgr.scoped_prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{owner_type}/')
ap.storage_mgr.is_scoped_object_key = Mock(return_value=True)
key = 'scoped/upload/uploaded_file.txt'
result = service._is_uploaded_file_key(TEST_CONTEXT, key)
# Verify
assert result is True
assert ap.storage_mgr.scoped_prefix.call_args_list == [
call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload')
]
ap.storage_mgr.is_scoped_object_key.assert_called_once_with(key, expected_owner_type='upload')
def test_is_uploaded_file_key_with_path(self):
"""Returns False for key with path separator."""
@@ -907,7 +946,7 @@ class TestMaintenanceServiceExpiredLogCandidates:
result = service._expired_log_candidates(3, include_paths=True)
# Verify - path included
assert 'path' in result[0]
assert result[0]['path'] == '/data/logs/' + old_log_name
class TestMaintenanceServiceExpiredLocalUploadCandidates:
@@ -928,40 +967,38 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
# Verify
assert result == []
def test_expired_local_upload_candidates_filters_uploaded(self):
"""Only returns uploaded files matching pattern."""
# Setup
ap = SimpleNamespace()
ap.logger = SimpleNamespace()
ap.storage_mgr = _scoped_storage_manager()
def test_expired_local_upload_candidates_filters_uploaded(self, tmp_path, monkeypatch):
"""Only expired files below this Workspace's upload roots are candidates."""
monkeypatch.chdir(tmp_path)
prefix = Mock(side_effect=lambda context, *, owner_type: f'scoped/{context.workspace_uuid}/{owner_type}/')
ap = SimpleNamespace(logger=SimpleNamespace(warning=Mock()), storage_mgr=SimpleNamespace(scoped_prefix=prefix))
storage_root = Path('data/storage')
upload_root = storage_root / 'scoped/test-workspace/upload'
upload_root.mkdir(parents=True)
old = upload_root / 'valid_upload.txt'
old.write_bytes(b'x' * 100)
os.utime(old, (0, 0))
recent = upload_root / 'recent.txt'
recent.write_text('keep')
(upload_root / 'directory').mkdir()
foreign = storage_root / 'scoped/other-workspace/upload/foreign.txt'
foreign.parent.mkdir(parents=True)
foreign.write_text('keep')
os.utime(foreign, (0, 0))
service = MaintenanceService(ap)
# Create one file and one non-file entry under the scoped upload root.
mock_entry_valid = Mock(spec=Path)
mock_entry_valid.is_file = Mock(return_value=True)
mock_entry_valid.name = 'valid_upload.txt'
mock_stat = Mock()
mock_stat.st_size = 100
mock_stat.st_mtime = 0 # Very old
mock_entry_valid.stat = Mock(return_value=mock_stat)
mock_entry_valid.relative_to = Mock(return_value=Path('scoped/valid_upload.txt'))
result = MaintenanceService(ap)._expired_local_upload_candidates(TEST_CONTEXT, 7)
mock_entry_plugin = Mock(spec=Path)
mock_entry_plugin.is_file = Mock(return_value=False)
mock_entry_plugin.name = 'plugin_config_test.json'
mock_stat2 = Mock()
mock_stat2.st_size = 200
mock_stat2.st_mtime = 0
mock_entry_plugin.stat = Mock(return_value=mock_stat2)
with patch.object(Path, 'exists', return_value=True):
with patch.object(Path, 'rglob') as mock_rglob:
mock_rglob.return_value = [mock_entry_valid, mock_entry_plugin]
result = service._expired_local_upload_candidates(TEST_CONTEXT, 7)
# Verify - only valid upload included
assert len(result) == 1
assert result[0]['key'] == 'scoped/valid_upload.txt'
assert result == [
{
'key': 'scoped/test-workspace/upload/valid_upload.txt',
'size_bytes': 100,
'modified_at': '1970-01-01T00:00:00+00:00',
}
]
assert prefix.call_args_list == [
call(TEST_CONTEXT, owner_type=owner) for owner in ('upload_image', 'upload_document', 'upload')
]
assert recent.read_text() == foreign.read_text() == 'keep'
def test_expired_local_upload_candidates_includes_path(self):
"""Includes path when include_paths=True."""
@@ -991,7 +1028,7 @@ class TestMaintenanceServiceExpiredLocalUploadCandidates:
)
# Verify - path included
assert 'path' in result[0]
assert result[0]['path'] == '/data/storage/old_file.txt'
def test_expired_local_upload_candidates_respects_run_limit(self):
ap = SimpleNamespace(
+101 -21
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import asyncio
import copy
import sqlalchemy
import pytest
from unittest.mock import AsyncMock, Mock, MagicMock
from types import SimpleNamespace
@@ -97,6 +98,22 @@ def _create_mock_result(items: list = None, first_item=None, *, scalar_value=0,
return result
def _write_statement(ap, operation):
statements = [
call.args[0]
for call in ap.persistence_mgr.execute_async.await_args_list
if getattr(call.args[0], f'is_{operation}')
]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == MCPServer.__tablename__
if operation != 'insert':
assert statement.whereclause.compare(
sqlalchemy.and_(MCPServer.uuid == 'test-uuid', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid)
)
return statement
class TestMCPServiceGetRuntimeInfo:
"""Tests for get_runtime_info method."""
@@ -118,7 +135,8 @@ class TestMCPServiceGetRuntimeInfo:
# Verify
assert result is not None
assert result['status'] == 'running'
assert result == {'status': 'running', 'tools': 5}
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'test-server')
async def test_get_runtime_info_session_not_exists(self):
"""Returns None when session not exists."""
@@ -400,6 +418,8 @@ class TestMCPServiceCreateMCPServer:
# Execute & Verify - 2 plugins + new server would exceed limit
with pytest.raises(ValueError, match='Maximum number of extensions'):
await service.create_mcp_server(_CONTEXT, {'name': 'New Server'})
assert all(not call.args[0].is_insert for call in ap.persistence_mgr.execute_async.await_args_list)
ap.tool_mgr.mcp_tool_loader.get_session.assert_not_called()
async def test_create_mcp_server_no_limit(self):
"""Creates MCP server without limit when max_extensions=-1."""
@@ -427,7 +447,11 @@ class TestMCPServiceCreateMCPServer:
# Verify
assert server_uuid is not None
assert len(server_uuid) == 36 # UUID format
assert uuid.UUID(server_uuid).version == 4
values = _write_statement(ap, 'insert').compile().params
assert values['uuid'] == server_uuid
assert values['workspace_uuid'] == _CONTEXT.workspace_uuid
assert values['name'] == 'New Server'
async def test_create_mcp_server_duplicate_name_raises(self):
"""Rejects duplicate MCP server names."""
@@ -485,7 +509,14 @@ class TestMCPServiceCreateMCPServer:
await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': True})
# Verify - host_mcp_server was called
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'insert').compile().params
assert values['workspace_uuid'] == _CONTEXT.workspace_uuid
assert values['name'] == 'New Server'
assert values['enable'] is True
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': True}
)
async def test_create_mcp_server_does_not_start_host_until_transaction_commits(self):
"""The Runtime must not observe a server row that can still roll back."""
@@ -536,25 +567,36 @@ class TestMCPServiceCreateMCPServer:
]
async def test_create_mcp_server_disabled_no_load(self):
"""Does not load server when disabled."""
"""Persists and passes disabled configuration to the runtime loader."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.instance_config = SimpleNamespace()
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
ap.tool_mgr = None
ap.tool_mgr = SimpleNamespace(
mcp_tool_loader=SimpleNamespace(host_mcp_server=AsyncMock(), _hosted_mcp_tasks=[])
)
mock_result = _create_mock_result([])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'new-uuid'})
server = _create_mock_mcp_server(server_uuid='new-uuid', name='New Server', enable=False)
ap.persistence_mgr.execute_async = AsyncMock(
side_effect=[_create_mock_result(), _create_mock_result(), _create_mock_result(first_item=server)]
)
ap.persistence_mgr.serialize_model = Mock(
return_value={'uuid': 'new-uuid', 'name': 'New Server', 'enable': False}
)
service = _service(ap)
# Execute with enable=False
server_uuid = await service.create_mcp_server(_CONTEXT, {'name': 'New Server', 'enable': False})
# Verify - no tool_mgr load attempt
assert server_uuid is not None
# Verify the persisted flag and the configuration passed to the loader
assert uuid.UUID(server_uuid).version == 4
assert _write_statement(ap, 'insert').compile().params['enable'] is False
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'new-uuid', 'name': 'New Server', 'enable': False}
)
class TestMCPServiceGetMCPServerByName:
@@ -585,6 +627,12 @@ class TestMCPServiceGetMCPServerByName:
# Verify
assert result is not None
assert result['name'] == 'Found Server'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(MCPServer.name == 'Found Server', MCPServer.workspace_uuid == _CONTEXT.workspace_uuid)
)
ap.persistence_mgr.serialize_model.assert_called_once_with(MCPServer, server)
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'Found Server')
async def test_get_mcp_server_by_name_not_found(self):
"""Returns None when MCP server not found."""
@@ -647,7 +695,9 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - server was removed
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is False
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_awaited_once_with(_CONTEXT, 'Old Server')
async def test_update_mcp_server_enable_disabled_server(self):
"""Loads server when enabling previously disabled server."""
@@ -687,7 +737,12 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': True})
# Verify - server was loaded
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is True
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
async def test_update_mcp_server_update_enabled_server(self):
"""Removes and reloads server when updating enabled server."""
@@ -725,7 +780,13 @@ class TestMCPServiceUpdateMCPServer:
# Verify - remove and reload
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Old Server')
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_called_once()
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is True
assert values.get('extra_args') == {'new': 'args'}
await asyncio.gather(*ap.tool_mgr.mcp_tool_loader._hosted_mcp_tasks)
ap.tool_mgr.mcp_tool_loader.host_mcp_server.assert_awaited_once_with(
_CONTEXT, {'uuid': 'test-uuid', 'name': 'Old Server', 'enable': True}
)
async def test_update_mcp_server_no_tool_mgr(self):
"""Updates persistence without tool_mgr operations."""
@@ -763,7 +824,8 @@ class TestMCPServiceUpdateMCPServer:
await service.update_mcp_server(_CONTEXT, 'test-uuid', {'enable': False})
# Verify - persistence was called
assert ap.persistence_mgr.execute_async.call_count >= 2
values = _write_statement(ap, 'update').compile().params
assert values['enable'] is False
async def test_update_restores_existing_masked_secrets_and_preserves_explicit_changes(self):
ap = SimpleNamespace()
@@ -884,7 +946,7 @@ class TestMCPServiceDeleteMCPServer:
# Verify
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_called_once_with(_CONTEXT, 'Server to Delete')
ap.persistence_mgr.execute_async.assert_called()
_write_statement(ap, 'delete')
async def test_delete_mcp_server_not_in_sessions(self):
"""Does not attempt remove if server not in sessions."""
@@ -923,7 +985,8 @@ class TestMCPServiceDeleteMCPServer:
await service.delete_mcp_server(_CONTEXT, 'test-uuid')
# Verify - remove not called (server not in sessions)
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_called()
ap.tool_mgr.mcp_tool_loader.remove_mcp_server.assert_not_awaited()
_write_statement(ap, 'delete')
async def test_delete_mcp_server_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
@@ -989,6 +1052,7 @@ class TestMCPServiceTestMCPServer:
mock_session = MagicMock()
mock_session.status = MCPSessionStatus.ERROR
mock_session.start = AsyncMock()
mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3}
mock_session.refresh = AsyncMock()
ap.tool_mgr.mcp_tool_loader.get_session = Mock(return_value=mock_session)
@@ -997,8 +1061,10 @@ class TestMCPServiceTestMCPServer:
service = _service(ap)
service._require_server = AsyncMock(return_value=(_CONTEXT, {'name': 'existing-server'}))
def create_user_task(coroutine, **_kwargs):
coroutine.close()
captured = {}
def create_user_task(coroutine, **kwargs):
captured.update(coroutine=coroutine, context=kwargs['context'])
return SimpleNamespace(id=123)
ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
@@ -1007,7 +1073,11 @@ class TestMCPServiceTestMCPServer:
task_id = await service.test_mcp_server(_CONTEXT, 'existing-server', {})
# Verify - returns task ID
ap.tool_mgr.mcp_tool_loader.get_session.assert_called_once_with(_CONTEXT, 'existing-server')
assert task_id == 123
await captured['coroutine']
mock_session.start.assert_awaited_once_with()
assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3}
@pytest.mark.parametrize('refresh_first', [False, True])
async def test_persisted_test_preserves_failure_details(self, refresh_first):
@@ -1064,14 +1134,18 @@ class TestMCPServiceTestMCPServer:
mock_session = MagicMock()
mock_session.start = AsyncMock()
mock_session.shutdown = AsyncMock()
mock_session.get_runtime_info_dict.return_value = {'status': 'connected', 'tools': 3}
ap.tool_mgr.mcp_tool_loader.load_mcp_server = AsyncMock(return_value=mock_session)
ap.task_mgr = SimpleNamespace()
service = _service(ap)
def create_user_task(coroutine, **_kwargs):
coroutine.close()
captured = {}
def create_user_task(coroutine, **kwargs):
captured.update(coroutine=coroutine, context=kwargs['context'])
return SimpleNamespace(id=456)
ap.task_mgr.create_user_task = Mock(side_effect=create_user_task)
@@ -1080,8 +1154,14 @@ class TestMCPServiceTestMCPServer:
task_id = await service.test_mcp_server(_CONTEXT, '_', {'name': 'New Server'})
# Verify - load_mcp_server called
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_called_once()
ap.tool_mgr.mcp_tool_loader.load_mcp_server.assert_awaited_once_with(
_CONTEXT, {'name': 'New Server', 'workspace_uuid': _CONTEXT.workspace_uuid}
)
assert task_id == 456
await captured['coroutine']
mock_session.start.assert_awaited_once_with()
mock_session.shutdown.assert_awaited_once_with()
assert captured['context'].metadata['runtime_info'] == {'status': 'connected', 'tools': 3}
async def test_transient_test_preserves_runtime_info_after_connection_failure(self):
runtime_info = {
@@ -14,6 +14,7 @@ Source: src/langbot/pkg/api/http/service/model.py
from __future__ import annotations
import pytest
import sqlalchemy
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
@@ -182,6 +183,42 @@ def _create_reasoning_runtime_provider(capabilities: dict) -> SimpleNamespace:
)
def _assert_created_model(ap, model_class, expected):
statements = [call.args[0] for call in ap.persistence_mgr.execute_async.await_args_list if call.args[0].is_insert]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == model_class.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
kind = {LLMModel: 'llm', EmbeddingModel: 'embedding', RerankModel: 'rerank'}[model_class]
load = getattr(ap.model_mgr, f'load_{kind}_model_with_provider')
load.assert_awaited_once()
context, entity, provider = load.await_args.args
assert context == WORKSPACE_UUID
assert entity.uuid == expected['uuid']
assert provider is ap.model_mgr.provider_dict[expected['provider_uuid']]
getattr(ap.model_mgr, f'cache_{kind}_model').assert_awaited_once_with(WORKSPACE_UUID, load.return_value)
def _assert_provider_query(ap, model_class, provider_uuid):
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_select
assert statement.whereclause.compare(
sqlalchemy.and_(model_class.provider_uuid == provider_uuid, model_class.workspace_uuid == WORKSPACE_UUID)
)
def _assert_model_delete(ap, model_class, model_uuid):
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_delete
assert statement.table.name == model_class.__tablename__
assert statement.whereclause.compare(
sqlalchemy.and_(model_class.uuid == model_uuid, model_class.workspace_uuid == WORKSPACE_UUID)
)
class TestParseProviderApiKeys:
"""Tests for _parse_provider_api_keys helper function."""
@@ -348,6 +385,8 @@ class TestLLMModelsServiceGetLLMModels:
assert len(result) == 1
assert result[0]['name'] == 'Test LLM'
assert result[0]['context_length'] == 128000
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
async def test_get_llm_models_hide_secret_keys(self):
"""Hides secret API keys when include_secret=False."""
@@ -462,7 +501,9 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'model-1', 'name': 'Model 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = LLMModelsService(ap)
@@ -470,7 +511,8 @@ class TestLLMModelsServiceGetLLMModelsByProvider:
result = await service.get_llm_models_by_provider(WORKSPACE_UUID, 'target-provider')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['model-1', 'model-2']
_assert_provider_query(ap, LLMModel, 'target-provider')
class TestLLMModelsServiceCreateLLMModel:
@@ -507,6 +549,11 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36 # UUID format
_assert_created_model(
ap,
LLMModel,
{'uuid': model_uuid, 'workspace_uuid': WORKSPACE_UUID, 'name': 'New LLM', 'provider_uuid': 'provider-uuid'},
)
async def test_create_llm_model_preserve_uuid(self):
"""Creates LLM model preserving provided UUID."""
@@ -540,6 +587,16 @@ class TestLLMModelsServiceCreateLLMModel:
# Verify
assert model_uuid == 'preserved-uuid'
_assert_created_model(
ap,
LLMModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'Preserved UUID Model',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_llm_model_persists_context_length_as_column(self):
"""Creates LLM model with context_length outside extra_args."""
@@ -574,6 +631,17 @@ class TestLLMModelsServiceCreateLLMModel:
assert runtime_entity.context_length == 128000
assert runtime_entity.extra_args == {'temperature': 0.2}
assert 'context_length' not in runtime_entity.extra_args
_assert_created_model(
ap,
LLMModel,
{
'uuid': 'model-with-context',
'workspace_uuid': WORKSPACE_UUID,
'provider_uuid': 'provider-uuid',
'context_length': 128000,
'extra_args': {'temperature': 0.2},
},
)
async def test_create_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found in runtime."""
@@ -639,7 +707,9 @@ class TestLLMModelsServiceCreateLLMModel:
)
# Verify - provider_service was called and UUID generated
ap.provider_service.find_or_create_provider.assert_called_once()
ap.provider_service.find_or_create_provider.assert_awaited_once_with(
WORKSPACE_UUID, requester='openai', base_url='https://api.openai.com', api_keys=['key']
)
assert result_uuid is not None
async def test_create_llm_model_validates_explicit_reasoning_level(self):
@@ -738,6 +808,11 @@ class TestLLMModelsServiceUpdateLLMModel:
# Verify - remove and load called
ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert 'uuid' not in statement.compile().params
assert statement.compile().params.get('name') == 'Updated Name'
async def test_update_llm_model_provider_not_found_raises_error(self):
"""Raises Exception when provider not found after update."""
@@ -795,6 +870,11 @@ class TestLLMModelsServiceUpdateLLMModel:
assert runtime_entity.context_length == 64000
assert runtime_entity.extra_args == {'temperature': 0.4}
assert 'context_length' not in runtime_entity.extra_args
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert statement.compile().params.get('context_length') == 64000
assert statement.compile().params.get('extra_args') == {'temperature': 0.4}
class TestLLMModelsServiceDeleteLLMModel:
@@ -817,7 +897,8 @@ class TestLLMModelsServiceDeleteLLMModel:
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
ap.model_mgr.remove_llm_model.assert_called_once_with(WORKSPACE_UUID, 'delete-uuid')
ap.model_mgr.remove_llm_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-uuid')
_assert_model_delete(ap, LLMModel, 'delete-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModels:
@@ -880,7 +961,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModels:
)
# Verify
assert len(result) == 1
assert [(item['uuid'], item['name']) for item in result] == [('embedding-uuid', 'Test Embedding')]
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
class TestEmbeddingModelsServiceGetEmbeddingModel:
@@ -907,11 +990,7 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-embedding',
'name': 'Found Embedding',
'provider': {'uuid': 'provider-uuid'},
}
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = EmbeddingModelsService(ap)
@@ -920,7 +999,11 @@ class TestEmbeddingModelsServiceGetEmbeddingModel:
result = await service.get_embedding_model(WORKSPACE_UUID, 'found-embedding')
# Verify
assert result is not None
assert result == {
'uuid': 'found-embedding',
'name': 'Test Embedding',
'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'},
}
async def test_get_embedding_model_not_found(self):
"""Returns None when model not found."""
@@ -971,6 +1054,16 @@ class TestEmbeddingModelsServiceCreateEmbeddingModel:
# Verify
assert model_uuid is not None
assert len(model_uuid) == 36
_assert_created_model(
ap,
EmbeddingModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Embedding',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_embedding_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
@@ -1016,7 +1109,8 @@ class TestEmbeddingModelsServiceDeleteEmbeddingModel:
await service.delete_embedding_model(WORKSPACE_UUID, 'delete-embedding-uuid')
# Verify
ap.model_mgr.remove_embedding_model.assert_called_once()
ap.model_mgr.remove_embedding_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-embedding-uuid')
_assert_model_delete(ap, EmbeddingModel, 'delete-embedding-uuid')
class TestRerankModelsServiceGetRerankModels:
@@ -1078,7 +1172,9 @@ class TestRerankModelsServiceGetRerankModels:
)
# Verify
assert len(result) == 1
assert [(item['uuid'], item['name']) for item in result] == [('rerank-uuid', 'Test Rerank')]
assert result[0].get('provider', {}).get('uuid') == 'provider-uuid'
assert result[0].get('provider', {}).get('name') == 'Test Provider'
class TestRerankModelsServiceGetRerankModel:
@@ -1105,11 +1201,7 @@ class TestRerankModelsServiceGetRerankModel:
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
ap.persistence_mgr.serialize_model = Mock(
return_value={
'uuid': 'found-rerank',
'name': 'Found Rerank',
'provider': {'uuid': 'provider-uuid'},
}
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = RerankModelsService(ap)
@@ -1118,7 +1210,11 @@ class TestRerankModelsServiceGetRerankModel:
result = await service.get_rerank_model(WORKSPACE_UUID, 'found-rerank')
# Verify
assert result is not None
assert result == {
'uuid': 'found-rerank',
'name': 'Test Rerank',
'provider': {'uuid': 'provider-uuid', 'name': 'Test Provider'},
}
async def test_get_rerank_model_not_found(self):
"""Returns None when model not found."""
@@ -1168,6 +1264,16 @@ class TestRerankModelsServiceCreateRerankModel:
# Verify
assert model_uuid is not None
_assert_created_model(
ap,
RerankModel,
{
'uuid': model_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Rerank',
'provider_uuid': 'provider-uuid',
},
)
async def test_create_rerank_model_provider_not_found_raises(self):
"""Raises Exception when provider not found."""
@@ -1213,7 +1319,8 @@ class TestRerankModelsServiceDeleteRerankModel:
await service.delete_rerank_model(WORKSPACE_UUID, 'delete-rerank-uuid')
# Verify
ap.model_mgr.remove_rerank_model.assert_called_once()
ap.model_mgr.remove_rerank_model.assert_awaited_once_with(WORKSPACE_UUID, 'delete-rerank-uuid')
_assert_model_delete(ap, RerankModel, 'delete-rerank-uuid')
class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
@@ -1230,7 +1337,9 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'emb-1', 'name': 'Embedding 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = EmbeddingModelsService(ap)
@@ -1238,7 +1347,8 @@ class TestEmbeddingModelsServiceGetEmbeddingModelsByProvider:
result = await service.get_embedding_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['emb-1', 'emb-2']
_assert_provider_query(ap, EmbeddingModel, 'provider-uuid')
class TestRerankModelsServiceGetRerankModelsByProvider:
@@ -1255,7 +1365,9 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
mock_result = _create_mock_result([model1, model2])
ap.persistence_mgr.execute_async = AsyncMock(return_value=mock_result)
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'rerank-1', 'name': 'Rerank 1'})
ap.persistence_mgr.serialize_model = Mock(
side_effect=lambda _model_class, entity: {'uuid': entity.uuid, 'name': entity.name}
)
service = RerankModelsService(ap)
@@ -1263,7 +1375,8 @@ class TestRerankModelsServiceGetRerankModelsByProvider:
result = await service.get_rerank_models_by_provider(WORKSPACE_UUID, 'provider-uuid')
# Verify
assert len(result) == 2
assert [item['uuid'] for item in result] == ['rerank-1', 'rerank-2']
_assert_provider_query(ap, RerankModel, 'provider-uuid')
class TestValidateProviderSupports:
@@ -21,6 +21,8 @@ pytestmark = pytest.mark.asyncio
@pytest.mark.asyncio(loop_scope='module')
@pytest.mark.postgres
@pytest.mark.contract('monitoring_sessions')
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
import asyncio
import importlib
@@ -138,7 +138,8 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
assert (await service.get_message_details(context_a, message_b))['found'] is False
async def test_session_search_matches_user_id_or_name_within_workspace(service):
@pytest.mark.parametrize('bot_ids', [None, ['same-bot']])
async def test_session_search_matches_user_id_or_name_within_workspace(service, bot_ids):
context_a = _context(WORKSPACE_A)
context_b = _context(WORKSPACE_B)
fixtures = [
@@ -159,8 +160,8 @@ async def test_session_search_matches_user_id_or_name_within_workspace(service):
user_name=user_name,
)
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
by_id, id_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='customer-42')
by_name, name_total = await service.get_sessions(context_a, bot_ids=bot_ids, user_query='alice')
assert id_total == 1
assert [session['session_id'] for session in by_id] == ['session-id-match']
@@ -367,6 +367,9 @@ async def test_ordinary_update_requires_manual_migration_but_allows_metadata(env
await service.update_pipeline(context(), 'one', {'name': 'renamed'})
configs, backups = await rows(env)
assert configs['one'] == SOURCE and not backups
async with env.engine.connect() as conn:
name = (await conn.execute(sa.select(LegacyPipeline.name).where(LegacyPipeline.uuid == 'one'))).scalar_one()
assert name == 'renamed'
@pytest.mark.asyncio
@@ -777,7 +780,8 @@ async def test_all_mode_installs_missing_plugin_once_then_migrates_both(env):
await conn.execute(sa.delete(PluginSetting))
async def install(source, info, task_context):
assert info['plugin_version'] == '1.0'
assert source == env.m.PluginInstallSource.MARKETPLACE
assert info == {'plugin_author': 'langbot-team', 'plugin_name': 'TestAgent', 'plugin_version': '1.0'}
async with env.engine.begin() as conn:
await conn.execute(
sa.insert(PluginSetting).values(
@@ -848,7 +852,14 @@ async def test_all_mode_has_no_fifty_pipeline_limit(env):
)
response, metadata = await execute_all(env, install_plugins=False)
assert len(response['pipeline_uuids']) == 53
expected = {'one', 'two', *(f'extra-{i}' for i in range(51))}
assert {r['pipeline_uuid'] for r in metadata['results']} == expected
assert len(metadata['results']) == 53
assert all(r['state'] == 'migrated' for r in metadata['results'])
configs, backups = await rows(env)
assert len(backups) == 53
assert all(configs[pipeline_uuid]['ai']['runner']['id'] == RID for pipeline_uuid in expected)
assert configs['foreign'] == SOURCE
@pytest.mark.asyncio
@@ -23,12 +23,6 @@ env = existing.env
NATIVE = 'langbot.pkg.provider.runners.difysvapi'
@pytest.fixture(autouse=True)
async def interaction_schema(env):
async with env.engine.begin() as conn:
await conn.run_sync(lambda c: AgentInteraction.__table__.create(c, checkfirst=True))
async def add_request(env, status='pending', workspace=WS, pipeline='one'):
async with env.engine.begin() as conn:
await conn.execute(
@@ -224,11 +218,3 @@ async def test_native_form_arriving_during_cas_rolls_back(env, monkeypatch):
configs, snapshots = await rows(env)
assert configs['one'] == SOURCE and not snapshots
env.ap.pipeline_mgr.publish_pipeline.assert_not_called()
# Also exercise the unchanged regression assertions with this module's
# interaction-schema fixture. The original tests remain in their own module.
for _name, _test in vars(existing).items():
if _name.startswith('test_') and callable(_test):
globals()['test_existing_' + _name[5:]] = _test
@@ -17,6 +17,7 @@ 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
@@ -145,8 +146,12 @@ class TestPipelineServiceGetPipelines:
)
# Verify
assert len(result) == 2
ap.persistence_mgr.execute_async.assert_called_once()
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."""
@@ -164,7 +169,11 @@ class TestPipelineServiceGetPipelines:
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_called_once()
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:
@@ -271,7 +280,19 @@ class TestPipelineServiceCreatePipeline:
# Verify
assert bot_uuid is not None
assert len(bot_uuid) == 36 # UUID format
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."""
@@ -305,7 +326,10 @@ class TestPipelineServiceCreatePipeline:
await service.create_pipeline(WORKSPACE_UUID, {'name': 'Default Pipeline'}, default=True)
# Verify - execute was called
ap.persistence_mgr.execute_async.assert_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."""
@@ -515,21 +539,19 @@ class TestPipelineServiceDeletePipeline:
ap.persistence_mgr.execute_async.assert_called_once()
async def test_delete_pipeline_nonexistent_uuid(self):
"""Delete operation completes even for nonexistent UUID."""
"""Missing persisted Pipelines do not remove runtime instances."""
# Setup
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock()
ap.persistence_mgr.execute_async = AsyncMock(return_value=SimpleNamespace(rowcount=0))
ap.pipeline_mgr = SimpleNamespace()
ap.pipeline_mgr.remove_pipeline = AsyncMock()
service = PipelineService(ap)
# Execute - should not raise
await service.delete_pipeline(WORKSPACE_UUID, 'nonexistent-uuid')
# Verify
ap.pipeline_mgr.remove_pipeline.assert_called_once()
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:
@@ -627,7 +649,23 @@ class TestPipelineServiceCopyPipeline:
# Verify
assert new_uuid is not None
assert len(new_uuid) == 36 # UUID format
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."""
@@ -659,7 +697,20 @@ class TestPipelineServiceCopyPipeline:
await service.copy_pipeline(WORKSPACE_UUID, 'original-uuid')
# Verify - pipeline_mgr.load_pipeline called (copy created)
ap.pipeline_mgr.load_pipeline.assert_called_once()
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:
@@ -731,7 +782,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
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."""
@@ -782,7 +835,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
)
# Verify
ap.persistence_mgr.execute_async.assert_called()
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."""
@@ -825,7 +880,7 @@ class TestPipelineServiceUpdatePipelineExtensions:
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
# Verify - persistence was called
ap.persistence_mgr.execute_async.assert_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."""
@@ -863,10 +918,9 @@ class TestPipelineServiceUpdatePipelineExtensions:
await service.update_pipeline_extensions(WORKSPACE_UUID, 'test-uuid', bound_plugins=[])
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
assert original_pipeline.extensions_preferences['mcp_resources'] == [
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
]
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:
@@ -14,12 +14,14 @@ Source: src/langbot/pkg/api/http/service/provider.py
from __future__ import annotations
import pytest
import sqlalchemy
from uuid import UUID
from contextlib import nullcontext
from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel
from langbot.pkg.entity.persistence.model import ModelProvider, LLMModel, EmbeddingModel, RerankModel
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
@@ -300,7 +302,23 @@ class TestModelProviderServiceCreateProvider:
# Verify - UUID is generated
assert provider_uuid is not None
assert len(provider_uuid) == 36 # UUID format
assert UUID(provider_uuid).version == 4
expected = {
'uuid': provider_uuid,
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Provider',
'requester': 'openai',
'base_url': 'https://api.openai.com',
'api_keys': ['key'],
}
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.table.name == ModelProvider.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
ap.model_mgr.load_provider.assert_awaited_once_with(WORKSPACE_UUID, expected)
ap.model_mgr.cache_provider.assert_awaited_once_with(WORKSPACE_UUID, runtime_provider)
async def test_create_provider_loads_to_runtime(self):
"""Loads provider to runtime model_mgr."""
@@ -362,7 +380,15 @@ class TestModelProviderServiceUpdateProvider:
)
# Verify - reload called
ap.model_mgr.reload_provider.assert_called_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.model_mgr.reload_provider.assert_awaited_once_with(WORKSPACE_UUID, 'existing-uuid')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert 'uuid' not in statement.compile().params
assert statement.compile().params.get('name') == 'Updated Name'
assert statement.whereclause.compare(
sqlalchemy.and_(ModelProvider.uuid == 'existing-uuid', ModelProvider.workspace_uuid == WORKSPACE_UUID)
)
async def test_update_provider_reloads_runtime(self):
"""Reloads provider in runtime after update."""
@@ -432,16 +458,20 @@ class TestModelProviderServiceGetProviderModelCounts:
rerank_result = Mock()
rerank_result.scalar = Mock(return_value=1)
call_count = 0
results_by_table = {
LLMModel.__tablename__: (LLMModel, llm_result),
EmbeddingModel.__tablename__: (EmbeddingModel, embedding_result),
RerankModel.__tablename__: (RerankModel, rerank_result),
}
async def mock_execute(query):
nonlocal call_count
call_count += 1
if call_count == 1:
return llm_result
elif call_count == 2:
return embedding_result
return rerank_result
model_type, result = results_by_table[query.get_final_froms()[0].name]
assert query.whereclause.compare(
sqlalchemy.and_(
model_type.provider_uuid == 'provider-uuid', model_type.workspace_uuid == WORKSPACE_UUID
)
)
return result
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
@@ -602,7 +632,12 @@ class TestModelProviderServiceFindOrCreateProvider:
)
# Verify - name should be parsed from URL (api.example.com)
ap.model_mgr.load_provider.assert_called_once()
ap.model_mgr.load_provider.assert_awaited_once()
assert ap.model_mgr.load_provider.await_args.args[0] == WORKSPACE_UUID
assert ap.model_mgr.load_provider.await_args.args[1]['name'] == 'api.example.com'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_insert
assert statement.compile().params.get('name') == 'api.example.com'
assert result_uuid is not None
@@ -624,6 +659,16 @@ class TestModelProviderServiceUpdateSpaceModelProviderApiKeys:
# Execute
await service.update_space_model_provider_api_keys(WORKSPACE_UUID, 'space-api-key')
ap.persistence_mgr.execute_async.assert_awaited_once()
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.is_update
assert statement.compile().params.get('api_keys') == ['space-api-key']
assert statement.whereclause.compare(
sqlalchemy.and_(
ModelProvider.uuid == '00000000-0000-0000-0000-000000000000',
ModelProvider.workspace_uuid == WORKSPACE_UUID,
)
)
# Verify - update and reload called for Space provider UUID
ap.model_mgr.reload_provider.assert_called_once_with(
WORKSPACE_UUID,
@@ -86,11 +86,19 @@ async def test_reset_preserves_logs_and_isolates_context(context_service):
async with engine.connect() as conn:
assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2
assert (await conn.execute(sa.select(sa.func.count()).select_from(MonitoringSession))).scalar() == 1
assert (
remaining = (
await conn.execute(
sa.select(sa.func.count()).select_from(RunnerState).where(RunnerState.runner_id == 'runner')
sa.select(
RunnerState.workspace_id, RunnerState.bot_id, RunnerState.conversation_id, RunnerState.scope
).where(RunnerState.runner_id == 'runner')
)
).scalar() == 4
).all()
assert set(remaining) == {
('w', 'b2', 'person_u', 'conversation'),
('w2', 'b', 'person_u', 'conversation'),
('w', 'b', 'person_other', 'conversation'),
('w', 'b', 'person_u', 'actor'),
}
page = await store.page_transcript('person_u', bot_id='b', workspace_id='w')
assert not page[0]
assert (await store.page_transcript('person_u', bot_id='b2', workspace_id='w'))[0]
@@ -143,6 +151,6 @@ async def test_reset_starts_new_external_runner_conversation(context_service):
] == 'remote-old'
await service.reset_session_context('w', 'b', 'person_u')
snapshot = await store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation']['external.conversation_id'] == await get_reset_generation(
engine, 'w', 'b', 'person_u'
)
generation = await get_reset_generation(engine, 'w', 'b', 'person_u')
assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'}
assert snapshot['conversation']['external.conversation_id'] == generation
@@ -15,6 +15,7 @@ from __future__ import annotations
from collections import OrderedDict
import json
import sqlalchemy
from urllib.parse import parse_qs, urlsplit
import pytest
@@ -25,6 +26,7 @@ import time
from langbot.pkg.api.http.service.space import SpaceService
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.model import LLMModel, ModelProvider
from langbot.pkg.utils import constants
@@ -157,6 +159,8 @@ class TestSpaceServiceGetUserByEmail:
# Verify
assert result is not None
assert result.user == '[email protected]'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.user == '[email protected]')
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
@@ -478,6 +482,9 @@ class TestSpaceServiceRefreshToken:
# Verify
assert result['access_token'] == 'new_access_token'
mock_session_obj.post.assert_called_once_with(
'https://space.langbot.app/api/v1/accounts/token/refresh', json={'refresh_token': 'old_refresh_token'}
)
async def test_refresh_token_api_error(self):
"""Raises ValueError on API error."""
@@ -662,6 +669,9 @@ class TestSpaceServiceGetUserInfoRaw:
# Verify
assert result['email'] == '[email protected]'
assert result['credits'] == 100
mock_session_obj.get.assert_called_once_with(
'https://space.langbot.app/api/v1/accounts/me', headers={'Authorization': 'Bearer access_token'}
)
async def test_get_user_info_raw_api_error(self):
"""Raises ValueError on API error."""
@@ -737,7 +747,8 @@ class TestSpaceServiceGetUserInfo:
result = await service.get_user_info('[email protected]')
# Verify
assert result['email'] == '[email protected]'
assert result == {'email': '[email protected]', 'credits': 100}
service.get_user_info_raw.assert_awaited_once_with('valid_token')
class TestSpaceServiceGetModels:
@@ -980,6 +991,20 @@ class TestSpaceServiceGetModelSelection:
service.get_model_selection.assert_awaited_once_with('chat')
model_mgr.sync_new_models_from_space.assert_awaited_once()
assert persistence.execute_async.await_count == 2
execution = model_mgr.sync_new_models_from_space.await_args.args[0]
assert (execution.instance_uuid, execution.workspace_uuid, execution.placement_generation) == (
'instance',
'workspace',
1,
)
for call in persistence.execute_async.await_args_list:
assert call.args[0].whereclause.compare(
sqlalchemy.and_(
LLMModel.workspace_uuid == 'workspace',
ModelProvider.requester == 'space-chat-completions',
sqlalchemy.or_(LLMModel.uuid == 'best-upstream-uuid', LLMModel.name == 'best-chat-model'),
)
)
class TestSpaceServiceCreditsCache:
@@ -13,6 +13,7 @@ Source: src/langbot/pkg/api/http/service/user.py
from __future__ import annotations
import asyncio
import pytest
import jwt
import datetime
@@ -28,6 +29,7 @@ from langbot.pkg.api.http.service.user import (
UserService,
)
from langbot.pkg.entity.persistence.user import AccountSource, AccountStatus, User
from langbot.pkg.entity.persistence.workspace import WorkspaceMembership, MembershipRole, MembershipStatus
from langbot.pkg.entity.errors.account import (
AccountEmailMismatchError,
SpaceAccountBindingRequiredError,
@@ -269,6 +271,8 @@ class TestUserServiceGetUserByEmail:
# Verify
assert result is not None
assert result.user == '[email protected]'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.normalized_email == '[email protected]')
async def test_get_user_by_email_not_found(self):
"""Returns None when user not found."""
@@ -327,6 +331,8 @@ class TestUserServiceGetUserBySpaceAccountUuid:
# Verify
assert result is not None
assert result.space_account_uuid == 'space-uuid-123'
statement = ap.persistence_mgr.execute_async.await_args.args[0]
assert statement.whereclause.compare(User.space_account_uuid == 'space-uuid-123')
async def test_get_user_by_space_uuid_not_found(self):
"""Returns None when Space UUID not found."""
@@ -621,6 +627,16 @@ class TestUserServiceSetPassword:
await service.set_password('[email protected]', 'new_password')
def _space_user_write(persistence, operation, credentials):
persistence.execute_async.assert_awaited_once()
statement = persistence.execute_async.await_args.args[0]
assert getattr(statement, f'is_{operation}')
assert statement.table.name == User.__tablename__
values = statement.compile().params
assert {key: values.get(key) for key in credentials} == credentials
return statement, values
class TestUserServiceCreateOrUpdateSpaceUser:
"""Tests for create_or_update_space_user method."""
@@ -657,7 +673,17 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
# Verify - update was called and user returned
ap.persistence_mgr.execute_async.assert_called()
statement, values = _space_user_write(
ap.persistence_mgr,
'update',
{
'space_access_token': 'new_access_token',
'space_refresh_token': 'new_refresh_token',
'space_api_key': 'new_api_key',
},
)
assert statement.whereclause.compare(User.space_account_uuid == 'existing-space-uuid')
assert isinstance(values['space_access_token_expires_at'], datetime.datetime)
assert updated_user.space_account_uuid == 'existing-space-uuid'
async def test_cloud_login_updates_only_the_projected_space_account(self):
@@ -690,7 +716,23 @@ class TestUserServiceCreateOrUpdateSpaceUser:
)
assert result is projected
persistence.execute_async.assert_awaited_once()
statement, values = _space_user_write(
persistence,
'update',
{
'space_access_token': 'access-token',
'space_refresh_token': 'refresh-token',
'space_api_key': 'api-key',
},
)
assert statement.whereclause.compare(
sqlalchemy.and_(
User.uuid == 'projected-space-uuid',
User.space_account_uuid == 'projected-space-uuid',
User.source == AccountSource.CLOUD_PROJECTION.value,
)
)
assert isinstance(values['space_access_token_expires_at'], datetime.datetime)
async def test_cloud_login_never_creates_an_unprojected_account(self):
persistence = SimpleNamespace(execute_async=AsyncMock())
@@ -770,6 +812,20 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify
assert result.space_account_uuid == 'new-space-uuid'
_space_user_write(
ap.persistence_mgr,
'insert',
{
'user': '[email protected]',
'normalized_email': '[email protected]',
'password': '',
'account_type': 'space',
'space_account_uuid': 'new-space-uuid',
'space_access_token': 'access_token',
'space_refresh_token': 'refresh_token',
'space_api_key': 'api_key',
},
)
async def test_create_or_update_space_user_already_initialized_reports_unknown_space_email(self):
"""Unknown Space email is distinct from an existing local Account collision."""
@@ -914,6 +970,17 @@ class TestUserServiceCreateOrUpdateSpaceUser:
# Verify
assert result is not None
assert result.space_account_uuid == 'noexpiry-uuid'
_space_user_write(
ap.persistence_mgr,
'insert',
{
'space_account_uuid': 'noexpiry-uuid',
'space_access_token': 'token',
'space_refresh_token': 'refresh',
'space_api_key': 'key',
'space_access_token_expires_at': None,
},
)
async def test_bind_space_account_rejects_different_email(self):
service = UserService(SimpleNamespace())
@@ -941,13 +1008,23 @@ class TestUserServiceCreateOrUpdateSpaceUser:
async def test_get_workspace_owner_returns_user_object_from_core_connection_result(self):
service = UserService(SimpleNamespace())
owner = _create_mock_user('[email protected]', password='pw')
service.ap.persistence_mgr = SimpleNamespace(
current_session=lambda: SimpleNamespace(scalar=AsyncMock(return_value=owner)),
)
session = SimpleNamespace(scalar=AsyncMock(return_value=owner))
service.ap.persistence_mgr = SimpleNamespace(current_session=lambda: session)
resolved = await service.get_workspace_owner('workspace-1')
assert resolved is owner
session.scalar.assert_awaited_once()
statement = session.scalar.await_args.args[0]
assert statement.whereclause.compare(
sqlalchemy.and_(
WorkspaceMembership.workspace_uuid == 'workspace-1',
WorkspaceMembership.role == MembershipRole.OWNER.value,
WorkspaceMembership.status == MembershipStatus.ACTIVE.value,
User.status == AccountStatus.ACTIVE.value,
)
)
assert statement.get_final_froms()[0].onclause.compare(WorkspaceMembership.account_uuid == User.uuid)
class TestUserServiceLoginCapabilities:
@@ -980,4 +1057,5 @@ class TestUserServiceCreateUserLock:
# Verify lock exists
assert hasattr(service, '_create_user_lock')
assert service._create_user_lock is not None
assert isinstance(service._create_user_lock, asyncio.Lock)
assert not service._create_user_lock.locked()
@@ -125,6 +125,24 @@ class TestWebhookServiceGetWebhooks:
assert result[1]['name'] == 'Webhook 2'
def _assert_webhook_write(ap, operation, expected):
statements = [
call.args[0]
for call in ap.persistence_mgr.execute_async.await_args_list
if getattr(call.args[0], f'is_{operation}')
]
assert len(statements) == 1
statement = statements[0]
assert statement.table.name == Webhook.__tablename__
if operation == 'insert':
values = statement.compile().params
assert {key: values.get(key) for key in expected} == expected
else:
assert statement.whereclause.compare(
sqlalchemy.and_(Webhook.id == expected, Webhook.workspace_uuid == WORKSPACE_UUID)
)
class TestWebhookServiceCreateWebhook:
"""Tests for create_webhook method."""
@@ -187,6 +205,17 @@ class TestWebhookServiceCreateWebhook:
assert result['url'] == 'http://new.example.com/webhook'
assert result['description'] == 'New Description'
assert result['enabled'] is True
_assert_webhook_write(
ap,
'insert',
{
'workspace_uuid': WORKSPACE_UUID,
'name': 'New Webhook',
'url': 'http://new.example.com/webhook',
'description': 'New Description',
'enabled': True,
},
)
async def test_create_webhook_defaults(self):
"""Creates webhook with default description and enabled."""
@@ -236,6 +265,17 @@ class TestWebhookServiceCreateWebhook:
# Verify defaults
assert result['description'] == ''
assert result['enabled'] is True
_assert_webhook_write(
ap,
'insert',
{
'workspace_uuid': WORKSPACE_UUID,
'name': 'Minimal Webhook',
'url': 'http://minimal.example.com',
'description': '',
'enabled': True,
},
)
async def test_create_webhook_disabled(self):
"""Creates webhook with enabled=False."""
@@ -271,6 +311,7 @@ class TestWebhookServiceCreateWebhook:
# Verify
assert result['enabled'] is False
_assert_webhook_write(ap, 'insert', {'enabled': False, 'workspace_uuid': WORKSPACE_UUID})
async def test_create_webhook_rejects_workspace_at_capacity(self):
ap = SimpleNamespace(
@@ -448,7 +489,8 @@ class TestWebhookServiceDeleteWebhook:
await service.delete_webhook(WORKSPACE_UUID, 1)
# Verify
ap.persistence_mgr.execute_async.assert_called_once()
ap.persistence_mgr.execute_async.assert_awaited_once()
_assert_webhook_write(ap, 'delete', 1)
async def test_delete_webhook_nonexistent_id(self):
"""Delete operation completes even for nonexistent ID."""
@@ -463,7 +505,8 @@ class TestWebhookServiceDeleteWebhook:
await service.delete_webhook(WORKSPACE_UUID, 999)
# Verify - still called
ap.persistence_mgr.execute_async.assert_called_once()
ap.persistence_mgr.execute_async.assert_awaited_once()
_assert_webhook_write(ap, 'delete', 999)
class TestWebhookServiceGetEnabledWebhooks:
@@ -190,10 +190,17 @@ async def test_session_capacity_evicts_oldest_session_in_same_workspace():
_bind_session_scope(session, owner_context)
sessions[f'session-{index}'] = session
foreign_task = SimpleNamespace(done=Mock(return_value=False), cancel=Mock())
foreign = {'created_at': -1.0, 'task': foreign_task}
_bind_session_scope(foreign, _request_context(workspace_uuid='workspace-b'))
sessions['foreign'] = foreign
_make_room_for_session(sessions, owner_context)
assert 'session-0' not in sessions
assert len(sessions) == 9
assert len(sessions) == 10
assert sessions['foreign'] is foreign
foreign_task.cancel.assert_not_called()
tasks[0].cancel.assert_called_once_with()
+24 -1
View File
@@ -3,10 +3,12 @@ from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import hashlib
import sqlalchemy
import pytest
from langbot.pkg.api.http.service.apikey import ApiKeyService
from langbot.pkg.entity.persistence.apikey import ApiKeyStatus
from langbot.pkg.entity.persistence.apikey import ApiKey, ApiKeyStatus
@pytest.mark.asyncio
@@ -76,6 +78,27 @@ async def test_verify_api_key_keeps_db_validation_for_lbk_keys(key_exists):
result = await service.verify_api_key('lbk_valid_format')
assert result is key_exists
digest = hashlib.sha256(b'lbk_valid_format').hexdigest()
statements = [call.args[0] for call in persistence_mgr.execute_async.await_args_list]
assert statements[0].whereclause.compare(ApiKey.key_hash == digest)
if key_exists:
assert statements[1].whereclause.compare(
sqlalchemy.and_(
ApiKey.id == 1,
ApiKey.workspace_uuid == 'workspace-a',
ApiKey.key_hash == digest,
)
)
assert statements[2].is_update
assert statements[2].whereclause.compare(
sqlalchemy.and_(
ApiKey.id == 1,
ApiKey.workspace_uuid == 'workspace-a',
ApiKey.key_hash == digest,
ApiKey.status == ApiKeyStatus.ACTIVE.value,
)
)
assert statements[2].compile().params['last_used_at'] is not None
assert persistence_mgr.execute_async.await_count == (3 if key_exists else 1)
if key_exists:
workspace_service.get_execution_binding.assert_awaited_once_with('workspace-a')
@@ -9,6 +9,7 @@ import quart
from quart.datastructures import FileStorage
from langbot.pkg.api.http.controller.groups.files import FilesRouterGroup
from langbot.pkg.api.http.context import RequestContext
pytestmark = pytest.mark.asyncio
@@ -52,6 +53,13 @@ async def test_document_upload_uses_dedicated_scoped_owner_type():
assert response.status_code == 200
assert (await response.get_json())['data']['file_id'] == 'scoped-document-key'
storage_mgr.save_scoped.assert_awaited_once()
context = storage_mgr.save_scoped.await_args.args[0]
assert isinstance(context, RequestContext)
assert context.instance_uuid == 'instance-test'
assert context.workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert context.placement_generation == 3
assert context.account_uuid == 'account-test'
kwargs = storage_mgr.save_scoped.await_args.kwargs
assert kwargs['owner_type'] == 'upload_document'
assert kwargs['owner'] == 'account:account-test'
@@ -62,9 +62,7 @@ async def _create_client(role: str):
application.provider_service = SimpleNamespace(
get_providers=AsyncMock(side_effect=get_providers),
get_provider=AsyncMock(side_effect=get_provider),
get_provider_model_counts=AsyncMock(
return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}
),
get_provider_model_counts=AsyncMock(return_value={'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}),
)
def model_service(list_name: str, get_name: str):
@@ -136,8 +134,15 @@ async def test_default_preserves_secrets_and_explicit_false_redacts_high_permiss
false_data = await false_response.get_json()
default_value = default_data['data'].get('providers', default_data['data'].get('models'))[0]
false_value = false_data['data'].get('providers', false_data['data'].get('models'))[0]
assert '***' not in str(default_value)
expected = (
{**RAW_PROVIDER, 'llm_count': 1, 'embedding_count': 1, 'rerank_count': 1}
if endpoint.endswith('/providers')
else RAW_MODEL
)
assert default_value == expected
assert '***' in str(false_value)
assert 'provider-secret' not in str(false_data)
assert 'model-secret' not in str(false_data)
@pytest.mark.parametrize(
@@ -158,6 +163,8 @@ async def test_explicit_true_does_not_grant_low_permission_reads(endpoint):
data = await response.get_json()
value = data['data'].get('providers', data['data'].get('models'))[0]
assert '***' in str(value)
assert 'provider-secret' not in str(data)
assert 'model-secret' not in str(data)
@pytest.mark.parametrize(
@@ -16,7 +16,7 @@ import asyncio
import logging
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock, call
import pytest
import quart
@@ -65,13 +65,17 @@ def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
)
async def test_recovery_key_generation_is_short_and_unambiguous():
async def test_recovery_key_generation_is_short_and_unambiguous(monkeypatch):
"""Eight random base32 characters balance manual entry and online throttling."""
ap = _make_genkeys_ap(existing_key='')
choice = Mock(side_effect=list('ZYXW9876'))
monkeypatch.setattr('langbot.pkg.core.stages.genkeys.secrets.choice', choice)
await GenKeysStage().run(ap)
key = ap.instance_config.data['system']['recovery_key']
assert key == 'ZYXW9876'
assert choice.call_args_list == [call('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')] * 8
assert len(key) == 8
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
assert ap.instance_config.dump_config.called
@@ -198,7 +202,7 @@ async def test_non_string_recovery_key_does_not_crash():
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
async def test_non_ascii_recovery_key_does_not_crash(key):
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
client, _, _ = await _create_client()
client, reset_password, _ = await _create_client()
resp = await client.post(
'/api/v1/user/reset-password',
@@ -206,6 +210,7 @@ async def test_non_ascii_recovery_key_does_not_crash(key):
)
assert resp.status_code == 403
reset_password.assert_not_awaited()
async def test_quota_exhausted_after_max_attempts():