mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
chore(merge): sync master and preserve monitoring test coverage
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user