test: preserve session and quota contracts when merging master

This commit is contained in:
huanghuoguoguo
2026-10-17 20:14:10 +08:00
138 changed files with 5586 additions and 2497 deletions
+8 -1
View File
@@ -166,12 +166,19 @@ jobs:
'test_pipeline_admission_postgres': 27,
'test_rag_document_identity': 34,
'test_rag_interrupted_ingestion': 22,
'test_migration_branch_convergence': 7,
'test_migration_branch_convergence': 6,
'test_runner_timestamps_postgres': 32,
}
counts = Counter(case.get('classname', '').rsplit('.', 1)[-1] for case in cases)
for module, minimum in expected.items():
assert counts[module] >= minimum, f'{module}: expected at least {minimum} cases, got {counts[module]}'
convergence_cases = {
case.get('name') for case in cases
if case.get('classname', '').rsplit('.', 1)[-1] == 'test_migration_branch_convergence'
}
assert 'test_empty_database_startup_schema_then_real_migrations[postgresql]' in convergence_cases, (
'The fresh-schema migration and idempotency contract must execute'
)
unsuccessful = [
case.get('name') for case in cases
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error'))
@@ -111,7 +111,11 @@ class UserRouterGroup(group.RouterGroup):
async def _() -> str:
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
json_data = await quart.request.json
json_data = await quart.request.get_json(silent=True)
if not isinstance(json_data, dict) or not all(
isinstance(json_data.get(field), str) for field in ('user', 'password')
):
return self.http_status(400, -1, 'Invalid request parameters')
user_email = json_data['user']
password = json_data['password']
+121 -34
View File
@@ -16,6 +16,75 @@ import pytest
pytestmark = pytest.mark.e2e
@pytest.fixture(scope='session')
def e2e_tmpdir(tmp_path_factory):
# Initial registration is global; keep this module independent of other E2E accounts.
return tmp_path_factory.mktemp('langbot_startup')
@pytest.fixture(scope='session')
def e2e_port():
import socket
with socket.socket() as listener:
listener.bind(('127.0.0.1', 0))
return listener.getsockname()[1]
@pytest.fixture(scope='session')
def langbot_process(e2e_tmpdir, e2e_port):
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
from tests.e2e.utils.process_manager import LangBotProcess, find_project_root
create_minimal_config(e2e_tmpdir, port=e2e_port)
create_test_directories(e2e_tmpdir)
process = LangBotProcess(
project_root=find_project_root(),
work_dir=e2e_tmpdir,
port=e2e_port,
timeout=180,
collect_coverage=True,
)
try:
assert process.start(), process.get_logs()
yield process
finally:
process.stop()
coverage_file = process.get_coverage_file()
if coverage_file is not None and coverage_file.exists():
import shutil
shutil.copy(coverage_file, find_project_root() / '.coverage.e2e.startup')
@pytest.fixture(scope='session')
def e2e_db_path(e2e_tmpdir):
return e2e_tmpdir / 'data/langbot.db'
@pytest.fixture
def startup_credentials(e2e_client):
credentials = {'user': '[email protected]', 'password': 'startup-local-test-password'}
state = e2e_client.get('/api/v1/user/init')
assert state.status_code == 200
if not state.json()['data']['initialized']:
response = e2e_client.post('/api/v1/user/init', json=credentials)
assert response.status_code == 200, response.text
assert response.json()['code'] == 0
return credentials
@pytest.fixture
def startup_headers(e2e_client, startup_credentials):
response = e2e_client.post('/api/v1/user/auth', json=startup_credentials)
assert response.status_code == 200, response.text
headers = {'Authorization': f'Bearer {response.json()["data"]["token"]}'}
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers=headers)
assert bootstrap.status_code == 200, bootstrap.text
headers['X-Workspace-Id'] = bootstrap.json()['data']['workspaces'][0]['workspace']['uuid']
return headers
class TestStartupFlow:
"""Tests for LangBot startup process."""
@@ -87,22 +156,21 @@ class TestStartupFlow:
response = e2e_client.get('/api/v1/pipelines')
assert response.status_code == 401
def test_auth_endpoint(self, e2e_client, e2e_tmpdir):
"""Test auth endpoint."""
# First startup may allow initial setup
response = e2e_client.post(
'/api/v1/user/auth',
json={
'user': 'admin',
'password': 'admin',
},
)
def test_auth_endpoint(self, e2e_client, startup_credentials):
"""A controlled local account can sign in and use its token."""
response = e2e_client.post('/api/v1/user/auth', json=startup_credentials)
assert response.status_code == 200, response.text
assert response.json()['code'] == 0
token = response.json()['data']['token']
assert isinstance(token, str) and token
bootstrap = e2e_client.get('/api/v1/workspaces/bootstrap', headers={'Authorization': f'Bearer {token}'})
assert bootstrap.status_code == 200, bootstrap.text
assert bootstrap.json()['data']['workspaces'][0]['membership']['role'] == 'owner'
# Response could be:
# - 200 if auth succeeds
# - 400 if credentials wrong
# - 401 if user not initialized
assert response.status_code in [200, 400, 401]
rejected = e2e_client.post('/api/v1/user/auth', json={**startup_credentials, 'password': 'wrong-password'})
assert rejected.status_code == 200, rejected.text
assert rejected.json()['code'] == 1
assert rejected.json().get('data', {}).get('token') is None
class TestStartupStages:
@@ -127,24 +195,37 @@ class TestStartupStages:
cursor.execute('SELECT version_num FROM alembic_version;')
version = cursor.fetchone()
assert version is not None, 'Migration version should be set'
from pathlib import Path
from alembic.script import ScriptDirectory
migrations = Path(__file__).parents[2] / 'src/langbot/pkg/persistence/alembic'
assert version == (ScriptDirectory(str(migrations)).get_current_head(),)
conn.close()
def test_http_controller_initialized(self, e2e_client):
"""Verify HTTP controller was initialized."""
# Multiple endpoints should be available
endpoints = [
'/api/v1/system/info',
'/api/v1/pipelines',
'/api/v1/provider/providers',
'/api/v1/platform/bots',
]
def test_http_controller_initialized(self, e2e_client, startup_headers):
"""Authenticated business endpoints return their documented collections."""
info = e2e_client.get('/api/v1/system/info', headers=startup_headers)
assert info.status_code == 200
assert isinstance(info.json()['data']['version'], str)
for endpoint in endpoints:
response = e2e_client.get(endpoint)
# Should get a real route response, even if auth is required.
assert response.status_code in [200, 401, 403], f'{endpoint} should be registered'
for endpoint, key in [
('/api/v1/pipelines', 'pipelines'),
('/api/v1/provider/providers', 'providers'),
('/api/v1/platform/bots', 'bots'),
]:
response = e2e_client.get(endpoint, headers=startup_headers)
assert response.status_code == 200, (endpoint, response.text)
payload = response.json()
assert payload['code'] == 0
assert isinstance(payload['data'][key], list)
if key == 'providers':
assert [(item['name'], item['requester'], item['api_keys']) for item in payload['data'][key]] == [
('LangBot Models', 'space-chat-completions', [])
]
else:
assert payload['data'][key] == []
class TestMinimalStartupNoLLM:
@@ -155,8 +236,14 @@ class TestMinimalStartupNoLLM:
response = e2e_client.get('/api/v1/system/info')
assert response.status_code == 200
def test_pipeline_metadata_available(self, e2e_client):
"""Pipeline metadata endpoint should work without LLM."""
# Requires auth, but endpoint should exist
response = e2e_client.get('/api/v1/pipelines/_/metadata')
assert response.status_code in [200, 401] # Not 404 or 500
def test_pipeline_metadata_available(self, e2e_client, startup_headers):
"""All pipeline configuration groups remain readable without an LLM."""
response = e2e_client.get('/api/v1/pipelines/_/metadata', headers=startup_headers)
assert response.status_code == 200, response.text
payload = response.json()
assert payload['code'] == 0
configs = payload['data']['configs']
assert [config['name'] for config in configs] == ['trigger', 'safety', 'ai', 'output']
ai = configs[2]
runner = next(stage for stage in ai['stages'] if stage['name'] == 'runner')
assert any(item['name'] == 'id' for item in runner['config'])
+68 -9
View File
@@ -125,7 +125,7 @@ def fake_bot_app():
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
)
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
app.bot_service.create_bot = AsyncMock(return_value='new-bot-uuid')
app.bot_service.update_bot = AsyncMock(return_value={})
app.bot_service.delete_bot = AsyncMock()
app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1))
@@ -179,6 +179,11 @@ def fake_bot_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_bot_app):
fake_bot_app.bot_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_bot_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -201,11 +206,17 @@ class TestBotEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bots' in data['data']
assert data['data']['bots'] == [
{
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
}
]
@pytest.mark.asyncio
async def test_create_bot_success(self, quart_test_client):
async def test_create_bot_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots creates new bot."""
response = await quart_test_client.post(
'/api/v1/platform/bots',
@@ -218,8 +229,17 @@ class TestBotEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_bot_app.bot_service.create_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == (
{'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'},
)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-bot-uuid'}
@pytest.mark.asyncio
async def test_get_single_bot_success(self, quart_test_client):
async def test_get_single_bot_success(self, quart_test_client, fake_bot_app):
"""GET /api/v1/platform/bots/{uuid} returns bot with runtime info."""
response = await quart_test_client.get(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -230,8 +250,21 @@ class TestBotEndpoints:
assert data['code'] == 0
assert 'bot' in data['data']
called = fake_bot_app.bot_service.get_runtime_bot_info
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['bot'] == {
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
@pytest.mark.asyncio
async def test_update_bot_success(self, quart_test_client):
async def test_update_bot_success(self, quart_test_client, fake_bot_app):
"""PUT /api/v1/platform/bots/{uuid} updates bot."""
response = await quart_test_client.put(
'/api/v1/platform/bots/test-bot-uuid',
@@ -243,8 +276,14 @@ class TestBotEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_bot_app.bot_service.update_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_bot_success(self, quart_test_client):
async def test_delete_bot_success(self, quart_test_client, fake_bot_app):
"""DELETE /api/v1/platform/bots/{uuid} deletes bot."""
response = await quart_test_client.delete(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -252,13 +291,20 @@ class TestBotEndpoints:
assert response.status_code == 200
called = fake_bot_app.bot_service.delete_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotLogsEndpoint:
"""Tests for bot logs endpoint."""
@pytest.mark.asyncio
async def test_get_bot_logs_success(self, quart_test_client):
async def test_get_bot_logs_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots/{uuid}/logs returns logs."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
@@ -272,6 +318,13 @@ class TestBotLogsEndpoint:
assert 'logs' in data['data']
assert 'total_count' in data['data']
called = fake_bot_app.bot_service.list_event_logs
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10)
assert called.await_args.kwargs == {}
assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1}
@pytest.mark.asyncio
async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app):
access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
@@ -366,7 +419,7 @@ class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@pytest.mark.asyncio
async def test_send_message_success(self, quart_test_client):
async def test_send_message_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots/{uuid}/send_message sends message."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
@@ -383,6 +436,12 @@ class TestBotSendMessageEndpoint:
assert data['code'] == 0
assert data['data']['sent'] is True
called = fake_bot_app.bot_service.send_message
called.assert_awaited_once()
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}])
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_send_message_missing_target_type(self, quart_test_client):
"""POST send_message without target_type returns 400."""
+92 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint:
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
body = await response.get_data(as_text=True)
assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body
assert 'baseUrl: "http://localhost"' in body
assert '__LANGBOT_' not in body
@pytest.mark.asyncio
@pytest.mark.parametrize(
('query', 'expected_base'),
@@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint:
assert response.status_code == 200
assert 'image/webp' in response.content_type
body = await response.get_data()
assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes()
assert body[:4] == b'RIFF' and body[8:12] == b'WEBP'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedTurnstileVerifyEndpoint:
"""Tests for Turnstile verification endpoint."""
@pytest.mark.asyncio
async def test_turnstile_verify_no_secret(self, quart_test_client):
async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch):
"""POST turnstile verify without secret returns dummy token."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25)
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
)
@@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint:
assert data['code'] == 0
assert 'token' in data['data']
assert data['data']['token'] == '1720000000.25.dummy'
@pytest.mark.asyncio
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
"""POST turnstile verify with invalid UUID returns 400."""
@@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint:
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
async def test_get_messages_group_success(self, quart_test_client, fake_embed_app):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
@@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}]
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_requires_session_id(self, quart_test_client):
"""GET messages without a client session identifier returns 400."""
@@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint:
async def test_get_messages_invalid_session_type(self, quart_test_client):
"""GET messages with invalid session_type returns 400."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid',
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
assert (await response.get_json())['msg'] == 'session_type must be person or group'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedResetEndpoint:
@@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint:
"""Tests for feedback submission endpoint."""
@pytest.mark.asyncio
async def test_submit_feedback_like(self, quart_test_client):
async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=1 (like) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint:
assert data['code'] == 0
assert 'feedback_id' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 1,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_dislike(self, quart_test_client):
async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=2 (dislike) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 2,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_invalid_type(self, quart_test_client):
"""POST feedback with invalid type returns 400."""
@@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint:
"""The public socket authenticates before resolving shared runtime state."""
@pytest.mark.asyncio
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
authenticated = False
original_authenticate = embed.EmbedRouterGroup._authenticate_websocket
original_add = embed.ws_connection_manager.add_connection
proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot
async def authenticate(router, runtime_bot):
nonlocal authenticated
await original_authenticate(router, runtime_bot)
authenticated = True
async def get_proxy(*_args, **_kwargs):
assert authenticated, 'Runtime lookup must follow successful authentication'
return proxy.return_value
async def add_connection(*args, **kwargs):
assert authenticated, 'Connection registration must follow successful authentication'
return await original_add(*args, **kwargs)
monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate)
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
+92 -11
View File
@@ -117,13 +117,13 @@ def fake_knowledge_app():
'engine_plugin_id': 'test/engine',
}
)
app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'})
app.knowledge_service.create_knowledge_base = AsyncMock(return_value='new-kb-uuid')
app.knowledge_service.update_knowledge_base = AsyncMock(return_value={})
app.knowledge_service.delete_knowledge_base = AsyncMock()
app.knowledge_service.get_files_by_knowledge_base = AsyncMock(
return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
)
app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'})
app.knowledge_service.store_file = AsyncMock(return_value='test-task-id')
app.knowledge_service.delete_file = AsyncMock()
app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}])
@@ -133,6 +133,11 @@ def fake_knowledge_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_knowledge_app):
fake_knowledge_app.knowledge_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_knowledge_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -148,7 +153,7 @@ class TestKnowledgeBaseEndpoints:
"""Tests for /api/v1/knowledge/bases endpoints."""
@pytest.mark.asyncio
async def test_get_knowledge_bases_success(self, quart_test_client):
async def test_get_knowledge_bases_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases returns knowledge base list."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}
@@ -160,8 +165,24 @@ class TestKnowledgeBaseEndpoints:
assert 'data' in data
assert 'bases' in data['data']
called = fake_knowledge_app.knowledge_service.get_knowledge_bases
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['bases'] == [
{
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
}
]
@pytest.mark.asyncio
async def test_create_knowledge_base_success(self, quart_test_client):
async def test_create_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases creates new knowledge base."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases',
@@ -174,8 +195,15 @@ class TestKnowledgeBaseEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_knowledge_app.knowledge_service.create_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-kb-uuid'}
@pytest.mark.asyncio
async def test_get_single_knowledge_base_success(self, quart_test_client):
async def test_get_single_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases/{uuid} returns knowledge base."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -186,8 +214,20 @@ class TestKnowledgeBaseEndpoints:
assert data['code'] == 0
assert 'base' in data['data']
called = fake_knowledge_app.knowledge_service.get_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['base'] == {
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
}
@pytest.mark.asyncio
async def test_update_knowledge_base_success(self, quart_test_client):
async def test_update_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""PUT /api/v1/knowledge/bases/{uuid} updates knowledge base."""
response = await quart_test_client.put(
'/api/v1/knowledge/bases/test-kb-uuid',
@@ -199,8 +239,14 @@ class TestKnowledgeBaseEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_knowledge_app.knowledge_service.update_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_knowledge_base_success(self, quart_test_client):
async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -208,13 +254,20 @@ class TestKnowledgeBaseEndpoints:
assert response.status_code == 200
called = fake_knowledge_app.knowledge_service.delete_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseFilesEndpoints:
"""Tests for knowledge base files endpoints."""
@pytest.mark.asyncio
async def test_get_files_success(self, quart_test_client):
async def test_get_files_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases/{uuid}/files returns files."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'}
@@ -225,8 +278,15 @@ class TestKnowledgeBaseFilesEndpoints:
assert data['code'] == 0
assert 'files' in data['data']
called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {}
assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
@pytest.mark.asyncio
async def test_add_file_to_knowledge_base(self, quart_test_client):
async def test_add_file_to_knowledge_base(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases/{uuid}/files adds file."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/files',
@@ -239,8 +299,15 @@ class TestKnowledgeBaseFilesEndpoints:
assert data['code'] == 0
assert 'task_id' in data['data']
called = fake_knowledge_app.knowledge_service.store_file
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id')
assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'}
assert data['data'] == {'task_id': 'test-task-id'}
@pytest.mark.asyncio
async def test_delete_file_from_knowledge_base(self, quart_test_client):
async def test_delete_file_from_knowledge_base(self, quart_test_client, fake_knowledge_app):
"""DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -248,13 +315,20 @@ class TestKnowledgeBaseFilesEndpoints:
assert response.status_code == 200
called = fake_knowledge_app.knowledge_service.delete_file
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid')
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseRetrieveEndpoint:
"""Tests for knowledge base retrieval endpoint."""
@pytest.mark.asyncio
async def test_retrieve_knowledge_success(self, quart_test_client):
async def test_retrieve_knowledge_success(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases/{uuid}/retrieve."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve',
@@ -267,6 +341,13 @@ class TestKnowledgeBaseRetrieveEndpoint:
assert data['code'] == 0
assert 'results' in data['data']
called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5})
assert called.await_args.kwargs == {}
assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}]
@pytest.mark.asyncio
async def test_retrieve_without_query_returns_error(self, quart_test_client):
"""POST retrieve without query returns 400."""
+129 -6
View File
@@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations
import csv
import io
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock, patch
from types import SimpleNamespace
@@ -122,17 +125,24 @@ def fake_monitoring_app():
)
app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10})
app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12))
app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}])
app.monitoring_service.export_messages = AsyncMock(
return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}]
)
app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}])
app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}])
app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}])
app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}])
app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}])
app.monitoring_service._escape_csv_field = Mock(return_value='escaped')
app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field
return app
@pytest.fixture(autouse=True)
def reset_monitoring_calls(fake_monitoring_app):
fake_monitoring_app.monitoring_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_monitoring_app, http_controller_cls):
"""Create Quart test client (module scope)."""
@@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
@pytest.mark.asyncio
async def test_viewer_can_read_monitoring_but_cannot_export(
self,
@@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint:
assert data['code'] == 0
assert 'messages' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'messages': [{'id': 'msg-1', 'content': 'test'}],
'total': 100,
'limit': 100,
'offset': 0,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringLLMCallsEndpoint:
@@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringEmbeddingCallsEndpoint:
@@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringSessionsEndpoint:
@@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0}
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
@@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringAllDataEndpoint:
@@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint:
assert 'overview' in data['data']
assert data['data']['traffic'] == traffic
assert data['code'] == 0
assert data['data']['overview'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}]
assert data['data']['llmCalls'] == [{'id': 'llm-1'}]
assert data['data']['toolCalls'] == [{'id': 'tool-1'}]
assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}]
assert data['data']['sessions'] == [{'session_id': 'sess-1'}]
assert data['data']['errors'] == [{'id': 'err-1'}]
assert data['data']['totalCount'] == {
'messages': 100,
'llmCalls': 50,
'toolCalls': 5,
'embeddingCalls': 10,
'sessions': 20,
'errors': 2,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringDetailsEndpoints:
@@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'found': True, 'message_id': 'msg-1'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints:
@@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'like_count': 10}
@pytest.mark.asyncio
async def test_get_feedback_list(self, quart_test_client):
"""GET /api/v1/monitoring/feedback."""
@@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringExportEndpoint:
"""Tests for /api/v1/monitoring/export endpoint."""
@pytest.mark.asyncio
async def test_export_messages(self, quart_test_client):
async def test_export_messages(self, quart_test_client, fake_monitoring_app):
"""GET export?type=messages returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
@@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'text/csv' in response.content_type
assert 'monitoring-messages-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'msg-1'
called = fake_monitoring_app.monitoring_service.export_messages
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
assert records[0]['message_content'] == 'hello, "quoted"'
@pytest.mark.asyncio
async def test_export_llm_calls(self, quart_test_client):
async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app):
"""GET export?type=llm-calls returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'}
@@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-llm-calls-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'llm-1'
called = fake_monitoring_app.monitoring_service.export_llm_calls
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_sessions(self, quart_test_client):
async def test_export_sessions(self, quart_test_client, fake_monitoring_app):
"""GET export?type=sessions returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
@@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-sessions-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['session_id'] == 'sess-1'
called = fake_monitoring_app.monitoring_service.export_sessions
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_feedback(self, quart_test_client):
async def test_export_feedback(self, quart_test_client, fake_monitoring_app):
"""GET export?type=feedback returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-feedback-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'fb-1'
called = fake_monitoring_app.monitoring_service.export_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
+132 -14
View File
@@ -128,10 +128,10 @@ def fake_pipeline_app():
'config': {},
}
)
app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'})
app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid')
app.pipeline_service.update_pipeline = AsyncMock(return_value={})
app.pipeline_service.delete_pipeline = AsyncMock()
app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'})
app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid')
# Bot service
app.bot_service = Mock()
@@ -161,6 +161,11 @@ def fake_pipeline_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_pipeline_app):
fake_pipeline_app.pipeline_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_pipeline_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -179,7 +184,7 @@ class TestPipelineMetadataEndpoint:
"""Tests for /api/v1/pipelines/_/metadata endpoint."""
@pytest.mark.asyncio
async def test_get_pipeline_metadata_success(self, quart_test_client):
async def test_get_pipeline_metadata_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/_/metadata returns metadata list."""
response = await quart_test_client.get(
'/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'}
@@ -191,6 +196,13 @@ class TestPipelineMetadataEndpoint:
assert 'data' in data
assert isinstance(data['data'], dict)
called = fake_pipeline_app.pipeline_service.get_pipeline_metadata
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {}
assert data['data'] == {'configs': [{'name': 'trigger', 'stages': []}, {'name': 'ai', 'stages': []}]}
@pytest.mark.asyncio
async def test_get_pipeline_metadata_requires_auth(self, quart_test_client):
"""Pipeline metadata endpoint requires authentication."""
@@ -203,7 +215,7 @@ class TestPipelinesListEndpoint:
"""Tests for /api/v1/pipelines endpoint."""
@pytest.mark.asyncio
async def test_get_pipelines_success(self, quart_test_client):
async def test_get_pipelines_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines returns pipeline list."""
response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'})
@@ -212,24 +224,46 @@ class TestPipelinesListEndpoint:
assert data['code'] == 0
assert 'data' in data
called = fake_pipeline_app.pipeline_service.get_pipelines
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('created_at', 'DESC')
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['pipelines'] == [
{
'uuid': 'test-pipeline-uuid',
'name': 'Test Pipeline',
'description': 'Test description',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
'is_default': False,
}
]
@pytest.mark.asyncio
async def test_get_pipelines_with_sort_param(self, quart_test_client):
async def test_get_pipelines_with_sort_param(self, quart_test_client, fake_pipeline_app):
"""GET pipelines with sort parameter."""
response = await quart_test_client.get(
'/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'}
'/api/v1/pipelines?sort_by=name&sort_order=ASC', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.get_pipelines
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('name', 'ASC')
assert called.await_args.kwargs == {'include_secret': True}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelinesCRUDEndpoints:
"""Tests for pipeline CRUD operations."""
@pytest.mark.asyncio
async def test_get_single_pipeline_success(self, quart_test_client):
async def test_get_single_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/{uuid} returns pipeline."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -240,8 +274,15 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'data' in data
called = fake_pipeline_app.pipeline_service.get_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}}
@pytest.mark.asyncio
async def test_create_pipeline_success(self, quart_test_client):
async def test_create_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines creates new pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines',
@@ -254,6 +295,13 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_pipeline_app.pipeline_service.create_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},)
assert called.await_args.kwargs == {'default': False}
assert data['data'] == {'uuid': 'new-pipeline-uuid'}
@pytest.mark.asyncio
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
@@ -271,7 +319,7 @@ class TestPipelinesCRUDEndpoints:
assert call.args[1]['is_default'] is True
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
response = await quart_test_client.put(
'/api/v1/pipelines/test-pipeline-uuid',
@@ -283,8 +331,14 @@ class TestPipelinesCRUDEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.update_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_pipeline_success(self, quart_test_client):
async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""DELETE /api/v1/pipelines/{uuid} deletes pipeline."""
response = await quart_test_client.delete(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -294,8 +348,14 @@ class TestPipelinesCRUDEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.delete_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_copy_pipeline_success(self, quart_test_client):
async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines/{uuid}/copy copies pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'}
@@ -306,14 +366,24 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_pipeline_app.pipeline_service.copy_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'copied-pipeline-uuid'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineExtensionsEndpoint:
"""Tests for pipeline extensions."""
@pytest.mark.asyncio
async def test_get_extensions(self, quart_test_client):
async def test_get_extensions(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/{uuid}/extensions."""
fake_pipeline_app.plugin_connector.list_plugins.return_value = [{'name': 'extension-plugin'}]
fake_pipeline_app.mcp_service.get_mcp_servers.return_value = [{'uuid': 'extension-mcp'}]
fake_pipeline_app.skill_service.list_skills.return_value = [{'name': 'extension-skill'}]
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
)
@@ -323,6 +393,21 @@ class TestPipelineExtensionsEndpoint:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.get_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
assert data['data']['available_plugins'] == [{'name': 'extension-plugin'}]
assert data['data']['available_mcp_servers'] == [{'uuid': 'extension-mcp'}]
assert data['data']['available_skills'] == [{'name': 'extension-skill'}]
assert data['data']['bound_plugins'] == []
assert data['data']['bound_mcp_servers'] == []
assert data['data']['bound_skills'] == []
assert data['data']['enable_all_plugins'] is False
assert data['data']['enable_all_mcp_servers'] is False
assert data['data']['enable_all_skills'] is False
@pytest.mark.asyncio
async def test_get_extensions_redacts_available_plugin_secrets(
self,
@@ -377,7 +462,31 @@ class TestPipelineExtensionsEndpoint:
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineDashboardWebSocket:
@pytest.mark.asyncio
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
authenticated = False
original_authenticate = chat.WebSocketChatRouterGroup._authenticate_websocket
original_add = chat.ws_connection_manager.add_connection
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
async def authenticate(router):
nonlocal authenticated
result = await original_authenticate(router)
authenticated = True
return result
async def get_proxy(*_args, **_kwargs):
assert authenticated, 'Runtime lookup must follow authentication'
return proxy.return_value
async def add_connection(*args, **kwargs):
assert authenticated, 'Connection registration must follow authentication'
return await original_add(*args, **kwargs)
monkeypatch.setattr(chat.WebSocketChatRouterGroup, '_authenticate_websocket', authenticate)
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
@@ -403,7 +512,13 @@ class TestPipelineDashboardWebSocket:
fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
@pytest.mark.asyncio
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client, fake_pipeline_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
proxy.reset_mock()
add_connection = AsyncMock(wraps=chat.ws_connection_manager.add_connection)
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
@@ -412,6 +527,9 @@ class TestPipelineDashboardWebSocket:
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
proxy.assert_not_awaited()
add_connection.assert_not_awaited()
@pytest.mark.asyncio
async def test_websocket_rechecks_revocable_membership_before_each_message(
self,
+106 -16
View File
@@ -109,21 +109,23 @@ def fake_provider_app():
app.llm_model_service = Mock()
app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}])
app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'})
app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'})
app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid')
app.llm_model_service.update_llm_model = AsyncMock(return_value={})
app.llm_model_service.delete_llm_model = AsyncMock()
app.llm_model_service.test_llm_model = AsyncMock()
# Embedding model service
app.embedding_models_service = Mock()
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'})
app.embedding_models_service.get_embedding_models = AsyncMock(
return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}]
)
app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid')
app.embedding_models_service.test_embedding_model = AsyncMock()
# Rerank model service
app.rerank_models_service = Mock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'})
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid')
app.rerank_models_service.test_rerank_model = AsyncMock()
# Model manager
@@ -134,6 +136,14 @@ def fake_provider_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_provider_app):
fake_provider_app.provider_service.reset_mock()
fake_provider_app.llm_model_service.reset_mock()
fake_provider_app.embedding_models_service.reset_mock()
fake_provider_app.rerank_models_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_provider_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -206,7 +216,7 @@ class TestProviderEndpoints:
assert data['data']['uuid'] == 'new-provider-uuid'
@pytest.mark.asyncio
async def test_update_provider_success(self, quart_test_client):
async def test_update_provider_success(self, quart_test_client, fake_provider_app):
"""PUT /api/v1/provider/providers/{uuid} updates provider."""
response = await quart_test_client.put(
'/api/v1/provider/providers/test-provider-uuid',
@@ -218,8 +228,14 @@ class TestProviderEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_provider_app.provider_service.update_provider
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_provider_success(self, quart_test_client):
async def test_delete_provider_success(self, quart_test_client, fake_provider_app):
"""DELETE /api/v1/provider/providers/{uuid} deletes provider."""
response = await quart_test_client.delete(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -227,8 +243,15 @@ class TestProviderEndpoints:
assert response.status_code == 200
called = fake_provider_app.provider_service.delete_provider
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_get_provider_includes_model_counts(self, quart_test_client):
async def test_get_provider_includes_model_counts(self, quart_test_client, fake_provider_app):
"""GET provider response includes model counts."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -243,13 +266,24 @@ class TestProviderEndpoints:
assert 'embedding_count' in provider_data
assert 'rerank_count' in provider_data
called = fake_provider_app.provider_service.get_provider_model_counts
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid',)
assert called.await_args.kwargs == {}
assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == {
'llm_count': 2,
'embedding_count': 1,
'rerank_count': 0,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestModelEndpoints:
"""Tests for /api/v1/provider/models endpoints."""
@pytest.mark.asyncio
async def test_get_llm_models_success(self, quart_test_client):
async def test_get_llm_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/llm returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'}
@@ -260,8 +294,15 @@ class TestModelEndpoints:
assert data['code'] == 0
assert 'data' in data
called = fake_provider_app.llm_model_service.get_llm_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]
@pytest.mark.asyncio
async def test_get_single_llm_model_success(self, quart_test_client):
async def test_get_single_llm_model_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/llm/{uuid} returns model."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -271,8 +312,15 @@ class TestModelEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_provider_app.llm_model_service.get_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-model-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'}
@pytest.mark.asyncio
async def test_create_llm_model_success(self, quart_test_client):
async def test_create_llm_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/llm creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/llm',
@@ -285,8 +333,15 @@ class TestModelEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.llm_model_service.create_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-model-uuid'}
@pytest.mark.asyncio
async def test_delete_llm_model_success(self, quart_test_client):
async def test_delete_llm_model_success(self, quart_test_client, fake_provider_app):
"""DELETE /api/v1/provider/models/llm/{uuid} deletes model."""
response = await quart_test_client.delete(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -294,6 +349,13 @@ class TestModelEndpoints:
assert response.status_code == 200
called = fake_provider_app.llm_model_service.delete_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-model-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('service_name', 'method_name', 'path'),
@@ -337,7 +399,7 @@ class TestEmbeddingModelEndpoints:
"""Tests for /api/v1/provider/models/embedding endpoints."""
@pytest.mark.asyncio
async def test_get_embedding_models_success(self, quart_test_client):
async def test_get_embedding_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/embedding returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'}
@@ -348,8 +410,15 @@ class TestEmbeddingModelEndpoints:
assert data['code'] == 0
assert 'models' in data['data']
called = fake_provider_app.embedding_models_service.get_embedding_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'embedding-model', 'name': 'Embedding'}]
@pytest.mark.asyncio
async def test_create_embedding_model_success(self, quart_test_client):
async def test_create_embedding_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/embedding creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/embedding',
@@ -362,13 +431,20 @@ class TestEmbeddingModelEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.embedding_models_service.create_embedding_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-embedding-uuid'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRerankModelEndpoints:
"""Tests for /api/v1/provider/models/rerank endpoints."""
@pytest.mark.asyncio
async def test_get_rerank_models_success(self, quart_test_client):
async def test_get_rerank_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/rerank returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'}
@@ -379,8 +455,15 @@ class TestRerankModelEndpoints:
assert data['code'] == 0
assert 'models' in data['data']
called = fake_provider_app.rerank_models_service.get_rerank_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'rerank-model', 'name': 'Rerank'}]
@pytest.mark.asyncio
async def test_create_rerank_model_success(self, quart_test_client):
async def test_create_rerank_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/rerank creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/rerank',
@@ -392,3 +475,10 @@ class TestRerankModelEndpoints:
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.rerank_models_service.create_rerank_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-rerank-uuid'}
+31 -32
View File
@@ -164,16 +164,6 @@ class TestHealthEndpoint:
data = await response.get_json()
assert data == {'code': 0, 'msg': 'ok', 'resources': {}}
@pytest.mark.asyncio
async def test_healthz_no_auth_required(self, quart_test_client):
"""
/healthz doesn't require authentication.
Tests that AuthType.NONE endpoints work without headers.
"""
response = await quart_test_client.get('/healthz')
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestSystemEndpoint:
@@ -241,33 +231,42 @@ class TestInvalidPayload:
"""Tests for error handling with invalid payloads."""
@pytest.mark.asyncio
async def test_missing_json_body(self, quart_test_client):
"""
POST endpoint without JSON body handles gracefully.
"""
# /api/v1/user/auth expects JSON with 'user' and 'password'
async def test_missing_json_body(self, quart_test_client, fake_api_app):
"""A missing login body is rejected before authentication."""
response = await quart_test_client.post('/api/v1/user/auth')
# Should return error (500, 400, or 401) with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
# Verify error response has expected structure
assert 'code' in data
assert 'msg' in data
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
fake_api_app.user_service.authenticate.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_json_structure(self, quart_test_client):
"""
POST with wrong JSON structure returns stable error.
"""
response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'})
@pytest.mark.parametrize(
'payload',
[
{'wrong_field': 'value'},
[],
{'user': None, 'password': 'secret'},
{'user': '[email protected]', 'password': 42},
],
)
async def test_invalid_json_structure(self, quart_test_client, fake_api_app, payload):
"""Malformed credential fields never reach the authentication service."""
response = await quart_test_client.post('/api/v1/user/auth', json=payload)
# Should return error with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
assert 'code' in data
assert 'msg' in data
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
fake_api_app.user_service.authenticate.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('password', ['', 'existing-password'])
async def test_string_credentials_reach_authentication(self, quart_test_client, fake_api_app, password):
response = await quart_test_client.post(
'/api/v1/user/auth', json={'user': '[email protected]', 'password': password}
)
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'fake_token'
fake_api_app.user_service.authenticate.assert_awaited_once_with('[email protected]', password, None)
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -405,7 +405,7 @@ async def test_support_admin_persisted_expiry_and_revocation_are_enforced(suppor
async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api):
app, _client, _engine, private_key = support_admin_api
app, _client, engine, private_key = support_admin_api
launch = await _issue_support_token(app, private_key)
captured_contexts = []
@@ -414,7 +414,6 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
del data
captured_contexts.append(connection.execution_context)
await connection.send_queue.put({'type': 'handled'})
connection.is_active = False
app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1')))
app.platform_mgr = SimpleNamespace(
@@ -439,8 +438,17 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
handled = json.loads(await websocket.receive())
assert handled['type'] == 'handled'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(SupportAdminTemporarySession)
.where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash'])
.values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None))
)
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'after revoke'}]}))
rejected = json.loads(await websocket.receive())
assert rejected == {'type': 'error', 'message': 'Unauthorized'}
assert captured_contexts
assert len(captured_contexts) == 1
principal = captured_contexts[0].trigger_principal
assert principal is not None
assert principal.principal_type == PrincipalType.SUPPORT_ADMIN
+25 -34
View File
@@ -234,39 +234,6 @@ async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
assert all(payload['code'] == 1 for payload in payloads)
@pytest.mark.asyncio
async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
headers={'Origin': 'https://irrelevant.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {
'webui_url': '',
'webhook_prefix': 'https://langbot.example/base/path',
}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
headers={'Host': 'attacker.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api
@@ -423,6 +390,9 @@ async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oa
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['code'] == 0
assert payload['data']['token'] == 'space-login-token'
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
@@ -557,6 +527,7 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.user_service.consume_space_oauth_state_details.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
@@ -578,6 +549,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
expected_workspace_uuid=WORKSPACE_UUID,
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.user_service.consume_space_oauth_state_details.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
@@ -591,7 +563,24 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
status='active',
)
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
events = []
async def reconcile(_workspaces):
events.append('reconcile')
async def get_account(_account_uuid):
events.append('account')
return projected_account
access = application.workspace_collaboration_service.resolve_account_workspace.return_value
async def resolve_access(*_args, **_kwargs):
events.append('access')
return access
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock(side_effect=reconcile))
application.user_service.get_user_by_uuid.side_effect = get_account
application.workspace_collaboration_service.resolve_account_workspace.side_effect = resolve_access
response = await client.post(
'/api/v1/user/space/callback',
@@ -605,3 +594,5 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
assert events == ['reconcile', 'account', 'access']
+6
View File
@@ -623,6 +623,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
)
assert expired_capabilities.status_code == 401
await application.persistence_mgr.execute_async(
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.uuid == created['uuid']).values(expires_at=None)
)
valid_again = await client.get('/api/v1/system/context', headers={'X-API-Key': created['key']})
assert valid_again.status_code == 200
revoke_response = await client.delete(
f'/api/v1/apikeys/{created["id"]}',
headers=_auth(owner_token, workspace_uuid),
@@ -298,12 +298,13 @@ async def test_device_replacement_expiry_and_idempotent_cancel(auth):
async def test_device_accepts_issuer_iso_expiry(auth):
from datetime import datetime, timezone
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
expected_expiry = int(time.time()) + 600
expires = datetime.fromtimestamp(expected_expiry, timezone.utc).isoformat().replace('+00:00', 'Z')
auth._post = AsyncMock(
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
)
result = await auth.start(context(), 'p')
assert time.time() < result['expires_at'] < time.time() + 900
assert result['expires_at'] == expected_expiry
@pytest.mark.asyncio
@@ -108,6 +108,18 @@ async def _seed_common(engine):
base_url='https://fixture.invalid',
api_keys=['fixture-key'],
)
await _insert(
conn,
'llm_models',
uuid='model-1',
workspace_uuid=WORKSPACE,
name='Fixture model',
provider_uuid='provider-1',
abilities=['vision'],
extra_args={'sentinel': 'preserve-model'},
prefered_ranking=7,
reasoning_config={'level': 'provider_default'},
)
await _insert(
conn,
'legacy_pipelines',
@@ -261,6 +273,31 @@ async def _assert_merged_schema_and_data(engine, source):
assert await conn.scalar(sa.text('SELECT password FROM users')) == 'hash'
assert await conn.scalar(sa.text('SELECT COUNT(*) FROM workspace_memberships')) == 1
assert await conn.scalar(sa.text('SELECT name FROM model_providers')) == 'Fixture provider'
models = await conn.run_sync(lambda sync: sa.Table('llm_models', sa.MetaData(), autoload_with=sync))
model = (
await conn.execute(
sa.select(
models.c.name,
models.c.abilities,
models.c.extra_args,
models.c.prefered_ranking,
models.c.reasoning_config,
)
)
).one()
assert tuple(model) == (
'Fixture model',
['vision'],
{'sentinel': 'preserve-model'},
7,
{'level': 'provider_default'},
)
reasoning_column = await conn.run_sync(
lambda sync: next(
column for column in sa.inspect(sync).get_columns('llm_models') if column['name'] == 'reasoning_config'
)
)
assert reasoning_column['nullable'] is False
assert await conn.scalar(sa.text('SELECT description FROM legacy_pipelines')) == 'preserve pipeline'
assert (
await conn.execute(sa.text('SELECT message_content FROM monitoring_messages ORDER BY id'))
@@ -306,8 +343,18 @@ async def _assert_merged_schema_and_data(engine, source):
@pytest.mark.asyncio
@pytest.mark.parametrize('source', ['baseline', 'master', 'beta'])
async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source):
@pytest.mark.parametrize(
('source', 'checkpoint'),
[
('baseline', None),
('baseline', '0016_space_launch_replay'),
('baseline', '0018_merge_workspace_heads'),
('baseline', '0018_llm_reasoning_config'),
('master', None),
('beta', None),
],
)
async def test_historical_schema_and_populated_branch_upgrade(convergence_engine, source, checkpoint):
engine = convergence_engine
fixture = json.loads((FIXTURES / f'{source}_schema.json').read_text())
async with engine.begin() as conn:
@@ -338,6 +385,9 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine
await run_alembic_upgrade(engine, '0010_scope_resources')
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if checkpoint is not None:
await run_alembic_upgrade(engine, checkpoint)
assert await get_alembic_current(engine) == checkpoint
await run_alembic_upgrade(engine, 'head')
await _assert_merged_schema_and_data(engine, source)
await run_alembic_upgrade(engine, 'head')
@@ -351,6 +401,30 @@ async def test_empty_database_startup_schema_then_real_migrations(convergence_en
assert await conn.run_sync(lambda sync: sa.inspect(sync).get_table_names()) == []
# This is the documented fresh-install contract: create_all precedes Alembic.
await conn.run_sync(Base.metadata.create_all)
await conn.execute(
Base.metadata.tables['metadata'].insert().values(key='instance_uuid', value='migration-convergence-fresh')
)
await conn.execute(
Base.metadata.tables['users']
.insert()
.values(
uuid=ACCOUNT, user='[email protected]', normalized_email='[email protected]', password='fresh-password'
)
)
await run_alembic_upgrade(engine, 'head')
assert await get_alembic_current(engine) == get_alembic_head()
async with engine.connect() as conn:
tables = await conn.run_sync(lambda sync: set(sa.inspect(sync).get_table_names()))
assert {
'users',
'bots',
'agents',
'runner_state',
'directory_projection_inbox',
'passkey_credentials',
} <= tables
assert await conn.scalar(sa.text('SELECT password FROM users')) == 'fresh-password'
await run_alembic_upgrade(engine, 'head')
assert await get_alembic_current(engine) == get_alembic_head()
@@ -480,18 +554,6 @@ async def test_certification_artifact_digest_backfill_is_safe_and_keeps_legacy_d
)
@pytest.mark.asyncio
async def test_certification_artifact_digest_backfill_accepts_fresh_current_schema(convergence_engine):
engine = convergence_engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine, 'head')
await run_alembic_upgrade(engine, 'head')
assert await get_alembic_current(engine) == get_alembic_head()
@pytest.mark.asyncio
async def test_merge_only_downgrade_preserves_both_branch_schemas(convergence_engine):
engine = convergence_engine
@@ -122,39 +122,6 @@ class TestSQLiteMigrationBaseline:
class TestSQLiteMigrationUpgrade:
"""Tests for upgrade to head workflow."""
@pytest.mark.asyncio
async def test_upgrade_from_published_space_launch_head_to_merged_head(self, sqlite_engine):
"""A database released at the production-only 0016 head must remain upgradable."""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0016_space_launch_replay')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
@pytest.mark.asyncio
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
"""A database at the 4.11 development head must absorb later master migrations."""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0018_merge_workspace_heads')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
@pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
"""A database that already ran the feature migration must remain upgradable."""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
@pytest.mark.asyncio
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
async with sqlite_engine.begin() as conn:
@@ -171,62 +138,6 @@ class TestSQLiteMigrationUpgrade:
assert 'enabled' not in columns
@pytest.mark.asyncio
async def test_upgrade_from_baseline_to_head(self, sqlite_engine):
"""
Upgrade from baseline to head applies all migrations.
Workflow:
1. Create tables
2. Stamp baseline
3. Upgrade to head
4. Verify current revision is head
"""
# Create tables
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(sqlite_engine, '0001_baseline')
# Upgrade to head
await run_alembic_upgrade(sqlite_engine, 'head')
# Verify revision
rev = await get_alembic_current(sqlite_engine)
assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration. Resolve the actual head from the
# Alembic script directory instead of hardcoding a revision number, so
# adding a new migration doesn't require editing this assertion.
assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}'
@pytest.mark.asyncio
async def test_upgrade_idempotent(self, sqlite_engine):
"""
Running upgrade to head multiple times is idempotent.
Workflow:
1. Upgrade to head
2. Get revision
3. Upgrade to head again
4. Verify same revision
"""
# Create tables
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp and upgrade
await run_alembic_stamp(sqlite_engine, '0001_baseline')
await run_alembic_upgrade(sqlite_engine, 'head')
rev1 = await get_alembic_current(sqlite_engine)
# Upgrade again - should be idempotent
await run_alembic_upgrade(sqlite_engine, 'head')
rev2 = await get_alembic_current(sqlite_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
@pytest.mark.asyncio
async def test_upgrade_from_0012_adds_knowledge_base_embedding_dimension(self, sqlite_engine):
"""The PostgreSQL pgvector revision also evolves the OSS ORM schema."""
@@ -340,87 +251,14 @@ class TestSQLiteMigrationFreshDatabase:
assert await get_alembic_current(sqlite_engine) == '0022_codex_credentials'
@pytest.mark.asyncio
async def test_fresh_db_upgrade_from_scratch(self, tmp_path):
"""
Fresh database (no tables) can be upgraded directly to head.
Workflow:
1. Create fresh engine with new DB file
2. Create tables
3. Upgrade to head
4. Verify revision
"""
# Use different DB file for fresh test
fresh_db_file = tmp_path / 'test_migrations_fresh.db'
fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}'
fresh_engine = create_async_engine(fresh_url)
# Create tables on fresh DB
async with fresh_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Upgrade to head directly (no baseline stamp)
await run_alembic_upgrade(fresh_engine, 'head')
# Verify revision
rev = await get_alembic_current(fresh_engine)
assert rev is not None, 'Expected a revision on fresh DB'
await fresh_engine.dispose()
@pytest.mark.asyncio
async def test_fresh_db_without_create_all_behavior(self, tmp_path):
"""
Fresh database without create_all - test actual behavior.
This tests what happens when migrations run on truly empty DB.
The behavior is determined by Alembic and migration scripts.
EXPECTED: Either:
1. Migration succeeds (if scripts handle empty DB)
2. Migration fails with specific error (if scripts require tables)
IMPORTANT: This test verifies the ACTUAL behavior, not accepting
any arbitrary failure with try-except pass.
"""
fresh_db_file = tmp_path / 'test_empty_migrations.db'
fresh_url = f'sqlite+aiosqlite:///{fresh_db_file}'
fresh_engine = create_async_engine(fresh_url)
# Capture the actual behavior
actual_result = None
actual_error = None
try:
await run_alembic_upgrade(fresh_engine, 'head')
rev = await get_alembic_current(fresh_engine)
actual_result = rev
except Exception as e:
actual_error = e
await fresh_engine.dispose()
# Verify specific behavior - one of two outcomes is expected
if actual_result is not None:
# Migration succeeded - verify revision exists
assert actual_result is not None, 'Revision should exist after successful migration'
else:
# Migration failed - verify the error type is known
# Alembic typically raises specific errors for missing tables
assert actual_error is not None, 'Error should be captured if migration failed'
# Log the error type for documentation (don't silently pass)
error_type = type(actual_error).__name__
# Acceptable error types for empty DB scenarios
acceptable_errors = [
'OperationalError', # SQLite table not found
'ProgrammingError', # SQLAlchemy errors
'CommandError', # Alembic command errors
]
assert error_type in acceptable_errors, (
f'Unexpected error type: {error_type}. '
f'This may indicate a regression in migration behavior. '
f'Error: {actual_error}'
)
async def test_fresh_db_without_create_all_behavior(self, sqlite_engine):
"""Bare Alembic reaches head but relies on ORM bootstrap for core tables."""
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
async with sqlite_engine.connect() as conn:
tables = await conn.run_sync(lambda sync: set(sqlalchemy.inspect(sync).get_table_names()))
assert {'agents', 'runner_state', 'directory_projection_states', 'directory_projection_inbox'} <= tables
assert not {'users', 'bots', 'workspaces'} & tables
class TestSQLiteMigrationGetCurrent:
@@ -438,16 +276,3 @@ class TestSQLiteMigrationGetCurrent:
# No stamp - should return None
rev = await get_alembic_current(sqlite_engine)
assert rev is None, f'Expected None for unstamped DB, got {rev}'
@pytest.mark.asyncio
async def test_get_current_after_stamp_returns_revision(self, sqlite_engine):
"""
get_alembic_current returns correct revision after stamp.
"""
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(sqlite_engine, '0001_baseline')
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline'
@@ -424,62 +424,6 @@ class TestPostgreSQLMigrationBaseline:
class TestPostgreSQLMigrationUpgrade:
"""Tests for upgrade to head workflow on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_upgrade_from_baseline_to_head(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Upgrade from baseline to head applies all migrations.
Workflow:
1. Create tables
2. Stamp baseline
3. Upgrade to head
4. Verify current revision is head
"""
# Create tables
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(postgres_engine, '0001_baseline')
# Upgrade to head
await run_alembic_upgrade(postgres_engine, 'head')
# Verify revision
rev = await get_alembic_current(postgres_engine)
assert rev is not None, 'Expected a revision after upgrade'
# Head should be the latest migration. Resolve the actual head from the
# Alembic script directory instead of hardcoding a revision number, so
# adding a new migration doesn't require editing this assertion.
assert rev == _get_script_head(), f'Expected head {_get_script_head()}, got {rev}'
@pytest.mark.asyncio
async def test_postgres_upgrade_idempotent(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Running upgrade to head multiple times is idempotent.
Workflow:
1. Upgrade to head
2. Get revision
3. Upgrade to head again
4. Verify same revision
"""
# Create tables
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp and upgrade
await run_alembic_stamp(postgres_engine, '0001_baseline')
await run_alembic_upgrade(postgres_engine, 'head')
rev1 = await get_alembic_current(postgres_engine)
# Upgrade again - should be idempotent
await run_alembic_upgrade(postgres_engine, 'head')
rev2 = await get_alembic_current(postgres_engine)
assert rev2 == rev1, f'Expected {rev1}, got {rev2}'
@pytest.mark.asyncio
@pytest.mark.parametrize('settings_type', ['TEXT', 'JSON'])
async def test_legacy_knowledge_restore_statement_accepts_historical_and_fresh_settings_types(
@@ -550,21 +494,6 @@ class TestPostgreSQLMigrationGetCurrent:
rev = await get_alembic_current(postgres_engine)
assert rev is None, f'Expected None for unstamped DB, got {rev}'
@pytest.mark.asyncio
async def test_postgres_get_current_after_stamp_returns_revision(
self, postgres_engine, clean_tables, clean_alembic_version
):
"""
get_alembic_current returns correct revision after stamp.
"""
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_stamp(postgres_engine, '0001_baseline')
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
class TestPostgreSQLWorkspaceMigration:
"""Focused coverage for upgrading a pre-tenancy PostgreSQL instance."""
@@ -1070,7 +999,27 @@ class TestPostgreSQLTenantRuntime:
await rag_manager.load_knowledge_bases_from_db()
mcp_loader = MCPLoader(runtime_application)
mcp_loader.host_mcp_server = AsyncMock()
active_hosts = 0
peak_active_hosts = 0
peak_host_tasks = 0
track_hosted_task = mcp_loader.track_hosted_task
def observe_host_task(task, context):
nonlocal peak_host_tasks
track_hosted_task(task, context)
peak_host_tasks = max(peak_host_tasks, len(mcp_loader._hosted_mcp_tasks))
async def host_probe(_context, _config):
nonlocal active_hosts, peak_active_hosts
active_hosts += 1
peak_active_hosts = max(peak_active_hosts, active_hosts)
try:
await asyncio.sleep(0)
finally:
active_hosts -= 1
mcp_loader.track_hosted_task = observe_host_task
mcp_loader.host_mcp_server = AsyncMock(side_effect=host_probe)
await mcp_loader.load_mcp_servers_from_db()
dispatch_tasks = tuple(mcp_loader._host_dispatch_tasks)
if dispatch_tasks:
@@ -1111,6 +1060,12 @@ class TestPostgreSQLTenantRuntime:
assert len(pipeline_manager.pipelines) == workspace_count
assert len(rag_manager.knowledge_bases) == workspace_count
assert mcp_loader.host_mcp_server.await_count == workspace_count
assert 0 < peak_active_hosts <= min(workspace_count, 8)
assert 0 < peak_host_tasks <= min(workspace_count, 8)
assert active_hosts == 0
logging.getLogger('postgres-capacity-runtime-test').info(
'MCP startup peaks: active_hosts=%d hosted_tasks=%d', peak_active_hosts, peak_host_tasks
)
assert not mcp_loader._host_dispatch_tasks
assert not mcp_loader._hosted_mcp_tasks
assert len(plugin_handler.reconciled) == workspace_count
@@ -166,10 +166,14 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega
async with legacy_engine.connect() as conn:
workspace = (
await conn.execute(
sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"),
(
await conn.execute(
sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"),
)
)
).mappings().one()
.mappings()
.one()
)
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
@@ -196,7 +200,7 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
canonical_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
schema = sa.MetaData()
sa.Table(
'metadata',
@@ -218,7 +222,15 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
)
sa.Table(
'workspace_metadata',
schema,
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
sa.Column('key', sa.String(255)),
sa.Column('value', sa.String(255)),
)
async with engine.begin() as conn:
await conn.execute(sa.text('PRAGMA foreign_keys = ON'))
await conn.run_sync(schema.create_all)
await conn.execute(
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
@@ -233,6 +245,14 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
{'uuid': old_workspace_uuid},
)
await conn.execute(
sa.text("INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :value)"),
{'value': old_workspace_uuid},
)
await conn.execute(
sa.text("INSERT INTO workspace_metadata VALUES (:uuid, 'migration_probe', 'present')"),
{'uuid': old_workspace_uuid},
)
await run_alembic_stamp(engine, '0016_support_admin_sessions')
await run_alembic_upgrade(engine, 'head')
@@ -240,6 +260,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
async with engine.connect() as conn:
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
assert (
await conn.execute(sa.text('SELECT workspace_uuid FROM workspace_metadata'))
).scalar_one() == canonical_uuid
assert (
await conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text('PRAGMA foreign_key_check'))).all() == []
await engine.dispose()
@@ -497,51 +524,6 @@ async def test_persistence_startup_preserves_legacy_workspace_membership_with_fo
await manager.shutdown()
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try:
await _create_legacy_schema(engine)
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
async with engine.begin() as conn:
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
assert old_uuid
assert instance_uuid
await conn.execute(
sa.text(
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
),
{'workspace_uuid': old_uuid},
)
await conn.execute(
sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
),
{'workspace_uuid': old_uuid},
)
await run_alembic_upgrade(engine, 'head')
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
assert (
await conn.scalar(
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
)
== expected_uuid
)
assert (
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
== expected_uuid
)
finally:
await engine.dispose()
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
try:
+18 -3
View File
@@ -353,6 +353,9 @@ class TestPreProcessorStage:
# Check user_message content
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [
('text', 'test message content')
]
@pytest.mark.usefixtures('load_pipeline_modules')
@@ -370,6 +373,14 @@ class TestProcessorStage:
# Create query
query = text_query('hello')
from langbot_plugin.api.entities.builtin.provider.session import Session
query.session = Session(
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
using_conversation=await pipeline_app.sess_mgr.get_conversation(),
)
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
@@ -390,9 +401,13 @@ class TestProcessorStage:
# Collect results using helper
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) >= 1
# Check that resp_messages was populated
assert len(query.resp_messages) >= 1
from langbot.pkg.pipeline import entities
assert pipeline_app.agent_run_orchestrator.observed_queries == [query]
assert [result.result_type for result in results] == [entities.ResultType.CONTINUE]
assert [(message.role, message.content) for message in query.resp_messages] == [
('assistant', 'LANGBOT_FAKE_PONG')
]
@pytest.mark.asyncio
async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter):
+18 -5
View File
@@ -169,6 +169,7 @@ async def test_full_text_search(backend):
async def test_hybrid_filter_then_knn(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
documents = ['cats nearby', 'cats elsewhere', 'cats farther']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
@@ -180,10 +181,10 @@ async def test_hybrid_filter_then_knn(backend):
query_text='cats',
filter={'file_id': 'fileA'},
),
lambda r: len(r['ids'][0]) >= 1,
lambda r: len(r['ids'][0]) == 2,
)
# Only fileA docs (d1, d3) should be candidates.
assert set(result['ids'][0]).issubset({'d1', 'd3'})
assert result['ids'][0] == ['d1', 'd3']
assert [item['file_id'] for item in result['metadatas'][0]] == ['fileA', 'fileA']
@pytest.mark.asyncio
@@ -191,16 +192,17 @@ async def test_vector_weight_not_honored(backend):
"""Passing different vector_weight values must NOT change ranking."""
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
documents = ['cats nearby', 'cats elsewhere', 'cats farther']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
common = dict(
collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats'
)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) == 3)
r_low = await db.search(**common, vector_weight=0.1)
r_high = await db.search(**common, vector_weight=0.9)
assert r_low['ids'][0] == r_high['ids'][0]
assert r_low['ids'][0] == r_high['ids'][0] == ['d1', 'd3', 'd2']
@pytest.mark.asyncio
@@ -257,6 +259,9 @@ async def test_delete_by_filter_returns_count(backend):
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
assert deleted == 2
items, total = await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda result: result[1] == 1)
assert total == 1
assert [item['id'] for item in items] == ['d2']
@pytest.mark.asyncio
@@ -273,6 +278,10 @@ async def test_list_by_filter_pagination(backend):
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
assert total == 3
assert len(page2) == 1
first_ids = {item['id'] for item in page1}
second_ids = {item['id'] for item in page2}
assert first_ids.isdisjoint(second_ids)
assert first_ids | second_ids == {'d1', 'd2', 'd3'}
@pytest.mark.asyncio
@@ -282,7 +291,11 @@ async def test_delete_collection(backend):
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
client = await db._ensure_client()
keys = [f'kb:{collection}:{document_id}' for document_id in ids]
assert await client.exists(keys) == 3
await db.delete_collection(collection)
assert await client.exists(keys) == 0
# After dropping, search on a missing index returns empty.
result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector')
@@ -249,17 +249,68 @@ async def test_timeout_kills_command(box_client: ActionRPCBoxClient):
@requires_socket
@pytest.mark.asyncio
async def test_offline_cannot_reach_network(box_client: ActionRPCBoxClient):
"""With network=OFF the sandbox cannot reach the internet."""
spec = BoxSpec(
cmd='wget -q -O /dev/null --timeout=3 http://1.1.1.1 2>&1; exit $?',
session_id='int-offline',
workdir='/tmp',
network=BoxNetworkMode.OFF,
image=_TEST_IMAGE,
"""Only OFF blocks the same reachable private peer and installed HTTP client."""
server = await box_client.execute(
BoxSpec(
cmd=r"""cat > /tmp/response.sh <<'HTTP'
#!/bin/sh
while IFS= read -r line; do
[ -z "$(printf '%s' "$line" | busybox tr -d '\r')" ] && break
done
printf 'HTTP/1.0 200 OK\r\nContent-Length: 19\r\n\r\nbox-network-control'
HTTP
chmod +x /tmp/response.sh
(while true; do busybox nc -l -p 8080 -e /bin/sh /tmp/response.sh; done >/tmp/http.log 2>&1 < /dev/null &)
sleep 1 && hostname -i""",
session_id='int-network-peer',
workdir='/tmp',
network=BoxNetworkMode.ON,
image=_TEST_IMAGE,
)
)
result = await box_client.execute(spec)
assert server.status == BoxExecutionStatus.COMPLETED
assert server.exit_code == 0, server.stderr
import ipaddress
assert result.exit_code != 0
peer = str(ipaddress.IPv4Address(server.stdout.strip().split()[0]))
assert not ipaddress.ip_address(peer).is_loopback
command = f'wget -q -O - --timeout=3 http://{peer}:8080/'
online = await box_client.execute(
BoxSpec(
cmd=command,
session_id='int-online',
workdir='/tmp',
network=BoxNetworkMode.ON,
image=_TEST_IMAGE,
)
)
assert online.status == BoxExecutionStatus.COMPLETED
assert online.exit_code == 0, online.stderr
assert online.stdout == 'box-network-control'
offline = await box_client.execute(
BoxSpec(
cmd=command,
session_id='int-offline',
workdir='/tmp',
network=BoxNetworkMode.OFF,
image=_TEST_IMAGE,
)
)
assert offline.status == BoxExecutionStatus.COMPLETED
assert offline.exit_code != 0
assert offline.stdout == ''
still_online = await box_client.execute(
BoxSpec(
cmd=command,
session_id='int-online',
workdir='/tmp',
network=BoxNetworkMode.ON,
image=_TEST_IMAGE,
)
)
assert still_online.exit_code == 0
assert still_online.stdout == 'box-network-control'
# ── 5. Backend unavailable ───────────────────────────────────────────
@@ -580,3 +580,4 @@ class TestChatHandlerAsyncBehavior:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert query.resp_messages[0].content == 'Reply from plugin'
@@ -303,6 +303,10 @@ class TestRunnerContextProtocolV1:
assert ctx.event is not None
assert ctx.event.event_type == 'message.received'
from pydantic import ValidationError
with pytest.raises(ValidationError, match='event'):
RunnerContext.model_validate(ctx.model_dump(exclude={'event'}))
def test_sdk_context_has_no_history_message_fields(self):
"""RunnerContext should not expose inline history message fields."""
@@ -332,16 +336,6 @@ class TestRunnerContextProtocolV1:
assert not hasattr(ctx, 'bootstrap')
class TestHostManagedHistoryNotInProtocol:
"""Test that Host-managed history payloads are not in Protocol v1."""
def test_messages_not_in_sdk_context_top_level(self):
"""RunnerContext should not expose top-level history messages."""
ctx_fields = RunnerContext.model_fields.keys()
assert 'messages' not in ctx_fields
class TestSDKResultProtocolV1:
"""Test SDK RunnerResult for Protocol v1."""
@@ -355,6 +349,8 @@ class TestSDKResultProtocolV1:
)
assert result.run_id == 'run_1'
with pytest.raises(TypeError, match='run_id'):
RunnerResult.message_completed(message=Message(role='assistant', content='Hello'))
# Fixtures
@@ -139,30 +139,31 @@ class TestTranscriptStore:
"""Test TranscriptStore operations."""
@pytest.mark.asyncio
async def test_append_transcript(self, mock_db_engine):
async def test_append_transcript(self):
"""Test appending a transcript item."""
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from uuid import UUID
store = TranscriptStore(mock_db_engine)
mock_session = AsyncMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
# Mock _get_next_seq
with patch.object(store, '_get_next_seq', return_value=1):
with patch.object(store, '_session_factory') as mock_factory:
mock_factory.return_value.__aenter__.return_value = mock_session
transcript_id = await store.append_transcript(
transcript_id=None, # Auto-generate
event_id='evt_1',
conversation_id='conv_1',
role='user',
content='Hello',
)
assert transcript_id is not None
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
store = TranscriptStore(engine)
transcript_id = await store.append_transcript(
transcript_id=None,
event_id='evt_1',
conversation_id='conv_1',
role='user',
content='Hello',
)
assert UUID(transcript_id).version == 4
items, _, _, _ = await store.page_transcript('conv_1')
assert [(item['transcript_id'], item['event_id'], item['role'], item['content']) for item in items] == [
(transcript_id, 'evt_1', 'user', 'Hello')
]
finally:
await engine.dispose()
class TestEventLogStoreRealSQLite:
@@ -258,6 +259,7 @@ class TestEventLogStoreRealSQLite:
async def test_get_latest_cursor(self, db_engine):
"""Test get_latest_cursor with real DB."""
store = EventLogStore(db_engine)
assert await store.get_latest_cursor('conv_cursor') is None
# Append events
for i in range(3):
@@ -270,8 +272,12 @@ class TestEventLogStoreRealSQLite:
# Get latest cursor
cursor = await store.get_latest_cursor('conv_cursor')
assert cursor is not None
assert int(cursor) > 0
assert cursor == '3'
await store.append_event(
event_id='other', event_type='message.received', source='platform', conversation_id='other'
)
assert await store.get_latest_cursor('conv_cursor') == '3'
assert await store.get_latest_cursor('other') == '4'
@pytest.mark.asyncio
async def test_cleanup_events_older_than(self, db_engine):
@@ -789,6 +789,13 @@ async def test_orchestrator_applies_state_updates_and_suppresses_protocol_event(
assert [message.content for message in messages] == ['state saved']
# State is persisted to the database via PersistentStateStore.
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
event = QueryEntryAdapter.query_to_event(query)
config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
binding = AgentBindingResolver().resolve_one(event, [config])
snapshot = await get_persistent_state_store(db_engine).build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation'].get('external.conversation_id') == 'external_conv_123'
@pytest.mark.asyncio
+6
View File
@@ -124,6 +124,7 @@ class TestRegistryDiscovery:
async def test_discover_caches_results(self):
"""Discovery should cache results."""
ap = FakeApplication()
ap.plugin_connector.list_runners = AsyncMock(wraps=ap.plugin_connector.list_runners)
registry = RunnerRegistry(ap)
# First discovery
@@ -134,6 +135,11 @@ class TestRegistryDiscovery:
assert registry._cache is not None
assert len(runners1) == len(runners2)
ap.plugin_connector.list_runners.assert_awaited_once()
assert [runner.id for runner in runners2] == [
'plugin:langbot-team/LocalAgent/default',
'plugin:alice/my-agent/custom',
]
@pytest.mark.asyncio
async def test_discover_handles_plugin_disabled(self):
@@ -156,6 +156,7 @@ class TestNormalizeRunCompleted:
assert result is not None
assert isinstance(result, provider_message.Message)
assert (result.role, result.content) == ('assistant', 'Final response')
@pytest.mark.asyncio
async def test_normalize_run_completed_without_message(self):
@@ -173,6 +173,22 @@ async def _create_run(
)
@pytest.fixture
async def ledger_snapshot(db_engine):
await _create_run(
db_engine, run_id='workspace_guard', workspace_id='workspace_guard', plugin_identity='guard/runner'
)
async def snapshot():
async with AsyncSession(db_engine) as session:
return [
(await session.execute(sqlalchemy.select(*model.__table__.c).order_by(model.id))).mappings().all()
for model in (agent_run_model.AgentRun, agent_run_model.AgentRunEvent)
]
return snapshot
@pytest.mark.asyncio
async def test_run_get_returns_current_run(session_registry, db_engine):
await _register_session(session_registry, available_apis={'run_get': True})
@@ -262,6 +278,7 @@ async def test_run_list_filters_same_scope_different_runner_owner(session_regist
async def test_non_admin_target_run_actions_reject_same_scope_different_runner_owner(
session_registry,
db_engine,
ledger_snapshot,
):
await _register_session(
session_registry,
@@ -349,10 +366,12 @@ async def test_non_admin_target_run_actions_reject_same_scope_different_runner_o
),
]
before = await ledger_snapshot()
for action, payload in calls:
result = await handler.actions[action](payload)
assert result.code != 0, action
assert 'not accessible' in result.message.lower()
assert await ledger_snapshot() == before
@pytest.mark.asyncio
@@ -1046,11 +1065,12 @@ async def test_run_append_result_basic_path(session_registry, db_engine):
@pytest.mark.asyncio
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine):
async def test_run_append_result_rejects_side_effecting_result_type(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1066,14 +1086,16 @@ async def test_run_append_result_rejects_side_effecting_result_type(session_regi
assert result.code != 0
assert 'canonical runner result path' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine):
async def test_run_append_result_rejects_unknown_result_type(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1089,14 +1111,16 @@ async def test_run_append_result_rejects_unknown_result_type(session_registry, d
assert result.code != 0
assert 'unknown result type' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_result_validates_known_payloads(session_registry, db_engine):
async def test_run_append_result_validates_known_payloads(session_registry, db_engine, ledger_snapshot):
await _register_session(session_registry, available_apis={'run_append_result': True})
await _create_run(db_engine)
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
before = await ledger_snapshot()
result = await run_append_result(
{
@@ -1112,10 +1136,13 @@ async def test_run_append_result_validates_known_payloads(session_registry, db_e
assert result.code != 0
assert 'invalid action.requested payload' in result.message
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_run_append_and_finalize_claimed_target_require_active_claim(session_registry, db_engine):
async def test_run_append_and_finalize_claimed_target_require_active_claim(
session_registry, db_engine, ledger_snapshot
):
await _register_session(
session_registry,
available_apis={'run_append_result': True, 'run_finalize': True},
@@ -1139,6 +1166,7 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
handler = _handler(db_engine)
run_append_result = handler.actions[PluginToRuntimeAction.RUN_APPEND_RESULT.value]
run_finalize = handler.actions[PluginToRuntimeAction.RUN_FINALIZE.value]
before = await ledger_snapshot()
missing_claim = await run_append_result(
{
@@ -1152,6 +1180,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
},
}
)
assert missing_claim.code != 0
assert 'active claim ownership' in missing_claim.message
assert await ledger_snapshot() == before
wrong_claim = await run_finalize(
{
'run_id': 'run_1',
@@ -1162,6 +1193,9 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
'status': 'completed',
}
)
assert wrong_claim.code != 0
assert 'claim ownership is not active' in wrong_claim.message
assert await ledger_snapshot() == before
appended = await run_append_result(
{
'run_id': 'run_1',
@@ -1187,10 +1221,6 @@ async def test_run_append_and_finalize_claimed_target_require_active_claim(sessi
}
)
assert missing_claim.code != 0
assert 'active claim ownership' in missing_claim.message
assert wrong_claim.code != 0
assert 'claim ownership is not active' in wrong_claim.message
assert appended.code == 0
assert finalized.code == 0
assert finalized.data['status'] == 'completed'
@@ -1425,7 +1455,7 @@ async def test_non_admin_run_claim_is_scoped_to_session_runner_and_conversation(
@pytest.mark.asyncio
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine):
async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_registry, db_engine, ledger_snapshot):
await _register_session(
session_registry,
available_apis={'run_renew_claim': True, 'run_release_claim': True},
@@ -1447,6 +1477,7 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
handler = _handler(db_engine)
run_renew_claim = handler.actions[PluginToRuntimeAction.RUN_RENEW_CLAIM.value]
run_release_claim = handler.actions[PluginToRuntimeAction.RUN_RELEASE_CLAIM.value]
before = await ledger_snapshot()
renewed = await run_renew_claim(
{
@@ -1457,6 +1488,9 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
'claim_token': claim['claim_token'],
}
)
assert renewed.code != 0
assert 'not accessible' in renewed.message.lower()
assert await ledger_snapshot() == before
released = await run_release_claim(
{
'run_id': 'run_1',
@@ -1467,14 +1501,13 @@ async def test_non_admin_renew_and_release_reject_cross_runner_claim(session_reg
}
)
assert renewed.code != 0
assert 'not accessible' in renewed.message.lower()
assert released.code != 0
assert 'not accessible' in released.message.lower()
assert await ledger_snapshot() == before
@pytest.mark.asyncio
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine):
async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_engine, ledger_snapshot):
await _register_session(
session_registry,
available_apis={'run_claim': True, 'run_release_claim': True},
@@ -1503,6 +1536,7 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
}
)
assert claimed.code == 0
before = await ledger_snapshot()
released = await run_release_claim(
{
@@ -1517,3 +1551,4 @@ async def test_non_admin_release_claim_cannot_finalize_run(session_registry, db_
assert released.code != 0
assert 'use run_finalize' in released.message
assert await ledger_snapshot() == before
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import os
import tempfile
from copy import deepcopy
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
@@ -132,6 +133,24 @@ class TestStateScopeHelpers:
assert keys['subject'].startswith('subject:v2:')
assert keys['runner'].startswith('runner:v2:')
assert len(set(keys.values())) == len(keys)
for dimension, value in [
('workspace_id', 'ws_other'),
('bot_id', 'bot_other'),
]:
other = deepcopy(event)
setattr(other, dimension, value)
for scope in VALID_STATE_SCOPES:
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
for scope, dimension, value in [
('conversation', 'conversation_id', 'conv_other'),
('conversation', 'thread_id', 'thread_other'),
('actor', 'actor_id', 'user_other'),
('subject', 'subject_id', 'msg_other'),
]:
other = deepcopy(event)
owner = other.actor if scope == 'actor' else other.subject if scope == 'subject' else other
setattr(owner, dimension, value)
assert build_state_scope_key(scope, other, binding, descriptor) != keys[scope]
def test_scope_key_missing_identity_returns_none(self):
descriptor = make_descriptor()
@@ -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())
@@ -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']: (section['path'], 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."""
@@ -463,8 +493,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:
@@ -485,12 +519,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."""
@@ -507,7 +538,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:
@@ -574,7 +607,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:
@@ -789,11 +822,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."""
@@ -908,7 +947,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:
@@ -929,40 +968,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."""
@@ -992,7 +1029,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:
@@ -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():
+70 -53
View File
@@ -434,7 +434,7 @@ async def test_box_service_get_sessions_delegates_to_client():
sessions = await service.get_sessions(_CONTEXT)
assert sessions == [{'session_id': 'test-session'}]
client.get_sessions.assert_awaited_once()
client.get_sessions.assert_awaited_once_with(action_context=_ACTION_CONTEXT)
@pytest.mark.asyncio
@@ -599,12 +599,14 @@ async def test_cloud_box_service_reconnect_restores_runtime_only(
connector.reconnect = AsyncMock()
service._ensure_default_workspace = Mock()
service._verify_cloud_runtime = AsyncMock()
service._purge_attachment_dirs = AsyncMock()
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
await service._reconnect_loop(connector)
connector.reconnect.assert_awaited_once()
service._verify_cloud_runtime.assert_awaited_once()
service._purge_attachment_dirs.assert_not_awaited()
assert service.available is True
@@ -674,6 +676,8 @@ async def test_box_service_allows_host_mount_under_configured_root(tmp_path):
assert result['ok'] is True
assert backend.start_calls == ['person_test_user']
assert backend.start_specs[0].host_path == os.path.realpath(host_dir)
assert backend.start_specs[0].host_path_mode == BoxHostMountMode.READ_WRITE
@pytest.mark.asyncio
@@ -744,16 +748,20 @@ async def test_box_service_rejects_host_mount_outside_allowed_roots(tmp_path):
service = BoxService(make_app(logger, [str(allowed_root)]), client=_InProcessBoxRuntimeClient(logger, runtime))
await service.initialize()
with pytest.raises(BoxValidationError):
with pytest.raises(BoxValidationError, match='outside allowed_mount_roots'):
await service.execute_spec_payload(
{
'cmd': 'pwd',
'host_path': str(disallowed_root),
'session_id': '12',
'session_id': 'person_test_user',
},
make_query(12),
)
assert backend.start_calls == []
assert backend.exec_calls == []
assert list(disallowed_root.iterdir()) == []
@pytest.mark.asyncio
async def test_box_runtime_rejects_host_mount_conflict_in_same_session(tmp_path):
@@ -1064,8 +1072,18 @@ def test_builtin_profiles_are_consistent():
@pytest.mark.asyncio
async def test_profile_default_applies_resource_limits():
async def test_profile_default_applies_resource_limits(monkeypatch):
"""Default profile resource limits are applied to BoxSpec."""
profile = BUILTIN_PROFILES['default'].model_copy(
update={
'cpus': 0.75,
'memory_mb': 320,
'pids_limit': 37,
'read_only_rootfs': False,
'workspace_quota_mb': 19,
}
)
monkeypatch.setitem(BUILTIN_PROFILES, 'default', profile)
logger = Mock()
backend = FakeBackend(logger)
runtime = BoxRuntime(logger=logger, backends=[backend], session_ttl_sec=300)
@@ -1473,8 +1491,14 @@ async def test_rpc_client_get_status():
try:
status = await client.get_status(action_context=_ACTION_CONTEXT)
assert 'backend' in status
assert 'active_sessions' in status
assert status['backend']['name'] == 'fake'
assert status['backend']['available'] is True
assert status['active_sessions'] == 0
await client.execute(BoxSpec(cmd='true', session_id='status-session'), action_context=_ACTION_CONTEXT)
status = await client.get_status(action_context=_ACTION_CONTEXT)
assert status['active_sessions'] == 1
assert status['managed_processes'] == 0
assert status['session_ttl_sec'] == 300
finally:
server_task.cancel()
client_task.cancel()
@@ -1515,8 +1539,10 @@ async def test_rpc_client_delete_session():
spec = BoxSpec.model_validate({'cmd': 'echo hi', 'session_id': 'r-del-1'})
await client.execute(spec, action_context=_ACTION_CONTEXT)
physical_session_id = backend.start_calls[0]
await client.delete_session('r-del-1', action_context=_ACTION_CONTEXT)
assert backend.stop_calls == [physical_session_id]
sessions = await client.get_sessions(action_context=_ACTION_CONTEXT)
assert len(sessions) == 0
finally:
@@ -1884,21 +1910,6 @@ class TestBuildSkillExecutionMounts:
with pytest.raises(ValueError, match='no immutable package root'):
skill_loader.build_execution_mounts(app, query)
def test_empty_package_root_skipped_even_when_not_shared(self):
"""An empty package_root is always invalid regardless of topology."""
logger = Mock()
skills = {
'no_root': {
'name': 'no_root',
'package_root': '',
'manifest_path': '',
'revision': 'sha256:' + '1' * 64,
}
}
app = self._make_app(logger, skills)
assert skill_loader.build_execution_mounts(app, make_query()) == []
def test_returns_empty_when_no_skill_manager(self):
logger = Mock()
app = make_app(logger)
@@ -1973,13 +1984,26 @@ class TestAttachmentHelpers:
class TestInboundOutboundRoundTrip:
@staticmethod
async def _execute_command(command, workspace):
process = await asyncio.create_subprocess_exec(
'/bin/sh',
'-c',
command.replace('/workspace', str(workspace)),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
assert process.returncode == 0, stderr.decode()
return stdout.decode().replace(str(workspace), '/workspace')
def _service(self) -> BoxService:
service = BoxService(make_app(Mock()), client=Mock(spec=BoxRuntimeClient))
service._available = True
return service
@pytest.mark.asyncio
async def test_materialize_inbound_writes_and_describes(self):
async def test_materialize_inbound_writes_and_describes(self, tmp_path):
import base64
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -1997,14 +2021,9 @@ class TestInboundOutboundRoundTrip:
]
)
# Mock the sandbox write path: echo back the written paths.
async def fake_execute_tool(parameters, q):
assert '/workspace/inbox/' in parameters['command']
return {
'ok': True,
'stdout': '["/workspace/inbox/query-42/image_1.png"]',
'stderr': '',
}
assert q is query
return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''}
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
@@ -2014,6 +2033,7 @@ class TestInboundOutboundRoundTrip:
assert d['type'] == 'Image'
assert d['path'] == '/workspace/inbox/query-42/image_1.png'
assert d['size'] == len(img_bytes)
assert (tmp_path / 'inbox/query-42/image_1.png').read_bytes() == img_bytes
@pytest.mark.asyncio
async def test_materialize_inbound_noop_without_attachments(self):
@@ -2027,45 +2047,40 @@ class TestInboundOutboundRoundTrip:
service.execute_tool.assert_not_called()
@pytest.mark.asyncio
async def test_collect_outbound_reads_and_clears(self):
async def test_collect_outbound_reads_and_clears(self, tmp_path):
service = self._service()
query = make_query()
calls = []
outbox = tmp_path / 'outbox/query-42'
outbox.mkdir(parents=True)
(outbox / 'out.png').write_bytes(b'ABC')
guard = tmp_path / 'outbox/query-other/protected.txt'
guard.parent.mkdir()
guard.write_bytes(b'other query')
async def fake_client_execute(spec, *, action_context=None):
assert action_context.workspace_uuid == query.workspace_uuid
cmd = spec.cmd
calls.append(cmd)
if 'os.scandir' in cmd:
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='[{"name": "out.png", "b64": "QUJD"}]',
duration_ms=10,
)
# the rm -rf cleanup call
assert action_context == _ACTION_CONTEXT
return BoxExecutionResult(
session_id='s',
backend_name='test',
status=BoxExecutionStatus.COMPLETED,
exit_code=0,
stdout='',
stdout=await self._execute_command(spec.cmd, tmp_path),
duration_ms=10,
)
async def fake_execute_tool(parameters, q):
assert q is query
return {'ok': True, 'stdout': await self._execute_command(parameters['command'], tmp_path), 'stderr': ''}
service.client.execute = AsyncMock(side_effect=fake_client_execute)
service.execute_tool = AsyncMock(return_value={'ok': True, 'stdout': '', 'stderr': ''})
service.execute_tool = AsyncMock(side_effect=fake_execute_tool)
attachments = await service.collect_outbound_attachments(query)
assert len(attachments) == 1
assert attachments[0]['type'] == 'Image'
assert attachments[0]['name'] == 'out.png'
# cleanup (rm -rf) must have been issued after a successful collection
service.execute_tool.assert_awaited_once()
assert 'rm -rf' in service.execute_tool.await_args.args[0]['command']
assert attachments == [{'type': 'Image', 'name': 'out.png', 'base64': 'data:image/png;base64,QUJD'}]
assert not (outbox / 'out.png').exists()
assert guard.read_bytes() == b'other query'
assert await service.collect_outbound_attachments(query) == []
@pytest.mark.asyncio
async def test_collect_outbound_empty_still_clears(self):
@@ -2172,6 +2187,7 @@ class TestAttachmentHostPath:
stale_dir = os.path.join(ws, 'inbox', 'query-42')
os.makedirs(stale_dir, exist_ok=True)
pathlib.Path(stale_dir, 'image_1.png').write_bytes(b'STALE-OLD-IMAGE')
pathlib.Path(stale_dir, 'leftover-secret.txt').write_bytes(b'STALE-OTHER-FILE')
new = b'\x89PNG\r\n\x1a\n NEW'
b64 = 'data:image/png;base64,' + base64.b64encode(new).decode()
@@ -2185,6 +2201,7 @@ class TestAttachmentHostPath:
assert host_bytes == new
# No leftover content from the stale image.
assert b'STALE-OLD-IMAGE' not in host_bytes
assert sorted(os.listdir(stale_dir)) == ['image_1.png']
@pytest.mark.asyncio
async def test_inbound_host_replaces_query_symlink_without_touching_other_workspace(self, tmp_path):
@@ -3,6 +3,8 @@ from __future__ import annotations
import asyncio
import datetime
import logging
import sqlite3
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
@@ -184,7 +186,16 @@ def _delta(
async def test_directory_delta_requests_model_catalog_sync_after_commit(projection_context):
application, _session_factory = projection_context
request_sync = Mock()
observed_revisions = []
def observe_committed_revision():
with closing(sqlite3.connect(application.persistence_mgr.engine.url.database)) as connection:
row = connection.execute(
'SELECT projection_revision FROM workspaces WHERE uuid = ?', (WORKSPACE_UUID,)
).fetchone()
observed_revisions.append(row[0])
request_sync = Mock(side_effect=observe_committed_revision)
application.cloud_model_catalog_service = SimpleNamespace(request_sync=request_sync)
event = DirectoryEvent(
cursor=2,
@@ -209,10 +220,12 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
)
await service.initialize()
request_sync.reset_mock()
observed_revisions.clear()
await service.sync_once()
request_sync.assert_called_once_with()
assert observed_revisions == [2]
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
@@ -247,11 +260,7 @@ async def test_targeted_reconciliation_projects_new_workspace_without_advancing_
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
application, session_factory = projection_context
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
update={
'members': [
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
]
}
update={'members': [_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})]}
)
provider = _Provider(
[_snapshot(7)],
@@ -572,7 +581,13 @@ async def test_account_projection_reads_large_directory_in_bounded_batches(proje
assert await session.scalar(sqlalchemy.select(sqlalchemy.func.count()).select_from(User)) == 501
async def test_archived_and_absent_workspaces_are_execution_fenced(projection_context):
@pytest.mark.parametrize(
('status', 'execution_state', 'write_fenced', 'membership_status'),
[('archived', 'inactive', True, 'removed'), ('active', 'active', False, 'active')],
)
async def test_archived_and_absent_workspaces_are_execution_fenced(
projection_context, status, execution_state, write_fenced, membership_status
):
application, session_factory = projection_context
service = DirectoryProjectionService(
application,
@@ -584,17 +599,17 @@ async def test_archived_and_absent_workspaces_are_execution_fenced(projection_co
await service.apply_snapshot(
_snapshot(
2,
workspaces=[_workspace(revision=2, status='archived')],
workspaces=[_workspace(revision=2, status=status)],
)
)
async with session_factory() as session:
workspace = await session.get(Workspace, WORKSPACE_UUID)
execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
assert workspace.status == 'archived'
assert execution.state == 'inactive'
assert execution.write_fenced is True
assert membership.status == 'removed'
assert workspace.status == status
assert execution.state == execution_state
assert execution.write_fenced is write_fenced
assert membership.status == membership_status
await service.apply_snapshot(_snapshot(3, workspaces=[]))
async with session_factory() as session:
@@ -746,7 +761,15 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
high_water_cursor=2,
events=[event],
)
provider = _Provider([_snapshot(1)], [batch], [_delta(workspaces=[])])
second_workspace = _workspace().model_copy(
update={
'uuid': SECOND_WORKSPACE_UUID,
'slug': 'second-workspace',
'type': 'team',
'members': [_member().model_copy(update={'membership_uuid': SECOND_MEMBERSHIP_UUID})],
}
)
provider = _Provider([_snapshot(1, workspaces=[_workspace(), second_workspace])], [batch], [_delta(workspaces=[])])
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
await service.initialize()
@@ -755,7 +778,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
async with session_factory() as session:
workspace = await session.get(Workspace, WORKSPACE_UUID)
execution = await session.get(WorkspaceExecutionState, WORKSPACE_UUID)
membership = await session.scalar(sqlalchemy.select(WorkspaceMembership))
membership = await session.scalar(
sqlalchemy.select(WorkspaceMembership).where(WorkspaceMembership.workspace_uuid == WORKSPACE_UUID)
)
untouched_workspace = await session.get(Workspace, SECOND_WORKSPACE_UUID)
untouched_execution = await session.get(WorkspaceExecutionState, SECOND_WORKSPACE_UUID)
untouched_membership = await session.get(WorkspaceMembership, SECOND_MEMBERSHIP_UUID)
assert workspace.status == 'archived'
assert workspace.projection_revision == 2
assert execution.state == 'inactive'
@@ -763,6 +791,12 @@ async def test_missing_workspace_in_delta_fences_only_requested_workspace(projec
assert execution.desired_state_revision == 2
assert membership.status == 'removed'
assert membership.projection_revision == 2
assert untouched_workspace.status == 'active'
assert untouched_workspace.projection_revision == 1
assert untouched_execution.state == 'active'
assert untouched_execution.write_fenced is False
assert untouched_membership.status == 'active'
assert untouched_membership.projection_revision == 1
stale_same_cursor_snapshot = _snapshot(
2,
+4 -2
View File
@@ -437,8 +437,10 @@ class TestEmptyAndEdgeInputs:
async for ret in mgr.execute(' ', '/ ', query, session):
results.append(ret)
# Should yield error
assert len(results) >= 1
assert len(results) == 1
assert results[0].error is not None
assert results[0].error.message == '未知命令: '
assert results[0].text is None
@pytest.mark.asyncio
async def test_initialize_with_deep_nesting(self):
@@ -13,6 +13,7 @@ from __future__ import annotations
import pytest
import json
import yaml
from langbot.pkg.config.impls.yaml import YAMLConfigFile
from langbot.pkg.config.impls.json import JSONConfigFile
@@ -126,8 +127,7 @@ name: custom_name
assert config_file.exists()
content = config_file.read_text()
assert 'saved_app' in content
assert 'new_key' in content
assert yaml.safe_load(content) == {'name': 'saved_app', 'new_key': 'new_value'}
def test_yaml_save_sync(self, tmp_path):
"""YAML config can be saved synchronously."""
@@ -142,7 +142,7 @@ name: custom_name
assert config_file.exists()
content = config_file.read_text()
assert 'sync_saved' in content
assert yaml.safe_load(content) == {'name': 'sync_saved'}
class TestJSONConfigFile:
@@ -260,7 +260,7 @@ class TestConfigManager:
await manager.dump_config()
content = config_file.read_text()
assert 'dumped' in content
assert yaml.safe_load(content) == {'name': 'dumped', 'new_field': 'value'}
def test_config_manager_dump_sync(self, tmp_path):
"""ConfigManager can dump config synchronously."""
@@ -277,6 +277,7 @@ class TestConfigManager:
manager.dump_config_sync()
assert config_file.exists()
assert yaml.safe_load(config_file.read_text()) == {'name': 'sync_dumped'}
class TestConfigExists:
+4
View File
@@ -36,6 +36,8 @@ async def test_make_app_shuts_down_partially_built_application(monkeypatch):
@pytest.mark.asyncio
async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch):
captured_handler = {}
print_exc = Mock()
monkeypatch.setattr(boot.traceback, 'print_exc', print_exc)
def fake_signal(sig, handler):
captured_handler[sig] = handler
@@ -48,6 +50,8 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
await boot.main(SimpleNamespace())
print_exc.assert_not_called()
@pytest.mark.asyncio
async def test_main_signal_handler_disposes_created_app(monkeypatch):
+2 -4
View File
@@ -50,8 +50,7 @@ class TestCheckDeps:
result = asyncio.run(check_deps())
assert 'requests' in result
assert 'openai' in result
assert result == ['requests', 'openai']
def test_check_deps_all_missing(self):
"""check_deps returns all deps when none present."""
@@ -66,8 +65,7 @@ class TestCheckDeps:
result = asyncio.run(check_deps())
# Should include all required_deps keys
assert len(result) == len(required_deps)
assert result == list(required_deps)
def test_required_deps_dict_exists(self):
"""required_deps dictionary is defined."""
+3 -5
View File
@@ -10,14 +10,10 @@ from __future__ import annotations
import logging
import os
import re
import langbot.pkg.core.bootutils.log as logmod
from langbot.pkg.core.bootutils.log import DailyGroupedRotatingFileHandler
# Mirror of the cleanup pattern in api/http/service/maintenance.py.
MAINTENANCE_LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
def _listing(directory):
return sorted(os.listdir(directory))
@@ -105,6 +101,8 @@ class TestDailyGroupedRotatingFileHandler:
assert f'langbot-{day}.log' in files
def test_all_filenames_match_maintenance_cleanup_pattern(self, tmp_path, monkeypatch):
from langbot.pkg.api.http.service.maintenance import LOG_FILE_PATTERN
box = {'date': '2026-06-08'}
self._patch_date(monkeypatch, box)
@@ -117,4 +115,4 @@ class TestDailyGroupedRotatingFileHandler:
handler.close()
for name in _listing(tmp_path):
assert MAINTENANCE_LOG_FILE_PATTERN.match(name), name
assert LOG_FILE_PATTERN.match(name), name
+5 -6
View File
@@ -119,12 +119,11 @@ class TestApplyEnvOverridesToConfig:
"""Test that '1', 'yes', 'on' are treated as true."""
load_config = get_load_config_module()
cfg = {'system': {'flag': False}}
for true_val in ['1', 'yes', 'on', 'TRUE']:
cfg = {'system': {'flag': False}}
env = {'SYSTEM__FLAG': true_val}
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg.copy())
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['flag'] is True
def test_override_float_value(self):
@@ -237,7 +236,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result['system']['name'] == 'default'
assert result == {'system': {'name': 'default'}}
def test_skip_env_vars_with_empty_path_segments(self, capsys):
"""Platform variables such as __CF_USER_TEXT_ENCODING are not config."""
@@ -249,7 +248,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
assert result == {'system': {'name': 'default'}}
assert capsys.readouterr().out == ''
def test_nested_config_path(self):
@@ -299,7 +298,7 @@ class TestApplyEnvOverridesToConfig:
with patch.dict(os.environ, env, clear=True):
result = load_config._apply_env_overrides_to_config(cfg)
assert result == cfg
assert result == {'system': {'name': 'default'}}
def test_multiple_env_vars_override(self):
"""Test multiple env vars applied in order."""
+7 -32
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import inspect
from unittest.mock import MagicMock
import pytest
@@ -41,11 +42,11 @@ class TestStageClassDecorator:
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class
@stage_class('TestStage')
class TestStage:
value = 42
# Class attributes should be preserved
decorated = stage_class('TestStage')(TestStage)
assert decorated is TestStage
assert TestStage.value == 42
def test_stage_class_multiple_stages(self):
@@ -125,25 +126,15 @@ class TestBootingStageAbstract:
# Abstract class has name attribute defined as None
assert hasattr(BootingStage, 'name')
@pytest.mark.asyncio
async def test_booting_stage_run_signature(self):
"""run method receives Application parameter."""
def test_booting_stage_run_signature(self):
"""The production abstract stage accepts an Application asynchronously."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import BootingStage
class TestStage(BootingStage):
name = 'TestStage'
async def run(self, ap):
self.ap_received = ap
stage = TestStage()
mock_ap = MagicMock()
await stage.run(mock_ap)
assert stage.ap_received == mock_ap
assert list(inspect.signature(BootingStage.run).parameters) == ['self', 'ap']
assert inspect.iscoroutinefunction(BootingStage.run)
class TestPreregisteredStages:
@@ -160,19 +151,3 @@ class TestPreregisteredStages:
from langbot.pkg.core.stage import preregistered_stages
assert isinstance(preregistered_stages, dict)
def test_preregistered_stages_key_is_string(self):
"""Registry keys are stage names (strings)."""
mocks = self._make_stage_import_mocks()
with isolated_sys_modules(mocks):
from langbot.pkg.core.stage import stage_class, preregistered_stages
preregistered_stages.clear()
@stage_class('MyStage')
class MyStage:
pass
for key in preregistered_stages:
assert isinstance(key, str)
+31 -15
View File
@@ -15,7 +15,7 @@ import asyncio
import contextvars
import inspect
import sys
from unittest.mock import Mock, MagicMock
from unittest.mock import Mock, MagicMock, patch
from contextlib import contextmanager
from typing import Generator
@@ -181,12 +181,14 @@ class TestTaskContext:
ctx.set_current_action('test_action')
ctx.trace('Test message')
ctx.metadata['key'] = 'value'
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
result = ctx.to_dict()
assert result['current_action'] == 'test_action'
assert 'Test message' in result['log']
assert result['metadata'] == {'key': 'value'}
assert result['metadata'] == {'key': 'value', 'count': 5, 'items': ['a', 'b', 'c']}
def test_static_new_factory(self):
"""Test TaskContext.new() factory method."""
@@ -209,19 +211,9 @@ class TestTaskContext:
ctx1 = TaskContext.placeholder()
ctx2 = TaskContext.placeholder()
assert isinstance(ctx1, TaskContext)
assert ctx1 is ctx2
def test_metadata_is_mutable_dict(self):
"""Test that metadata is a mutable dict."""
TaskContext, _, _ = get_taskmgr_classes()
ctx = TaskContext()
ctx.metadata['count'] = 5
ctx.metadata['items'] = ['a', 'b', 'c']
assert ctx.metadata['count'] == 5
assert len(ctx.metadata['items']) == 3
class TestTaskWrapper:
"""Tests for TaskWrapper class."""
@@ -346,7 +338,8 @@ class TestTaskWrapper:
async def successful_coro():
return {'file_id': 'file-a'}
wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store')
with patch('time.time', return_value=1720000000.25):
wrapper = TaskWrapper(mock_app, successful_coro(), kind='knowledge_base.store')
await wrapper.task
result = wrapper.to_public_dict()
@@ -358,7 +351,7 @@ class TestTaskWrapper:
'status': 'succeeded',
'error': None,
'result': {'file_id': 'file-a'},
'created_at': result['created_at'],
'created_at': 1720000000.25,
}
assert 'runtime' not in result
assert 'traceback' not in str(result).lower()
@@ -429,6 +422,29 @@ class TestTaskWrapper:
class TestAsyncTaskManager:
"""Tests for AsyncTaskManager class."""
@pytest.mark.asyncio
async def test_completed_retention_keeps_newest_tasks_and_running_task(self):
_, _, AsyncTaskManager = get_taskmgr_classes()
mock_app = create_mock_app()
mock_app.instance_config.data['system']['task_retention']['completed_limit'] = 2
manager = AsyncTaskManager(mock_app)
running = manager.create_task(asyncio.Event().wait())
async def immediate_coro():
return 'done'
try:
completed = [manager.create_task(immediate_coro()) for _ in range(2)]
await asyncio.gather(*(wrapper.task for wrapper in completed))
assert manager.tasks == [running, *completed]
newest = manager.create_task(immediate_coro())
await newest.task
assert manager.tasks == [running, completed[1], newest]
finally:
running.cancel()
await asyncio.gather(running.task, return_exceptions=True)
@pytest.mark.asyncio
async def test_create_task_adds_to_list(self):
"""Test that create_task adds task to tasks list."""
@@ -46,4 +46,29 @@ def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exist
assert conn.execute(sa.select(migrated)).mappings().one() == row
migration.downgrade()
assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')}
restored = sa.Table('bots', sa.MetaData(), autoload_with=conn)
bindings = conn.execute(sa.select(restored.c.event_bindings)).scalar_one()
assert bindings == [
primary,
{
'id': 'plugin_processor:one',
'event_pattern': '*',
'target_type': 'event_processor',
'target_uuid': 'one',
'enabled': True,
'filters': [],
'priority': 0,
'order': 1,
},
{
'id': 'plugin_processor:two',
'event_pattern': '*',
'target_type': 'event_processor',
'target_uuid': 'two',
'enabled': False,
'filters': [],
'priority': 0,
'order': 2,
},
]
engine.dispose()
@@ -13,7 +13,7 @@ from __future__ import annotations
import sys
from contextlib import contextmanager
from typing import Generator
from unittest.mock import MagicMock, Mock
from unittest.mock import MagicMock
import pytest
@@ -174,40 +174,3 @@ class TestManagerClassDecorator:
# BaseDatabaseManager has name annotation (type hint)
# Check __annotations__ for the type hint
assert 'name' in database.BaseDatabaseManager.__annotations__
def test_decorated_class_inherits_from_base(self):
"""Test that decorated class properly inherits BaseDatabaseManager."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('test_inherit')
class TestChild(database.BaseDatabaseManager):
async def initialize(self):
pass
assert issubclass(TestChild, database.BaseDatabaseManager)
# Has abstract method requirement satisfied
assert hasattr(TestChild, 'initialize')
def test_decorator_preserves_class_methods(self):
"""Test that decorator preserves existing class methods."""
database = get_database_module()
database.preregistered_managers.clear()
@database.manager_class('preserve_test')
class ManagerWithMethods(database.BaseDatabaseManager):
custom_attr = 'test_value'
async def initialize(self):
pass
def custom_method(self):
return self.custom_attr
assert ManagerWithMethods.custom_attr == 'test_value'
# Create instance to test method (with mock app)
mock_app = Mock()
instance = ManagerWithMethods(mock_app)
assert instance.custom_method() == 'test_value'
@@ -50,8 +50,8 @@ class TestExecuteAsync:
# Execute a simple select
await mgr.execute_async(sqlalchemy.select(1))
mock_conn.execute.assert_called_once()
mock_conn.commit.assert_called_once()
mock_conn.execute.assert_awaited_once()
mock_conn.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_execute_async_returns_result(self):
@@ -39,4 +39,6 @@ def test_two_populated_tables_fail_without_discarding_either():
with pytest.raises(RuntimeError, match='Both .* contain state'):
migration.upgrade()
assert set(sa.inspect(conn).get_table_names()) == {'agent_runner_state', 'runner_state'}
assert conn.execute(sa.text('SELECT id FROM agent_runner_state')).all() == [(1,)]
assert conn.execute(sa.text('SELECT id FROM runner_state')).all() == [(1,)]
engine.dispose()
@@ -81,7 +81,7 @@ class TestSerializeModel:
result = mgr.serialize_model(TestModel, instance)
assert '2024-01-15' in result['created_at']
assert result['created_at'] == '2024-01-15T10:30:45+00:00'
assert isinstance(result['created_at'], str)
def test_serialize_none_datetime(self):
+69 -19
View File
@@ -465,8 +465,19 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid=None, # None -> disabled
)
# Should have called query_pool.add_query
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=execution_context(),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
@pytest.mark.asyncio
async def test_enabled_buffers_message(self):
@@ -505,8 +516,27 @@ class TestMessageAggregatorAddMessage:
pipeline_uuid='test-pipeline',
)
# Should have buffered the message
assert len(agg.buffers) == 1
context = execution_context(pipeline_uuid='test-pipeline')
key = aggregation_key(context, pipeline_uuid='test-pipeline')
assert list(agg.buffers) == [key]
assert agg.buffers[key].execution_context == context
assert len(agg.buffers[key].messages) == 1
pending = agg.buffers[key].messages[0]
assert {key: value for key, value in vars(pending).items() if key != 'timestamp'} == {
'execution_context': context,
'bot_uuid': 'test-bot',
'launcher_type': provider_session.LauncherTypes.PERSON,
'launcher_id': 12345,
'sender_id': 12345,
'message_event': event,
'message_chain': chain,
'adapter': adapter,
'pipeline_uuid': 'test-pipeline',
'routed_by_rule': False,
'variables': None,
}
app.query_pool.add_query.assert_not_awaited()
await agg.flush_all()
@pytest.mark.asyncio
async def test_control_variables_bypass_aggregation(self):
@@ -560,19 +590,18 @@ class TestMessageAggregatorAddMessage:
agg = aggregator.MessageAggregator(app)
chain = text_chain('hello')
event = friend_message_event(chain)
adapter = mock_adapter()
# Add messages up to MAX_BUFFER_MESSAGES
for i in range(aggregator.MAX_BUFFER_MESSAGES):
chain = text_chain(f'message {i}')
await agg.add_message(
execution_context=execution_context(pipeline_uuid='test-pipeline'),
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_event=friend_message_event(chain),
message_chain=chain,
adapter=adapter,
pipeline_uuid='test-pipeline',
@@ -588,6 +617,14 @@ class TestMessageAggregatorAddMessage:
'test-pipeline',
)
assert session_id not in agg.buffers or len(agg.buffers[session_id].messages) == 0
app.query_pool.add_query.assert_awaited_once()
forwarded = app.query_pool.add_query.call_args.kwargs
assert str(forwarded['message_chain']) == '\n'.join(
f'message {i}' for i in range(aggregator.MAX_BUFFER_MESSAGES)
)
assert forwarded['execution_context'] == context
assert forwarded['bot_uuid'] == 'test-bot'
assert forwarded['pipeline_uuid'] == 'test-pipeline'
class TestMessageAggregatorMerge:
@@ -658,10 +695,7 @@ class TestMessageAggregatorMerge:
merged = agg._merge_messages([pending1, pending2])
# Should contain both messages with separator
merged_str = str(merged.message_chain)
assert 'hello' in merged_str
assert 'world' in merged_str
assert str(merged.message_chain) == 'hello\nworld'
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
@@ -760,7 +794,19 @@ class TestMessageAggregatorFlush:
await agg._flush_buffer(key, context)
assert app.query_pool.add_query.called
app.query_pool.add_query.assert_awaited_once_with(
execution_context=context,
bot_uuid='test-bot',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
adapter=adapter,
pipeline_uuid=None,
routed_by_rule=False,
variables=None,
)
assert key not in agg.buffers
@pytest.mark.asyncio
@@ -825,8 +871,8 @@ class TestMessageAggregatorFlushAll:
app = make_aggregator_app()
agg = aggregator.MessageAggregator(app)
chain = text_chain('hello')
event = friend_message_event(chain)
first_chain = text_chain('first')
second_chain = text_chain('second')
adapter = mock_adapter()
# Create two buffers
@@ -836,8 +882,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_event=event,
message_chain=chain,
message_event=friend_message_event(first_chain),
message_chain=first_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -848,8 +894,8 @@ class TestMessageAggregatorFlushAll:
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=67890,
sender_id=67890,
message_event=event,
message_chain=chain,
message_event=friend_message_event(second_chain),
message_chain=second_chain,
adapter=adapter,
pipeline_uuid=None,
)
@@ -875,7 +921,11 @@ class TestMessageAggregatorFlushAll:
# Both buffers should be flushed
assert len(agg.buffers) == 0
assert app.query_pool.add_query.call_count == 2
assert app.query_pool.add_query.await_count == 2
assert [
(sent.kwargs['launcher_id'], str(sent.kwargs['message_chain']), sent.kwargs['execution_context'])
for sent in app.query_pool.add_query.await_args_list
] == [(12345, 'first', context), (67890, 'second', context)]
class TestMessageAggregatorWorkspaceIsolation:
+45 -9
View File
@@ -89,6 +89,25 @@ def set_runner(fake_app):
return _set_runner
@pytest.fixture
def chat_session():
from langbot_plugin.api.entities.builtin.provider.session import Session, Conversation, LauncherTypes
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
return Session(
launcher_type=LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
bot_uuid='test-bot-uuid',
using_conversation=Conversation(
prompt=Prompt(name='default', messages=[]),
messages=[],
pipeline_uuid='test-pipeline-uuid',
bot_uuid='test-bot-uuid',
),
)
# ============== CACHED LAZY IMPORTS ==============
_chat_handler_module = None
@@ -189,7 +208,7 @@ class TestChatMessageHandlerReal:
async def test_user_message_alter_string(self, fake_app, mock_event_ctx, set_runner):
"""user_message_alter as string updates query.user_message."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
chat = get_chat_handler()
@@ -200,7 +219,8 @@ class TestChatMessageHandlerReal:
query = text_query('original')
query.adapter = Mock()
query.adapter.is_stream_output_supported = AsyncMock(return_value=False)
query.user_message = Message(role='user', content=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('original')])
received_contents = []
class QuickRunner:
name = 'local-agent'
@@ -210,6 +230,7 @@ class TestChatMessageHandlerReal:
self.config = config
async def run(self, query):
received_contents.append(query.user_message.content)
yield Message(role='assistant', content='ok')
set_runner(QuickRunner)
@@ -220,10 +241,14 @@ class TestChatMessageHandlerReal:
async for result in handler.handle(query):
results.append(result)
assert query.user_message.content is not None
expected_content = [ContentElement.from_text('altered text')]
assert query.user_message.content == expected_content
assert received_contents == [expected_content]
@pytest.mark.asyncio
async def test_adapter_without_stream_method_defaults_non_stream(self, fake_app, mock_event_ctx, set_runner):
async def test_adapter_without_stream_method_defaults_non_stream(
self, fake_app, mock_event_ctx, set_runner, chat_session
):
"""Adapter without is_stream_output_supported defaults to non-stream."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement
@@ -237,6 +262,7 @@ class TestChatMessageHandlerReal:
query = text_query('test')
query.adapter = Mock(spec=[])
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class SingleRunner:
name = 'local-agent'
@@ -256,7 +282,9 @@ class TestChatMessageHandlerReal:
async for result in handler.handle(query):
results.append(result)
assert len(results) >= 1
assert len(results) == 1
assert results[0].result_type == get_entities().ResultType.CONTINUE
assert [(message.role, message.content) for message in query.resp_messages] == [('assistant', 'response')]
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -315,7 +343,7 @@ class TestChatHandlerStreaming:
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.asyncio
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner):
async def test_streaming_chunks_collected(self, fake_app, mock_event_ctx, set_runner, chat_session):
"""Streaming produces multiple results."""
from tests.factories import text_query
from langbot_plugin.api.entities.builtin.provider.message import Message, ContentElement, MessageChunk
@@ -330,6 +358,7 @@ class TestChatHandlerStreaming:
query.adapter.is_stream_output_supported = AsyncMock(return_value=True)
query.adapter.create_message_card = AsyncMock()
query.user_message = Message(role='user', content=[ContentElement.from_text('test')])
query.session = chat_session
class StreamRunner:
name = 'local-agent'
@@ -340,17 +369,24 @@ class TestChatHandlerStreaming:
async def run(self, query):
yield MessageChunk(role='assistant', content='Hello', is_final=False)
yield MessageChunk(role='assistant', content=' World', is_final=True)
yield MessageChunk(role='assistant', content='Hello World', is_final=True)
set_runner(StreamRunner)
handler = chat.ChatMessageHandler(fake_app)
results = []
delivered = []
async for result in handler.handle(query):
results.append(result)
delivered.append(query.resp_messages[-1])
assert len(results) >= 1
assert len(results) == 2
assert all(result.result_type == get_entities().ResultType.CONTINUE for result in results)
assert [chunk.content for chunk in delivered] == ['Hello', 'Hello World']
assert [chunk.is_final for chunk in delivered] == [False, True]
assert len({chunk.resp_message_id for chunk in delivered}) == 1
query.adapter.create_message_card.assert_awaited_once()
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -536,7 +572,7 @@ class TestChatHandlerHelper:
chat = get_chat_handler()
handler = chat.ChatMessageHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
def test_response_size_limit_uses_instance_config(self, fake_app):
from langbot_plugin.api.entities.builtin.provider.message import Message
@@ -367,6 +367,8 @@ class TestCommandHandlerReal:
assert len(msg.content) == 2
assert msg.content[0].type == 'text'
assert msg.content[1].type == 'image_url'
assert msg.content[0].text == 'Here is the image:'
assert msg.content[1].image_url.url == 'https://example.com/image.png'
@pytest.mark.asyncio
async def test_command_result_empty_interrupts(self, fake_app, mock_event_ctx, mock_execute_factory):
@@ -410,4 +412,4 @@ class TestCommandHandlerHelper:
command = get_command_handler()
handler = command.CommandHandler(fake_app)
result = handler.cut_str('first line\nsecond line')
assert '...' in result
assert result == 'first line...'
+8 -8
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, Mock
from importlib import import_module
from types import SimpleNamespace
from tests.factories import (
FakeApp,
@@ -73,7 +74,7 @@ class TestLongTextProcessStageInit:
async def test_initialize_forward_strategy(self):
"""Initialize with strategy='forward' should use ForwardComponentStrategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -82,8 +83,7 @@ class TestLongTextProcessStageInit:
await stage.initialize(pipeline_config)
assert stage.strategy_impl is not None
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
@pytest.mark.asyncio
async def test_initialize_unknown_strategy_raises(self):
@@ -340,10 +340,11 @@ class TestLongTextProcessStageImageStrategy:
"""Tests for image strategy handling (requires PIL/font)."""
@pytest.mark.asyncio
async def test_image_strategy_missing_font_fallback(self):
async def test_image_strategy_missing_font_fallback(self, monkeypatch):
"""Missing font should fallback to forward strategy."""
longtext = get_longtext_module()
strategy = get_strategy_module()
forward = import_module('langbot.pkg.pipeline.longtext.strategies.forward')
monkeypatch.setattr(longtext, 'os', SimpleNamespace(name='posix', path=SimpleNamespace(exists=lambda _: False)))
app = FakeApp()
stage = longtext.LongTextProcessStage(app)
@@ -354,6 +355,5 @@ class TestLongTextProcessStageImageStrategy:
# On non-Windows without font, should fallback to forward
await stage.initialize(pipeline_config)
# Should have initialized (possibly with fallback strategy)
if stage.strategy_impl is not None:
assert isinstance(stage.strategy_impl, strategy.LongTextStrategy)
assert pipeline_config['output']['long-text-processing']['strategy'] == 'forward'
assert isinstance(stage.strategy_impl, forward.ForwardComponentStrategy)
+33 -12
View File
@@ -190,6 +190,7 @@ class TestPreProcessorNormalText:
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [('text', 'test message')]
class TestPreProcessorEmptyMessage:
@@ -254,6 +255,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='vision-model', abilities=['func_call', 'vision'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -261,9 +263,8 @@ class TestPreProcessorImageSegment:
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = preproc.PreProcessor(app)
# Image query with base64
# Exercise both supported image transports.
query = image_query(text='look at this', url=None)
# Set base64 on the image component
import langbot_plugin.api.entities.builtin.platform.message as platform_message
chain = platform_message.MessageChain(
@@ -283,17 +284,23 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
# User message should have content
assert result.new_query.user_message.content is not None
assert result.new_query.use_llm_model_uuid == 'vision-model'
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
expected_image = (
ContentElement.from_image_base64('data:image/png;base64,abc123')
if image_source == 'base64'
else ContentElement.from_image_url('https://cdn.example/image.png')
)
assert result.new_query.user_message.content == [ContentElement.from_text('look at this'), expected_image]
contents = [part.model_dump() for part in result.new_query.user_message.content]
attachments = QueryEntryAdapter._build_attachments(result.new_query, contents)
assert len(attachments) == 1
if image_source == 'url':
assert attachments[0]['url'] == 'https://cdn.example/image.png'
else:
assert attachments[0].get('content') or attachments[0].get('url')
assert attachments[0]['content'] == 'data:image/png;base64,abc123'
@pytest.mark.asyncio
async def test_image_without_vision_model(self):
@@ -315,6 +322,7 @@ class TestPreProcessorImageSegment:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='text-only-model', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_runner_descriptor(app)
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
mock_event_ctx = Mock()
@@ -327,6 +335,12 @@ class TestPreProcessorImageSegment:
result = await stage.process(query, 'PreProcessor')
assert result.result_type == preproc.entities.ResultType.CONTINUE
assert result.new_query.use_llm_model_uuid == 'text-only-model'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [
('text', 'describe this'),
('text', '[Image]'),
]
assert result.new_query.variables['user_message_text'] == 'describe this[Image]'
class TestPreProcessorModelSelection:
@@ -471,11 +485,18 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'launcher_type' in variables
assert 'launcher_id' in variables
assert 'sender_id' in variables
assert variables['sender_id'] == 67890
assert 'user_message_text' in variables
expected = {
'launcher_type': 'person',
'launcher_id': 12345,
'sender_id': 67890,
'session_id': 'person_12345',
'conversation_id': 'conv-123',
'msg_create_time': 1609459200,
'group_name': '',
'sender_name': 'TestUser',
'user_message_text': 'hello',
}
assert {key: variables[key] for key in expected} == expected
@pytest.mark.asyncio
async def test_group_variables_include_group_name(self):
@@ -509,8 +530,8 @@ class TestPreProcessorVariables:
result = await stage.process(query, 'PreProcessor')
variables = result.new_query.variables
assert 'group_name' in variables
assert 'sender_name' in variables
assert variables['group_name'] == 'TestGroup'
assert variables['sender_name'] == 'TestUser'
class TestPreProcessorToolSelection:
+6 -3
View File
@@ -272,11 +272,14 @@ class TestFixedWindowAlgo:
algo = fixedwin.FixedWindowAlgo(mock_app_for_algo)
await algo.initialize()
# release_access is empty in current implementation
await algo.require_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
containers = dict(algo.containers)
records = {key: dict(container.records) for key, container in containers.items()}
await algo.release_access(sample_query_with_rate_limit, provider_session.LauncherTypes.PERSON, '12345')
# Should not raise or change state
assert 'person_12345' not in algo.containers
assert algo.containers == containers
assert {key: dict(container.records) for key, container in algo.containers.items()} == records
# Original mock-based tests for RateLimit stage integration
+23 -12
View File
@@ -22,6 +22,7 @@ from tests.factories import (
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot_plugin.api.entities.builtin.provider.message import Message
def get_wrapper_module():
@@ -109,7 +110,7 @@ class TestResponseWrapperMessageChain:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(results[0].new_query.resp_message_chain) == 1
assert [str(chain) for chain in results[0].new_query.resp_message_chain] == ['response']
@pytest.mark.asyncio
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
@@ -171,12 +172,7 @@ class TestResponseWrapperCommand:
query.pipeline_config = pipeline_config
query.resp_message_chain = []
# Create a command response message
command_resp = Mock()
command_resp.role = 'command'
command_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Help info')])
)
command_resp = Message(role='command', content='Help info')
query.resp_messages = [command_resp]
results = []
@@ -185,8 +181,7 @@ class TestResponseWrapperCommand:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Check that prefix was added (via get_content_platform_message_chain)
command_resp.get_content_platform_message_chain.assert_called_once()
assert [str(chain) for chain in query.resp_message_chain] == ['[bot] Help info']
class TestResponseWrapperPlugin:
@@ -223,6 +218,7 @@ class TestResponseWrapperPlugin:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert [str(chain) for chain in query.resp_message_chain] == ['Plugin response']
class TestResponseWrapperAssistant:
@@ -316,8 +312,11 @@ class TestResponseWrapperAssistant:
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
# Event should have been emitted
app.plugin_connector.emit_event.assert_called()
assert [str(chain) for chain in query.resp_message_chain] == ['Hello back!']
app.plugin_connector.emit_event.assert_awaited_once()
event = app.plugin_connector.emit_event.await_args.args[0]
assert event.response_text == 'Hello back!'
assert event.funcs_called == []
@pytest.mark.asyncio
async def test_assistant_empty_content(self):
@@ -386,24 +385,36 @@ class TestResponseWrapperAssistant:
mock_tool_call = Mock()
mock_tool_call.function = Mock()
mock_tool_call.function.name = 'test_function'
other_tool_call = Mock()
other_tool_call.function.name = 'other_function'
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = 'Processing...'
assistant_resp.tool_calls = [mock_tool_call]
assistant_resp.tool_calls = [mock_tool_call, other_tool_call]
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Processing...')])
)
query.resp_messages = [assistant_resp]
results = []
delivered = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
delivered.append(str(query.resp_message_chain[-1]))
assert len(results) == 2
for result in results:
assert result.result_type == entities.ResultType.CONTINUE
assert app.plugin_connector.emit_event.await_count == 2
expected = ['Processing...', 'Calling test_function, other_function...']
assert delivered == expected
events = [sent.args[0] for sent in app.plugin_connector.emit_event.await_args_list]
assert [event.response_text for event in events] == expected
assert [event.funcs_called for event in events] == [
['test_function', 'other_function'],
['test_function', 'other_function'],
]
class TestResponseWrapperInterrupt:
@@ -613,6 +613,7 @@ def test_agent_event_keeps_image_file_and_voice_attachments():
)
agent_input = RuntimeBot._build_agent_input(event)
assert [attachment.type for attachment in agent_input.attachments] == ['image', 'file', 'voice']
assert agent_input.attachments[0].url == 'https://cdn.example.test/image.png'
assert agent_input.attachments[1].name == 'input.txt'
assert agent_input.attachments[1].url == 'https://cdn.example.test/input.txt'
assert agent_input.attachments[2].content == 'dGVzdA=='
@@ -3,7 +3,7 @@ from __future__ import annotations
import pathlib
import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
import yaml
@@ -401,8 +401,7 @@ async def test_discord_stream_keeps_tool_round_and_delivers_terminal_attachment(
assert 'stream' in adapter._stream_buffer
adapter.reply_message.assert_not_awaited()
await adapter.reply_message_chunk(source, {'resp_message_id': 'stream'}, message, is_final=True)
adapter.reply_message.assert_awaited_once()
assert adapter.reply_message.await_args.args[1].root == [attachment]
adapter.reply_message.assert_awaited_once_with(source, message, False)
channel.send.assert_not_awaited()
assert not adapter._stream_buffer
@@ -471,26 +470,34 @@ async def test_discord_uncached_edit_fetches_message_without_duplicate_or_previe
@pytest.mark.asyncio
async def test_guild_event_reply_uses_system_channel(monkeypatch):
@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)])
async def test_guild_event_reply_uses_system_channel(monkeypatch, guild_id, expected_guild_id):
adapter = make_adapter()
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=456)))
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=channel))
get_guild = Mock(return_value=SimpleNamespace(system_channel=channel))
monkeypatch.setattr(adapter.bot, 'get_guild', get_guild)
adapter._get_channel = AsyncMock()
result = await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
assert result.message_id == 456
get_guild.assert_called_once_with(expected_guild_id)
channel.send.assert_awaited_once_with(content='Welcome')
adapter._get_channel.assert_not_awaited()
@pytest.mark.asyncio
async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(monkeypatch):
@pytest.mark.parametrize(('guild_id', 'expected_guild_id'), [('123', 123), ('456', 456)])
async def test_guild_event_reply_without_system_channel_does_not_pick_another_channel(
monkeypatch, guild_id, expected_guild_id
):
adapter = make_adapter()
monkeypatch.setattr(adapter.bot, 'get_guild', lambda guild_id: SimpleNamespace(system_channel=None))
get_guild = Mock(return_value=SimpleNamespace(system_channel=None))
monkeypatch.setattr(adapter.bot, 'get_guild', get_guild)
adapter._get_channel = AsyncMock()
with pytest.raises(ValueError, match='no system channel'):
await adapter.send_message(
'group', '123', platform_message.MessageChain([platform_message.Plain(text='Welcome')])
'group', guild_id, platform_message.MessageChain([platform_message.Plain(text='Welcome')])
)
get_guild.assert_called_once_with(expected_guild_id)
adapter._get_channel.assert_not_awaited()
@@ -137,10 +137,7 @@ async def test_officialaccount_message_converter_maps_components_to_passive_text
)
)
assert 'hi' in content
assert '[Image]' in content
assert '[File: a.txt]' in content
assert 'quoted' in content
assert content == 'hi\n[Image]\n[File: a.txt]\nquoted'
@pytest.mark.asyncio
@@ -11,12 +11,20 @@ from langbot.libs.openclaw_weixin_api.client import (
from langbot.libs.openclaw_weixin_api.types import ApiError
def test_cdn_crypto_helpers_round_trip():
original = b'tenant-media' * 128
def test_cdn_crypto_helpers_round_trip(monkeypatch):
# AES-128-ECB with PKCS7 padding, independently generated using OpenSSL.
key = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c')
original = bytes.fromhex('6bc1bee22e409f96e93d7e117393172a')
expected_ciphertext = bytes.fromhex('3ad77bb40d7a3660a89ecaf32466ef97a254be88e037ddd9d79fb6411c3f9df8')
monkeypatch.setattr('langbot.libs.openclaw_weixin_api.client.os.urandom', lambda size: key)
aes_key_hex, _encoded_key, encrypted, _raw_md5 = _encrypt_cdn_payload(original)
aes_key_hex, encoded_key, encrypted, raw_md5 = _encrypt_cdn_payload(original)
assert _decrypt_cdn_payload(encrypted, bytes.fromhex(aes_key_hex)) == original
assert aes_key_hex == '2b7e151628aed2a6abf7158809cf4f3c'
assert encoded_key == 'MmI3ZTE1MTYyOGFlZDJhNmFiZjcxNTg4MDljZjRmM2M='
assert encrypted == expected_ciphertext
assert raw_md5 == 'f2ecfb66e72f88f217e659ac75b1075f'
assert _decrypt_cdn_payload(expected_ciphertext, key) == original
@pytest.mark.asyncio
@@ -164,12 +164,7 @@ async def test_slack_message_converter_maps_common_components_to_text():
)
)
assert 'hi' in text
assert '<@U-2>' in text
assert '<!channel>' in text
assert 'https://example.test/a.png' in text
assert 'a.txt' in text
assert 'quoted' in text
assert text == 'hi\n<@U-2>\n<!channel>\nhttps://example.test/a.png\na.txt\nquoted'
@pytest.mark.asyncio
@@ -604,6 +604,17 @@ async def test_telegram_platform_apis_call_underlying_bot_methods():
assert invite['invite_link'] == 'https://t.me/+abc'
assert await PLATFORM_API_MAP['answer_callback_query'](bot, {'callback_query_id': 'cb'}) == {'ok': True}
bot.pin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2, disable_notification=False)
bot.unpin_chat_message.assert_awaited_once_with(chat_id=1, message_id=2)
bot.unpin_all_chat_messages.assert_awaited_once_with(chat_id=1)
bot.get_chat_administrators.assert_awaited_once_with(chat_id=1)
bot.set_chat_title.assert_awaited_once_with(chat_id=1, title='New')
bot.set_chat_description.assert_awaited_once_with(chat_id=1, description='Desc')
bot.get_chat_member_count.assert_awaited_once_with(chat_id=1)
bot.send_chat_action.assert_awaited_once_with(chat_id=1, action='typing')
bot.create_chat_invite_link.assert_awaited_once_with(chat_id=1, name='invite', expire_date=None, member_limit=None)
bot.answer_callback_query.assert_awaited_once_with(callback_query_id='cb', text=None, show_alert=False, url=None)
@pytest.mark.asyncio
async def test_telegram_unmute_member_uses_current_chat_permissions_fields():
@@ -90,17 +90,23 @@ async def test_image_defaults_to_png():
adapter, _, _ = _make_adapter()
chain = [{'type': 'Image', 'path': f'{_UPLOAD_PREFIX}blob'}]
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:image/png;base64,')
assert chain[0]['base64'] == 'data:image/png;base64,aGVsbG8='
@pytest.mark.asyncio
async def test_voice_uses_guessed_or_wav_mimetype():
async def test_voice_uses_guessed_or_wav_mimetype(monkeypatch):
adapter, storage_mgr, _ = _make_adapter()
chain = [{'type': 'Voice', 'path': f'{_UPLOAD_PREFIX}clip.wav'}]
path = f'{_UPLOAD_PREFIX}clip.unknown'
chain = [{'type': 'Voice', 'path': path}]
guess_type = Mock(return_value=(None, None))
monkeypatch.setattr('mimetypes.guess_type', guess_type)
await adapter._process_image_components(_make_connection(), chain)
assert chain[0]['base64'].startswith('data:audio/')
guess_type.assert_called_once_with(path)
assert chain[0]['base64'] == 'data:audio/wav;base64,aGVsbG8='
assert chain[0]['path'] == ''
storage_mgr.delete_scoped_object_key.assert_awaited_once()
storage_mgr.delete_scoped_object_key.assert_awaited_once_with(_CONTEXT, path, expected_owner_type='upload_image')
@pytest.mark.asyncio
+38
View File
@@ -0,0 +1,38 @@
"""Database fixture for plugin persistence behavior tests."""
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.user import User
from langbot.pkg.entity.persistence.workspace import Workspace
@pytest.fixture
async def plugin_db():
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
try:
async with engine.begin() as connection:
await connection.execute(text('PRAGMA foreign_keys=ON'))
await connection.run_sync(
Base.metadata.create_all,
tables=[User.__table__, Workspace.__table__, PluginSetting.__table__, BinaryStorage.__table__],
)
async with AsyncSession(engine, expire_on_commit=False) as session:
session.add_all(
Workspace(
uuid=workspace,
instance_uuid='instance-a',
name=workspace,
slug=workspace,
source='cloud_projection',
)
for workspace in ('workspace-a', 'workspace-b')
)
await session.flush()
yield session
finally:
await engine.dispose()
@@ -123,6 +123,7 @@ class TestExtractDepsMetadata:
# Should find requirements.txt in subdirectory
assert task_context.metadata['deps_total'] == 2
assert task_context.metadata['deps_list'] == ['pytest', 'black']
def test_archive_preview_rejects_extreme_compression_ratio(self):
from langbot.pkg.plugin.connector import inspect_plugin_archive_metadata
@@ -147,13 +148,6 @@ class TestParsePluginId:
assert author == 'myauthor'
assert name == 'myplugin'
def test_parse_plugin_id_empty(self):
"""Empty plugin ID is invalid."""
from langbot.pkg.plugin.connector import PluginRuntimeConnector
with pytest.raises(ValueError):
PluginRuntimeConnector._parse_plugin_id('')
@pytest.mark.asyncio
async def test_marketplace_response_reader_is_bounded():
@@ -583,11 +583,15 @@ async def test_local_install_cleans_untracked_legacy_plugin_before_runtime_apply
@pytest.mark.asyncio
@pytest.mark.parametrize(('remaining_references', 'statement_count'), [(1, 1), (0, 2)])
@pytest.mark.parametrize('remaining_references', [1, 0])
async def test_artifact_cleanup_is_reference_counted_within_workspace(
remaining_references: int,
statement_count: int,
plugin_db,
):
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
app = SimpleNamespace(
instance_config=SimpleNamespace(data={'plugin': {'enable': True}}),
deployment=SimpleNamespace(mode='cloud'),
@@ -598,19 +602,38 @@ async def test_artifact_cleanup_is_reference_counted_within_workspace(
workspace_uuid='workspace-a',
placement_generation=1,
)
statements = []
async def execute(statement):
statements.append(statement)
return SimpleNamespace(scalar_one=lambda: remaining_references)
digest = 'a' * 64
settings = [('workspace-b', digest), ('workspace-a', 'b' * 64)]
if remaining_references:
settings.append(('workspace-a', digest))
plugin_db.add_all(
PluginSetting(workspace_uuid=workspace, plugin_author='author', plugin_name=artifact, artifact_digest=artifact)
for workspace, artifact in settings
)
plugin_db.add_all(
BinaryStorage(
workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'pkg'
)
for workspace, key, owner_type, owner in [
('workspace-a', 'target', 'plugin_artifact', digest),
('workspace-b', 'other-workspace', 'plugin_artifact', digest),
('workspace-a', 'other-artifact', 'plugin_artifact', 'b' * 64),
('workspace-a', 'other-owner-type', 'plugin', digest),
]
)
await plugin_db.flush()
await connector._delete_artifact_if_unreferenced(
execution_context,
'a' * 64,
execute=execute,
digest,
execute=plugin_db.execute,
)
assert len(statements) == statement_count
remaining = (await plugin_db.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all()
expected = {'other-workspace', 'other-artifact', 'other-owner-type'}
if remaining_references:
expected.add('target')
assert set(remaining) == expected
@pytest.mark.asyncio
@@ -113,6 +113,7 @@ numpy"""
# Should find nested requirements.txt (ends with 'requirements.txt')
assert task_context.metadata.get('deps_total') == 2
assert task_context.metadata['deps_list'] == ['requests', 'flask']
def test_no_requirements_in_zip(self):
"""Test handling ZIP without requirements.txt."""
@@ -236,10 +236,16 @@ class TestInitializePluginSettings:
app.persistence_mgr.execute_async.assert_awaited_once()
@pytest.mark.asyncio
async def test_existing_desired_setting_remains_core_owned(self, app):
async def test_existing_desired_setting_remains_core_owned(self, app, plugin_db):
"""Runtime initialization validates identity without rewriting Core state."""
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
runtime_handler = make_handler(app)
existing_setting = SimpleNamespace(
existing_setting = PluginSetting(
workspace_uuid='workspace-a',
plugin_author='test-author',
plugin_name='test-plugin',
enabled=False,
priority=5,
config={'key': 'value'},
@@ -247,7 +253,29 @@ class TestInitializePluginSettings:
runtime_revision=1,
artifact_digest='a' * 64,
)
app.persistence_mgr.execute_async.return_value = make_result(existing_setting)
other_setting = PluginSetting(
workspace_uuid='workspace-b',
plugin_author='test-author',
plugin_name='test-plugin',
enabled=True,
)
plugin_db.add_all([other_setting, existing_setting])
await plugin_db.flush()
async def execute(statement):
result = await plugin_db.execute(statement)
return result.scalars() if statement.is_select or statement.is_update else result
app.persistence_mgr.execute_async.side_effect = execute
before = (
(
await plugin_db.execute(
sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid)
)
)
.mappings()
.all()
)
response = await runtime_handler.actions[RuntimeToLangBotAction.INITIALIZE_PLUGIN_SETTINGS.value](
{
@@ -260,6 +288,17 @@ class TestInitializePluginSettings:
assert response.code == 0
app.persistence_mgr.execute_async.assert_awaited_once()
plugin_db.expire_all()
after = (
(
await plugin_db.execute(
sqlalchemy.select(*PluginSetting.__table__.c).order_by(PluginSetting.workspace_uuid)
)
)
.mappings()
.all()
)
assert after == before
class TestSetBinaryStorage:
+60 -32
View File
@@ -107,41 +107,69 @@ class TestMakeRagErrorResponse:
class TestCleanupPluginData:
"""Tests for cleanup_plugin_data method."""
@pytest.mark.asyncio
async def test_deletes_plugin_settings(self):
"""Test that plugin settings are deleted."""
handler_module = get_handler_module()
@pytest.fixture
async def cleanup(self, plugin_db):
from langbot.pkg.entity.persistence.plugin import PluginSetting
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
from langbot_plugin.entities.io.context import InstallationBinding
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
# Mock the handler without connection (we only need ap)
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
# Call cleanup_plugin_data
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(
handler_instance, 'test-author', 'test-plugin'
plugin_db.add_all(
PluginSetting(workspace_uuid=workspace, plugin_author=author, plugin_name=name)
for workspace, author, name in [
('workspace-a', 'test-author', 'test-plugin'),
('workspace-b', 'test-author', 'test-plugin'),
('workspace-a', 'other-author', 'test-plugin'),
('workspace-a', 'test-author', 'other-plugin'),
]
)
# Verify plugin settings delete was called
calls = mock_app.persistence_mgr.execute_async.call_args_list
assert len(calls) >= 1
plugin_db.add_all(
BinaryStorage(
workspace_uuid=workspace, unique_key=key, key=key, owner_type=owner_type, owner=owner, value=b'data'
)
for workspace, key, owner_type, owner in [
('workspace-a', 'target', 'plugin', 'test-author/test-plugin'),
('workspace-b', 'other-workspace', 'plugin', 'test-author/test-plugin'),
('workspace-a', 'other-owner', 'plugin', 'other-author/test-plugin'),
('workspace-a', 'other-type', 'skill', 'test-author/test-plugin'),
]
)
await plugin_db.flush()
app = Mock()
app.persistence_mgr.execute_async = plugin_db.execute
handler = get_handler_module().RuntimeConnectionHandler(Mock(), AsyncMock(), app)
binding = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=1,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=1,
artifact_digest='a' * 64,
)
with handler.installation_scope(binding):
await handler.cleanup_plugin_data('test-author', 'test-plugin')
return plugin_db
@pytest.mark.asyncio
async def test_deletes_binary_storage(self):
async def test_deletes_plugin_settings(self, cleanup):
import sqlalchemy
from langbot.pkg.entity.persistence.plugin import PluginSetting
rows = (
await cleanup.execute(
sqlalchemy.select(PluginSetting.workspace_uuid, PluginSetting.plugin_author, PluginSetting.plugin_name)
)
).all()
assert set(rows) == {
('workspace-b', 'test-author', 'test-plugin'),
('workspace-a', 'other-author', 'test-plugin'),
('workspace-a', 'test-author', 'other-plugin'),
}
@pytest.mark.asyncio
async def test_deletes_binary_storage(self, cleanup):
"""Test that binary storage is deleted."""
handler_module = get_handler_module()
import sqlalchemy
from langbot.pkg.entity.persistence.bstorage import BinaryStorage
mock_app = Mock()
mock_app.persistence_mgr = AsyncMock()
mock_app.persistence_mgr.execute_async = AsyncMock()
handler_instance = Mock(spec=handler_module.RuntimeConnectionHandler)
handler_instance.ap = mock_app
await handler_module.RuntimeConnectionHandler.cleanup_plugin_data(handler_instance, 'author', 'plugin-name')
# Should have at least 2 calls: one for settings, one for binary storage
assert mock_app.persistence_mgr.execute_async.call_count >= 2
keys = (await cleanup.execute(sqlalchemy.select(BinaryStorage.unique_key))).scalars().all()
assert set(keys) == {'other-workspace', 'other-owner', 'other-type'}
@@ -5,10 +5,6 @@ import pytest
from langbot.pkg.plugin.connector import PluginRuntimeConnector
def test_parse_plugin_id_accepts_author_name():
assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine')
@pytest.mark.parametrize(
'plugin_id',
[
@@ -1048,10 +1048,8 @@ class TestInvokeRerank:
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
)
assert len(results) == 3
# Scores should be normalized
assert results[0]['index'] == 0
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
assert [result['index'] for result in results] == [0, 1, 2]
assert [result['relevance_score'] for result in results] == pytest.approx([1.0, 0.0, 0.76923076923])
@pytest.mark.asyncio
async def test_invoke_rerank_normalization(self):
@@ -1297,8 +1295,8 @@ class TestConvertMessages:
result = requester._convert_messages(messages)
assert len(result) == 1
# LiteLLM handles multiple text parts, we pass them through
assert isinstance(result[0]['content'], list)
assert result[0]['role'] == 'user'
assert result[0]['content'] == [{'type': 'text', 'text': 'Hello'}, {'type': 'text', 'text': 'World'}]
class TestScanModels:
@@ -56,11 +56,8 @@ def test_requester_base_class_is_abstract():
mock_app = SimpleNamespace()
mock_app.logger = Mock()
# ProviderAPIRequester has abstract methods, but ABCMeta allows instantiation
# if you don't call the abstract methods. Test that it has abstract methods.
assert hasattr(requester.ProviderAPIRequester, 'invoke_llm')
# Check that invoke_llm is abstract
assert hasattr(requester.ProviderAPIRequester.invoke_llm, '__isabstractmethod__')
with pytest.raises(TypeError, match='abstract'):
requester.ProviderAPIRequester(mock_app, {})
def test_requester_default_config_merged():
@@ -250,16 +247,6 @@ def test_runtime_provider_initialization(runtime_provider, fake_persistence_data
assert isinstance(provider.requester, requester.ProviderAPIRequester)
def test_runtime_provider_has_invoke_methods(runtime_provider):
"""Test RuntimeProvider has invoke methods that delegate to requester."""
provider = runtime_provider
assert hasattr(provider, 'invoke_llm')
assert hasattr(provider, 'invoke_llm_stream')
assert hasattr(provider, 'invoke_embedding')
assert hasattr(provider, 'invoke_rerank')
@pytest.mark.asyncio
async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_llm_model):
"""Test RuntimeProvider.invoke_llm delegates to requester."""
@@ -404,6 +391,7 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
assert len(chunks) == 1
assert chunks[0].role == 'assistant'
assert [(part.type, part.text) for part in chunks[0].content] == [('text', 'Fake stream chunk')]
@pytest.mark.asyncio
@@ -513,13 +501,11 @@ def test_runtime_llm_model_initialization(runtime_llm_model, fake_persistence_da
assert model.reasoning_config_override is None
def test_runtime_llm_model_provider_ref(runtime_llm_model):
def test_runtime_llm_model_provider_ref(runtime_llm_model, runtime_provider):
"""Test RuntimeLLMModel has correct provider reference."""
model = runtime_llm_model
assert model.provider.provider_entity is not None
assert model.provider.token_mgr is not None
assert model.provider.requester is not None
assert model.provider is runtime_provider
# ============================================================================
@@ -365,7 +365,10 @@ class TestSessionManagerGetConversation:
)
assert conversation.prompt.name == 'default'
assert len(conversation.prompt.messages) == 2
assert [(message.role, message.content) for message in conversation.prompt.messages] == [
('system', 'System message'),
('user', 'User message'),
]
class TestSessionManagerWorkspaceIsolation:
+6 -2
View File
@@ -300,7 +300,9 @@ class TestStoreFileTask:
task_context.set_current_action.assert_called_once_with('Processing file')
kb.ap.storage_mgr.storage_provider.size.assert_awaited_once_with(object_key)
kb._ingest_document.assert_awaited_once()
assert kb.ap.persistence_mgr.execute_async.await_count == 2
statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list]
assert [params['status'] for params in statements] == ['processing', 'completed']
assert statements[-1]['engine_document_id'] == 'file-uuid'
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
@pytest.mark.asyncio
@@ -316,7 +318,9 @@ class TestStoreFileTask:
with pytest.raises(Exception, match='parser failed'):
await kb._store_file_task(CONTEXT, file_obj, task_context)
assert kb.ap.persistence_mgr.execute_async.await_count == 2
statements = [call.args[0].compile().params for call in kb.ap.persistence_mgr.execute_async.await_args_list]
assert [params['status'] for params in statements] == ['processing', 'failed']
assert statements[-1]['engine_document_id'] == 'file-uuid'
kb.ap.storage_mgr.storage_provider.delete.assert_awaited_once_with(object_key)
+3 -1
View File
@@ -662,7 +662,9 @@ class TestRuntimeKnowledgeBaseRetrieve:
assert len(results) == 1
assert results[0].id == 'doc1'
assert hasattr(results[0], 'content')
assert results[0].content[0].text == 'test content'
assert results[0].metadata == {'source': 'file.pdf'}
assert results[0].distance == 0.15
class TestRuntimeKnowledgeBaseDispose:
+18 -18
View File
@@ -12,8 +12,9 @@ Uses moto library to mock AWS S3 service.
from __future__ import annotations
import pytest
from unittest.mock import Mock
from unittest.mock import Mock, patch
from importlib import import_module
from botocore.exceptions import ClientError
def get_s3storage_module():
@@ -57,14 +58,6 @@ def s3_mock():
class TestS3StorageProviderInit:
"""Tests for S3StorageProvider initialization."""
def test_init_stores_app_reference(self):
"""Test that __init__ stores the Application reference."""
s3storage = get_s3storage_module()
mock_app = Mock()
provider = s3storage.S3StorageProvider(mock_app)
assert provider.ap is mock_app
def test_init_s3_client_none(self):
"""Test that s3_client starts as None."""
s3storage = get_s3storage_module()
@@ -102,6 +95,7 @@ class TestS3StorageProviderWithMoto:
assert provider.s3_client is not None
assert provider.bucket_name == 'test-langbot-storage'
mock_app_with_s3_config.logger.info.assert_called()
assert [bucket['Name'] for bucket in s3_mock.list_buckets()['Buckets']] == ['test-langbot-storage']
@pytest.mark.asyncio
async def test_initialize_uses_existing_bucket(self, mock_app_with_s3_config, s3_mock):
@@ -112,11 +106,16 @@ class TestS3StorageProviderWithMoto:
s3_mock.create_bucket(Bucket='test-langbot-storage')
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
await provider.initialize()
with (
patch.object(s3storage.boto3.session.Session, 'client', return_value=s3_mock),
patch.object(s3_mock, 'head_bucket', wraps=s3_mock.head_bucket) as head_bucket,
patch.object(s3_mock, 'create_bucket', wraps=s3_mock.create_bucket) as create_bucket,
):
await provider.initialize()
assert provider.s3_client is not None
# Bucket creation log should not be called since bucket exists
# Note: moto may still call head_bucket successfully
assert provider.s3_client is s3_mock
head_bucket.assert_called_once_with(Bucket='test-langbot-storage')
create_bucket.assert_not_called()
@pytest.mark.asyncio
async def test_save_and_load_bytes(self, mock_app_with_s3_config, s3_mock):
@@ -232,7 +231,7 @@ class TestS3StorageProviderWithMoto:
@pytest.mark.asyncio
async def test_delete_dir_recursive_handles_trailing_slash(self, mock_app_with_s3_config, s3_mock):
"""Test that delete_dir_recursive handles path without trailing slash."""
"""Test that delete_dir_recursive handles a path with a trailing slash."""
s3storage = get_s3storage_module()
provider = s3storage.S3StorageProvider(mock_app_with_s3_config)
@@ -241,8 +240,7 @@ class TestS3StorageProviderWithMoto:
# Save object
await provider.save('mydir/file.txt', b'data')
# Delete without trailing slash
await provider.delete_dir_recursive('mydir')
await provider.delete_dir_recursive('mydir/')
# Verify deleted
assert await provider.exists('mydir/file.txt') is False
@@ -325,8 +323,9 @@ class TestS3StorageProviderErrorHandling:
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
with pytest.raises(ClientError) as exc_info:
await provider.load('nonexistent.txt')
assert exc_info.value.response['Error']['Code'] == 'NoSuchKey'
@pytest.mark.asyncio
async def test_size_nonexistent_raises_error(self, s3_mock):
@@ -350,5 +349,6 @@ class TestS3StorageProviderErrorHandling:
provider = s3storage.S3StorageProvider(mock_app)
await provider.initialize()
with pytest.raises(Exception):
with pytest.raises(ClientError) as exc_info:
await provider.size('nonexistent.txt')
assert exc_info.value.response['Error']['Code'] == '404'
@@ -9,7 +9,6 @@ from unittest.mock import Mock, AsyncMock, patch
from langbot.pkg.storage.mgr import StorageMgr
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
from langbot.pkg.storage.providers.s3storage import S3StorageProvider
class TestStorageMgr:
@@ -21,77 +20,6 @@ class TestStorageMgr:
storage_mgr = StorageMgr(mock_app)
assert storage_mgr.ap == mock_app
@pytest.mark.asyncio
async def test_initialize_default_local(self):
"""Should use local storage by default."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
mock_app.logger.info.assert_called()
@pytest.mark.asyncio
async def test_initialize_with_explicit_local(self):
"""Should use local storage when explicitly configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 'local'}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_with_s3(self):
"""Should use S3 storage when configured."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 's3', 's3': {'endpoint_url': 'https://s3.amazonaws.com'}}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(S3StorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, S3StorageProvider)
@pytest.mark.asyncio
async def test_initialize_invalid_type_defaults_to_local(self):
"""Should default to local storage for invalid storage type."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {'storage': {'use': 'invalid_type'}}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock):
await storage_mgr.initialize()
assert isinstance(storage_mgr.storage_provider, LocalStorageProvider)
@pytest.mark.asyncio
async def test_initialize_calls_provider_initialize(self):
"""Should call the provider's initialize method."""
mock_app = Mock()
mock_app.instance_config = Mock()
mock_app.instance_config.data = {}
mock_app.logger = Mock()
storage_mgr = StorageMgr(mock_app)
with patch.object(LocalStorageProvider, 'initialize', new_callable=AsyncMock) as mock_init:
await storage_mgr.initialize()
mock_init.assert_called_once()
@pytest.mark.asyncio
async def test_shutdown_delegates_to_active_provider(self):
storage_mgr = StorageMgr(Mock())
@@ -234,6 +234,12 @@ class TestTriggerEvent:
await manager.trigger_event('new_event')
assert 'new_event' in manager._triggered_events
statements = mock_app.persistence_mgr.execute_async.await_args_list
assert len(statements) == 2
assert statements[-1].args[0].compile().params == {
'key': 'survey_triggered_events',
'value': '["new_event"]',
}
class TestRecordBotResponseSuccess:
@@ -255,8 +261,9 @@ class TestRecordBotResponseSuccess:
await manager.record_bot_response_success()
assert manager._bot_response_count == 1
# select + insert for the count key
assert mock_app.persistence_mgr.execute_async.call_count >= 2
statements = mock_app.persistence_mgr.execute_async.await_args_list
assert len(statements) == 2
assert statements[-1].args[0].compile().params == {'key': 'survey_bot_response_count', 'value': '1'}
@pytest.mark.asyncio
async def test_fires_milestone_event_at_threshold(self):
+9 -4
View File
@@ -83,7 +83,9 @@ class TestTelemetrySendEarlyReturn:
manager.telemetry_config = {}
# Should return without making HTTP calls
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
# No HTTP client should be created, no logs should be written
mock_app.logger.debug.assert_not_called()
@@ -99,7 +101,9 @@ class TestTelemetrySendEarlyReturn:
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'disable_telemetry': True, 'url': 'https://example.com'}
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
mock_app.logger.debug.assert_not_called()
@@ -113,7 +117,9 @@ class TestTelemetrySendEarlyReturn:
manager = telemetry.TelemetryManager(mock_app)
manager.telemetry_config = {'url': ''}
await manager.send({'query_id': 'test'})
with patch.object(httpx, 'AsyncClient') as client:
await manager.send({'query_id': 'test'})
client.assert_not_called()
mock_app.logger.debug.assert_not_called()
@@ -627,4 +633,3 @@ class TestAuthenticatedWorkspaceReporter:
assert mock_client.post.call_args.kwargs['headers'] == {
'Authorization': 'Bearer refreshed-workspace-owner-token'
}
+72 -44
View File
@@ -1,61 +1,89 @@
"""Regression tests for the production Matrix ``!relogin`` command."""
from __future__ import annotations
"""Regression test for the Matrix ``!relogin`` command (``_handle_relogin_command``).
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, call
The old code placed ``logout_cmd = ...`` on a line after an unconditional
``continue`` inside the ``if not bridge.login_command or not bridge.dm_room_id``
branch. Because the ``continue`` always fired, ``logout_cmd`` was never assigned
on the configured path, so any bridge with both ``login_command`` and
``dm_room_id`` raised ``UnboundLocalError``. The fix moves the assignment above
the ``if`` so it runs for configured bridges.
import pytest
These tests replicate the loop's control flow with a minimal fake bridge so they
run without the Matrix SDK / langbot_plugin dependency.
"""
from langbot.pkg.platform.sources import matrix
class FakeBridge:
def __init__(self, user_id: str, login_command: str, logout_command: str = '', dm_room_id: str | None = None):
self.user_id = user_id
self.login_command = login_command
self.logout_command = logout_command
self.dm_room_id = dm_room_id
@pytest.fixture
def bridge():
bridge = matrix.BridgeState('@u:example.org', 'login', '', '', '')
bridge.dm_room_id = '!bridge:example.org'
bridge.logged_in = True
return bridge
def _relogin_commands(bridges: list[FakeBridge]) -> list[str]:
"""Return the logout commands the fixed loop would send for each bridge."""
commands: list[str] = []
for bridge in bridges:
if not bridge.login_command or not bridge.dm_room_id:
continue
# Use configured logout command, fallback to deriving from login command.
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
commands.append(logout_cmd)
return commands
@pytest.fixture
def adapter(monkeypatch):
adapter = matrix.MatrixAdapter.model_construct(
client=SimpleNamespace(room_send=AsyncMock()),
logger=None,
)
monkeypatch.setattr(matrix.asyncio, 'sleep', AsyncMock())
monkeypatch.setattr(matrix.MatrixAdapter, '_restart_bridge_login', Mock())
return adapter
def test_configured_bridge_produces_logout_command_without_error() -> None:
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id='!room:example.org')]
# Old code raised UnboundLocalError here; the fix must return the derived
# logout command (no configured logout_command -> derive from login).
assert _relogin_commands(bridges) == ['logout']
async def test_configured_bridge_produces_logout_command_without_error(adapter, bridge) -> None:
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
assert adapter.client.room_send.await_count == 2
assert adapter.client.room_send.await_args_list[0] == call(
room_id='!bridge:example.org',
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': 'logout'},
)
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
def test_configured_logout_command_is_used_verbatim() -> None:
bridges = [FakeBridge('@u:example.org', 'login', logout_command='leave', dm_room_id='!room:example.org')]
assert _relogin_commands(bridges) == ['leave']
async def test_configured_logout_command_is_used_verbatim(adapter, bridge) -> None:
bridge.logout_command = 'leave'
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
assert adapter.client.room_send.await_count == 2
assert adapter.client.room_send.await_args_list[0] == call(
room_id='!bridge:example.org',
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': 'leave'},
)
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
def test_skipped_bridge_is_ignored() -> None:
# Missing dm_room_id -> skipped, no command emitted.
bridges = [FakeBridge('@u:example.org', 'login', dm_room_id=None)]
assert _relogin_commands(bridges) == []
async def test_skipped_bridge_is_ignored(adapter, bridge) -> None:
bridge.dm_room_id = None
adapter._bridges = [bridge]
await adapter._handle_relogin_command('!admin:example.org')
adapter.client.room_send.assert_awaited_once()
assert adapter.client.room_send.await_args.kwargs['room_id'] == '!admin:example.org'
adapter._restart_bridge_login.assert_not_called()
assert bridge.logged_in is True
def test_relogin_never_raises_for_mixed_configurations() -> None:
bridges = [
FakeBridge('@skip:example.org', '', dm_room_id='!room:example.org'), # no login_command
FakeBridge('@ok:example.org', 'login', dm_room_id='!room:example.org'),
FakeBridge('@skip2:example.org', 'login', dm_room_id=None), # no dm_room_id
async def test_relogin_never_raises_for_mixed_configurations(adapter, bridge) -> None:
missing_login = matrix.BridgeState('@skip:example.org', '', '', '', '')
missing_login.dm_room_id = '!skip:example.org'
missing_room = matrix.BridgeState('@skip2:example.org', 'login', '', '', '')
adapter._bridges = [missing_login, bridge, missing_room]
await adapter._handle_relogin_command('!admin:example.org')
assert [sent.kwargs['room_id'] for sent in adapter.client.room_send.await_args_list] == [
'!bridge:example.org',
'!admin:example.org',
]
assert _relogin_commands(bridges) == ['logout']
assert adapter.client.room_send.await_args_list[0].kwargs['content']['body'] == 'logout'
adapter._restart_bridge_login.assert_called_once_with(bridge)
assert bridge.logged_in is False
+2 -2
View File
@@ -70,8 +70,8 @@ class TestGetQQImageDownloadableUrl:
url = 'http://example.com/image.jpg#fragment'
result_url, query = get_qq_image_downloadable_url(url)
# Fragment is not included in query string parsing
assert 'http://example.com/image.jpg' in result_url
assert result_url == 'http://example.com/image.jpg'
assert query == {}
def test_https_url(self):
"""Parse HTTPS URL and preserve its scheme."""
+16 -16
View File
@@ -15,13 +15,14 @@ Uses mocking for import operations to avoid actual module imports.
import pytest
import importlib
from unittest.mock import patch, MagicMock
import os
from unittest.mock import patch, MagicMock, call
class TestImportDir:
"""Test import_dir function."""
def test_calls_importlib_for_each_python_file(self, tmp_path):
def test_calls_importlib_for_each_python_file(self, tmp_path, monkeypatch):
"""Should call importlib.import_module for each .py file."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -33,12 +34,17 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# Should call import_module for each .py file (excluding __init__.py)
assert mock_import.call_count == 2
mock_import.assert_has_calls(
[call('test_prefix.test_modules.module_a'), call('test_prefix.test_modules.module_b')],
any_order=True,
)
def test_skips_init_py(self, tmp_path):
def test_skips_init_py(self, tmp_path, monkeypatch):
"""Should skip __init__.py when importing."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -48,15 +54,12 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# __init__.py should be skipped
mock_import.assert_called_once()
# The call should not include __init__
call_args = mock_import.call_args[0][0]
assert '__init__' not in call_args
mock_import.assert_called_once_with('test_prefix.test_modules.regular')
def test_ignores_non_py_files(self, tmp_path):
def test_ignores_non_py_files(self, tmp_path, monkeypatch):
"""Should ignore non-.py files."""
module_dir = tmp_path / 'test_modules'
module_dir.mkdir()
@@ -67,10 +70,10 @@ class TestImportDir:
from langbot.pkg.utils import importutil
monkeypatch.setattr(importutil, '__file__', str(tmp_path / 'pkg' / 'utils' / 'importutil.py'))
with patch.object(importlib, 'import_module') as mock_import:
importutil.import_dir(str(module_dir), path_prefix='test_prefix.')
# Only .py files should be imported
assert mock_import.call_count == 1
mock_import.assert_called_once_with('test_prefix.test_modules.module')
class TestImportModulesInPkg:
@@ -107,7 +110,7 @@ class TestImportModulesInPkgs:
with patch.object(importutil, 'import_modules_in_pkg') as mock_import:
importutil.import_modules_in_pkgs([mock_pkg1, mock_pkg2])
assert mock_import.call_count == 2
assert mock_import.call_args_list == [call(mock_pkg1), call(mock_pkg2)]
class TestImportDotStyleDir:
@@ -124,10 +127,7 @@ class TestImportDotStyleDir:
with patch.object(importutil, 'import_dir') as mock_import_dir:
importutil.import_dot_style_dir('my.pkg.test')
# The path should be converted using os.path.join
call_path = mock_import_dir.call_args[0][0]
# Should contain the path components joined
assert 'my' in call_path
mock_import_dir.assert_called_once_with(os.path.join('my', 'pkg', 'test'))
class TestReadResourceFile:
+7 -9
View File
@@ -9,7 +9,6 @@ Tests path utility functions:
Uses tmp_path for file system isolation where applicable.
"""
import os
import pytest
@@ -45,17 +44,16 @@ class TestCheckIfSourceInstall:
class TestGetFrontendPath:
"""Test get_frontend_path function."""
def test_returns_web_dist_by_default(self):
"""Should return a path containing web/dist as default."""
def test_returns_web_dist_by_default(self, monkeypatch):
"""Should return web/dist when no frontend build exists."""
from langbot.pkg.utils import paths
paths._is_source_install = None
monkeypatch.setattr(paths, '_is_source_install', False)
monkeypatch.setattr(paths.os.path, 'exists', lambda _: False)
monkeypatch.setattr(paths.Path, 'exists', lambda _: False)
result = paths.get_frontend_path()
# The result should contain web/dist or be an absolute path to it
assert 'web/dist' in result or result.endswith('dist')
paths._is_source_install = None
assert result == 'web/dist'
def test_finds_dist_directory_in_source_mode(self, tmp_path, monkeypatch):
"""Should find web/dist when running from source mode."""
@@ -130,7 +128,7 @@ class TestGetResourcePath:
paths._is_source_install = None
result = paths.get_resource_path('templates/config.yaml')
assert os.path.exists(result)
assert result == 'templates/config.yaml'
paths._is_source_install = None
+1 -3
View File
@@ -198,6 +198,4 @@ class TestSupportedOpsConstant:
def test_supported_ops_is_frozenset(self):
"""SUPPORTED_OPS is a frozenset for immutability."""
from collections.abc import Set
assert isinstance(SUPPORTED_OPS, Set)
assert isinstance(SUPPORTED_OPS, frozenset)

Some files were not shown because too many files have changed in this diff Show More