mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-23 17:05:28 +08:00
test: preserve session and quota contracts when merging master
This commit is contained in:
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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...'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user