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

This commit is contained in:
BiFangKNT
2026-10-18 12:43:44 +08:00
174 changed files with 7645 additions and 3218 deletions
+68 -9
View File
@@ -125,7 +125,7 @@ def fake_bot_app():
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
)
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
app.bot_service.create_bot = AsyncMock(return_value='new-bot-uuid')
app.bot_service.update_bot = AsyncMock(return_value={})
app.bot_service.delete_bot = AsyncMock()
app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1))
@@ -179,6 +179,11 @@ def fake_bot_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_bot_app):
fake_bot_app.bot_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_bot_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -201,11 +206,17 @@ class TestBotEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bots' in data['data']
assert data['data']['bots'] == [
{
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
}
]
@pytest.mark.asyncio
async def test_create_bot_success(self, quart_test_client):
async def test_create_bot_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots creates new bot."""
response = await quart_test_client.post(
'/api/v1/platform/bots',
@@ -218,8 +229,17 @@ class TestBotEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_bot_app.bot_service.create_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == (
{'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'},
)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-bot-uuid'}
@pytest.mark.asyncio
async def test_get_single_bot_success(self, quart_test_client):
async def test_get_single_bot_success(self, quart_test_client, fake_bot_app):
"""GET /api/v1/platform/bots/{uuid} returns bot with runtime info."""
response = await quart_test_client.get(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -230,8 +250,21 @@ class TestBotEndpoints:
assert data['code'] == 0
assert 'bot' in data['data']
called = fake_bot_app.bot_service.get_runtime_bot_info
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['bot'] == {
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
@pytest.mark.asyncio
async def test_update_bot_success(self, quart_test_client):
async def test_update_bot_success(self, quart_test_client, fake_bot_app):
"""PUT /api/v1/platform/bots/{uuid} updates bot."""
response = await quart_test_client.put(
'/api/v1/platform/bots/test-bot-uuid',
@@ -243,8 +276,14 @@ class TestBotEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_bot_app.bot_service.update_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_bot_success(self, quart_test_client):
async def test_delete_bot_success(self, quart_test_client, fake_bot_app):
"""DELETE /api/v1/platform/bots/{uuid} deletes bot."""
response = await quart_test_client.delete(
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -252,13 +291,20 @@ class TestBotEndpoints:
assert response.status_code == 200
called = fake_bot_app.bot_service.delete_bot
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotLogsEndpoint:
"""Tests for bot logs endpoint."""
@pytest.mark.asyncio
async def test_get_bot_logs_success(self, quart_test_client):
async def test_get_bot_logs_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots/{uuid}/logs returns logs."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
@@ -272,6 +318,13 @@ class TestBotLogsEndpoint:
assert 'logs' in data['data']
assert 'total_count' in data['data']
called = fake_bot_app.bot_service.list_event_logs
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10)
assert called.await_args.kwargs == {}
assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1}
@pytest.mark.asyncio
async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app):
access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
@@ -366,7 +419,7 @@ class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@pytest.mark.asyncio
async def test_send_message_success(self, quart_test_client):
async def test_send_message_success(self, quart_test_client, fake_bot_app):
"""POST /api/v1/platform/bots/{uuid}/send_message sends message."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
@@ -383,6 +436,12 @@ class TestBotSendMessageEndpoint:
assert data['code'] == 0
assert data['data']['sent'] is True
called = fake_bot_app.bot_service.send_message
called.assert_awaited_once()
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}])
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_send_message_missing_target_type(self, quart_test_client):
"""POST send_message without target_type returns 400."""
+92 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint:
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
body = await response.get_data(as_text=True)
assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body
assert 'baseUrl: "http://localhost"' in body
assert '__LANGBOT_' not in body
@pytest.mark.asyncio
@pytest.mark.parametrize(
('query', 'expected_base'),
@@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint:
assert response.status_code == 200
assert 'image/webp' in response.content_type
body = await response.get_data()
assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes()
assert body[:4] == b'RIFF' and body[8:12] == b'WEBP'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedTurnstileVerifyEndpoint:
"""Tests for Turnstile verification endpoint."""
@pytest.mark.asyncio
async def test_turnstile_verify_no_secret(self, quart_test_client):
async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch):
"""POST turnstile verify without secret returns dummy token."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25)
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
)
@@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint:
assert data['code'] == 0
assert 'token' in data['data']
assert data['data']['token'] == '1720000000.25.dummy'
@pytest.mark.asyncio
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
"""POST turnstile verify with invalid UUID returns 400."""
@@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint:
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
async def test_get_messages_group_success(self, quart_test_client, fake_embed_app):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
@@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}]
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_requires_session_id(self, quart_test_client):
"""GET messages without a client session identifier returns 400."""
@@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint:
async def test_get_messages_invalid_session_type(self, quart_test_client):
"""GET messages with invalid session_type returns 400."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid',
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
assert (await response.get_json())['msg'] == 'session_type must be person or group'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedResetEndpoint:
@@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint:
"""Tests for feedback submission endpoint."""
@pytest.mark.asyncio
async def test_submit_feedback_like(self, quart_test_client):
async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=1 (like) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint:
assert data['code'] == 0
assert 'feedback_id' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 1,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_dislike(self, quart_test_client):
async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=2 (dislike) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 2,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_invalid_type(self, quart_test_client):
"""POST feedback with invalid type returns 400."""
@@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint:
"""The public socket authenticates before resolving shared runtime state."""
@pytest.mark.asyncio
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
authenticated = False
original_authenticate = embed.EmbedRouterGroup._authenticate_websocket
original_add = embed.ws_connection_manager.add_connection
proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot
async def authenticate(router, runtime_bot):
nonlocal authenticated
await original_authenticate(router, runtime_bot)
authenticated = True
async def get_proxy(*_args, **_kwargs):
assert authenticated, 'Runtime lookup must follow successful authentication'
return proxy.return_value
async def add_connection(*args, **kwargs):
assert authenticated, 'Connection registration must follow successful authentication'
return await original_add(*args, **kwargs)
monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate)
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
+92 -11
View File
@@ -117,13 +117,13 @@ def fake_knowledge_app():
'engine_plugin_id': 'test/engine',
}
)
app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'})
app.knowledge_service.create_knowledge_base = AsyncMock(return_value='new-kb-uuid')
app.knowledge_service.update_knowledge_base = AsyncMock(return_value={})
app.knowledge_service.delete_knowledge_base = AsyncMock()
app.knowledge_service.get_files_by_knowledge_base = AsyncMock(
return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
)
app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'})
app.knowledge_service.store_file = AsyncMock(return_value='test-task-id')
app.knowledge_service.delete_file = AsyncMock()
app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}])
@@ -133,6 +133,11 @@ def fake_knowledge_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_knowledge_app):
fake_knowledge_app.knowledge_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_knowledge_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -148,7 +153,7 @@ class TestKnowledgeBaseEndpoints:
"""Tests for /api/v1/knowledge/bases endpoints."""
@pytest.mark.asyncio
async def test_get_knowledge_bases_success(self, quart_test_client):
async def test_get_knowledge_bases_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases returns knowledge base list."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}
@@ -160,8 +165,24 @@ class TestKnowledgeBaseEndpoints:
assert 'data' in data
assert 'bases' in data['data']
called = fake_knowledge_app.knowledge_service.get_knowledge_bases
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['bases'] == [
{
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
}
]
@pytest.mark.asyncio
async def test_create_knowledge_base_success(self, quart_test_client):
async def test_create_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases creates new knowledge base."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases',
@@ -174,8 +195,15 @@ class TestKnowledgeBaseEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_knowledge_app.knowledge_service.create_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-kb-uuid'}
@pytest.mark.asyncio
async def test_get_single_knowledge_base_success(self, quart_test_client):
async def test_get_single_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases/{uuid} returns knowledge base."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -186,8 +214,20 @@ class TestKnowledgeBaseEndpoints:
assert data['code'] == 0
assert 'base' in data['data']
called = fake_knowledge_app.knowledge_service.get_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['base'] == {
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
}
@pytest.mark.asyncio
async def test_update_knowledge_base_success(self, quart_test_client):
async def test_update_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""PUT /api/v1/knowledge/bases/{uuid} updates knowledge base."""
response = await quart_test_client.put(
'/api/v1/knowledge/bases/test-kb-uuid',
@@ -199,8 +239,14 @@ class TestKnowledgeBaseEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_knowledge_app.knowledge_service.update_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_knowledge_base_success(self, quart_test_client):
async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
"""DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -208,13 +254,20 @@ class TestKnowledgeBaseEndpoints:
assert response.status_code == 200
called = fake_knowledge_app.knowledge_service.delete_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseFilesEndpoints:
"""Tests for knowledge base files endpoints."""
@pytest.mark.asyncio
async def test_get_files_success(self, quart_test_client):
async def test_get_files_success(self, quart_test_client, fake_knowledge_app):
"""GET /api/v1/knowledge/bases/{uuid}/files returns files."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'}
@@ -225,8 +278,15 @@ class TestKnowledgeBaseFilesEndpoints:
assert data['code'] == 0
assert 'files' in data['data']
called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid',)
assert called.await_args.kwargs == {}
assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
@pytest.mark.asyncio
async def test_add_file_to_knowledge_base(self, quart_test_client):
async def test_add_file_to_knowledge_base(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases/{uuid}/files adds file."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/files',
@@ -239,8 +299,15 @@ class TestKnowledgeBaseFilesEndpoints:
assert data['code'] == 0
assert 'task_id' in data['data']
called = fake_knowledge_app.knowledge_service.store_file
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id')
assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'}
assert data['data'] == {'task_id': 'test-task-id'}
@pytest.mark.asyncio
async def test_delete_file_from_knowledge_base(self, quart_test_client):
async def test_delete_file_from_knowledge_base(self, quart_test_client, fake_knowledge_app):
"""DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}."""
response = await quart_test_client.delete(
'/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -248,13 +315,20 @@ class TestKnowledgeBaseFilesEndpoints:
assert response.status_code == 200
called = fake_knowledge_app.knowledge_service.delete_file
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid')
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseRetrieveEndpoint:
"""Tests for knowledge base retrieval endpoint."""
@pytest.mark.asyncio
async def test_retrieve_knowledge_success(self, quart_test_client):
async def test_retrieve_knowledge_success(self, quart_test_client, fake_knowledge_app):
"""POST /api/v1/knowledge/bases/{uuid}/retrieve."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve',
@@ -267,6 +341,13 @@ class TestKnowledgeBaseRetrieveEndpoint:
assert data['code'] == 0
assert 'results' in data['data']
called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5})
assert called.await_args.kwargs == {}
assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}]
@pytest.mark.asyncio
async def test_retrieve_without_query_returns_error(self, quart_test_client):
"""POST retrieve without query returns 400."""
+129 -6
View File
@@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations
import csv
import io
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock, patch
from types import SimpleNamespace
@@ -122,17 +125,24 @@ def fake_monitoring_app():
)
app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10})
app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12))
app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}])
app.monitoring_service.export_messages = AsyncMock(
return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}]
)
app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}])
app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}])
app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}])
app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}])
app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}])
app.monitoring_service._escape_csv_field = Mock(return_value='escaped')
app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field
return app
@pytest.fixture(autouse=True)
def reset_monitoring_calls(fake_monitoring_app):
fake_monitoring_app.monitoring_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_monitoring_app, http_controller_cls):
"""Create Quart test client (module scope)."""
@@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
@pytest.mark.asyncio
async def test_viewer_can_read_monitoring_but_cannot_export(
self,
@@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint:
assert data['code'] == 0
assert 'messages' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'messages': [{'id': 'msg-1', 'content': 'test'}],
'total': 100,
'limit': 100,
'offset': 0,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringLLMCallsEndpoint:
@@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringEmbeddingCallsEndpoint:
@@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringSessionsEndpoint:
@@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0}
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
@@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringAllDataEndpoint:
@@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint:
assert 'overview' in data['data']
assert data['data']['traffic'] == traffic
assert data['code'] == 0
assert data['data']['overview'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}]
assert data['data']['llmCalls'] == [{'id': 'llm-1'}]
assert data['data']['toolCalls'] == [{'id': 'tool-1'}]
assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}]
assert data['data']['sessions'] == [{'session_id': 'sess-1'}]
assert data['data']['errors'] == [{'id': 'err-1'}]
assert data['data']['totalCount'] == {
'messages': 100,
'llmCalls': 50,
'toolCalls': 5,
'embeddingCalls': 10,
'sessions': 20,
'errors': 2,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringDetailsEndpoints:
@@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'found': True, 'message_id': 'msg-1'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints:
@@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'like_count': 10}
@pytest.mark.asyncio
async def test_get_feedback_list(self, quart_test_client):
"""GET /api/v1/monitoring/feedback."""
@@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringExportEndpoint:
"""Tests for /api/v1/monitoring/export endpoint."""
@pytest.mark.asyncio
async def test_export_messages(self, quart_test_client):
async def test_export_messages(self, quart_test_client, fake_monitoring_app):
"""GET export?type=messages returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
@@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'text/csv' in response.content_type
assert 'monitoring-messages-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'msg-1'
called = fake_monitoring_app.monitoring_service.export_messages
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
assert records[0]['message_content'] == 'hello, "quoted"'
@pytest.mark.asyncio
async def test_export_llm_calls(self, quart_test_client):
async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app):
"""GET export?type=llm-calls returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'}
@@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-llm-calls-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'llm-1'
called = fake_monitoring_app.monitoring_service.export_llm_calls
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_sessions(self, quart_test_client):
async def test_export_sessions(self, quart_test_client, fake_monitoring_app):
"""GET export?type=sessions returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
@@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-sessions-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['session_id'] == 'sess-1'
called = fake_monitoring_app.monitoring_service.export_sessions
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_feedback(self, quart_test_client):
async def test_export_feedback(self, quart_test_client, fake_monitoring_app):
"""GET export?type=feedback returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-feedback-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'fb-1'
called = fake_monitoring_app.monitoring_service.export_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
+132 -14
View File
@@ -128,10 +128,10 @@ def fake_pipeline_app():
'config': {},
}
)
app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'})
app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid')
app.pipeline_service.update_pipeline = AsyncMock(return_value={})
app.pipeline_service.delete_pipeline = AsyncMock()
app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'})
app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid')
# Bot service
app.bot_service = Mock()
@@ -161,6 +161,11 @@ def fake_pipeline_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_pipeline_app):
fake_pipeline_app.pipeline_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_pipeline_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -179,7 +184,7 @@ class TestPipelineMetadataEndpoint:
"""Tests for /api/v1/pipelines/_/metadata endpoint."""
@pytest.mark.asyncio
async def test_get_pipeline_metadata_success(self, quart_test_client):
async def test_get_pipeline_metadata_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/_/metadata returns metadata list."""
response = await quart_test_client.get(
'/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'}
@@ -191,6 +196,13 @@ class TestPipelineMetadataEndpoint:
assert 'data' in data
assert isinstance(data['data'], dict)
called = fake_pipeline_app.pipeline_service.get_pipeline_metadata
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {}
assert data['data'] == {'configs': [{'name': 'trigger', 'stages': []}, {'name': 'ai', 'stages': []}]}
@pytest.mark.asyncio
async def test_get_pipeline_metadata_requires_auth(self, quart_test_client):
"""Pipeline metadata endpoint requires authentication."""
@@ -203,7 +215,7 @@ class TestPipelinesListEndpoint:
"""Tests for /api/v1/pipelines endpoint."""
@pytest.mark.asyncio
async def test_get_pipelines_success(self, quart_test_client):
async def test_get_pipelines_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines returns pipeline list."""
response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'})
@@ -212,24 +224,46 @@ class TestPipelinesListEndpoint:
assert data['code'] == 0
assert 'data' in data
called = fake_pipeline_app.pipeline_service.get_pipelines
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('created_at', 'DESC')
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['pipelines'] == [
{
'uuid': 'test-pipeline-uuid',
'name': 'Test Pipeline',
'description': 'Test description',
'created_at': '2024-01-01T00:00:00',
'updated_at': '2024-01-01T00:00:00',
'is_default': False,
}
]
@pytest.mark.asyncio
async def test_get_pipelines_with_sort_param(self, quart_test_client):
async def test_get_pipelines_with_sort_param(self, quart_test_client, fake_pipeline_app):
"""GET pipelines with sort parameter."""
response = await quart_test_client.get(
'/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'}
'/api/v1/pipelines?sort_by=name&sort_order=ASC', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.get_pipelines
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('name', 'ASC')
assert called.await_args.kwargs == {'include_secret': True}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelinesCRUDEndpoints:
"""Tests for pipeline CRUD operations."""
@pytest.mark.asyncio
async def test_get_single_pipeline_success(self, quart_test_client):
async def test_get_single_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/{uuid} returns pipeline."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -240,8 +274,15 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'data' in data
called = fake_pipeline_app.pipeline_service.get_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}}
@pytest.mark.asyncio
async def test_create_pipeline_success(self, quart_test_client):
async def test_create_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines creates new pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines',
@@ -254,6 +295,13 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_pipeline_app.pipeline_service.create_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},)
assert called.await_args.kwargs == {'default': False}
assert data['data'] == {'uuid': 'new-pipeline-uuid'}
@pytest.mark.asyncio
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
@@ -271,7 +319,7 @@ class TestPipelinesCRUDEndpoints:
assert call.args[1]['is_default'] is True
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
response = await quart_test_client.put(
'/api/v1/pipelines/test-pipeline-uuid',
@@ -283,8 +331,14 @@ class TestPipelinesCRUDEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.update_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_pipeline_success(self, quart_test_client):
async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""DELETE /api/v1/pipelines/{uuid} deletes pipeline."""
response = await quart_test_client.delete(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -294,8 +348,14 @@ class TestPipelinesCRUDEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.delete_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_copy_pipeline_success(self, quart_test_client):
async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app):
"""POST /api/v1/pipelines/{uuid}/copy copies pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'}
@@ -306,14 +366,24 @@ class TestPipelinesCRUDEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_pipeline_app.pipeline_service.copy_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'copied-pipeline-uuid'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineExtensionsEndpoint:
"""Tests for pipeline extensions."""
@pytest.mark.asyncio
async def test_get_extensions(self, quart_test_client):
async def test_get_extensions(self, quart_test_client, fake_pipeline_app):
"""GET /api/v1/pipelines/{uuid}/extensions."""
fake_pipeline_app.plugin_connector.list_plugins.return_value = [{'name': 'extension-plugin'}]
fake_pipeline_app.mcp_service.get_mcp_servers.return_value = [{'uuid': 'extension-mcp'}]
fake_pipeline_app.skill_service.list_skills.return_value = [{'name': 'extension-skill'}]
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
)
@@ -323,6 +393,21 @@ class TestPipelineExtensionsEndpoint:
data = await response.get_json()
assert data['code'] == 0
called = fake_pipeline_app.pipeline_service.get_pipeline
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
assert called.await_args.kwargs == {}
assert data['data']['available_plugins'] == [{'name': 'extension-plugin'}]
assert data['data']['available_mcp_servers'] == [{'uuid': 'extension-mcp'}]
assert data['data']['available_skills'] == [{'name': 'extension-skill'}]
assert data['data']['bound_plugins'] == []
assert data['data']['bound_mcp_servers'] == []
assert data['data']['bound_skills'] == []
assert data['data']['enable_all_plugins'] is False
assert data['data']['enable_all_mcp_servers'] is False
assert data['data']['enable_all_skills'] is False
@pytest.mark.asyncio
async def test_get_extensions_redacts_available_plugin_secrets(
self,
@@ -377,7 +462,31 @@ class TestPipelineExtensionsEndpoint:
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineDashboardWebSocket:
@pytest.mark.asyncio
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
authenticated = False
original_authenticate = chat.WebSocketChatRouterGroup._authenticate_websocket
original_add = chat.ws_connection_manager.add_connection
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
async def authenticate(router):
nonlocal authenticated
result = await original_authenticate(router)
authenticated = True
return result
async def get_proxy(*_args, **_kwargs):
assert authenticated, 'Runtime lookup must follow authentication'
return proxy.return_value
async def add_connection(*args, **kwargs):
assert authenticated, 'Connection registration must follow authentication'
return await original_add(*args, **kwargs)
monkeypatch.setattr(chat.WebSocketChatRouterGroup, '_authenticate_websocket', authenticate)
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
@@ -403,7 +512,13 @@ class TestPipelineDashboardWebSocket:
fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
@pytest.mark.asyncio
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client, fake_pipeline_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
proxy.reset_mock()
add_connection = AsyncMock(wraps=chat.ws_connection_manager.add_connection)
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
headers={'Origin': 'http://localhost'},
@@ -412,6 +527,9 @@ class TestPipelineDashboardWebSocket:
response = json.loads(await websocket.receive())
assert response == {'type': 'error', 'message': 'Unauthorized'}
proxy.assert_not_awaited()
add_connection.assert_not_awaited()
@pytest.mark.asyncio
async def test_websocket_rechecks_revocable_membership_before_each_message(
self,
+106 -16
View File
@@ -109,21 +109,23 @@ def fake_provider_app():
app.llm_model_service = Mock()
app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}])
app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'})
app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'})
app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid')
app.llm_model_service.update_llm_model = AsyncMock(return_value={})
app.llm_model_service.delete_llm_model = AsyncMock()
app.llm_model_service.test_llm_model = AsyncMock()
# Embedding model service
app.embedding_models_service = Mock()
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'})
app.embedding_models_service.get_embedding_models = AsyncMock(
return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}]
)
app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid')
app.embedding_models_service.test_embedding_model = AsyncMock()
# Rerank model service
app.rerank_models_service = Mock()
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'})
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}])
app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid')
app.rerank_models_service.test_rerank_model = AsyncMock()
# Model manager
@@ -134,6 +136,14 @@ def fake_provider_app():
return app
@pytest.fixture(autouse=True)
def reset_service_calls(fake_provider_app):
fake_provider_app.provider_service.reset_mock()
fake_provider_app.llm_model_service.reset_mock()
fake_provider_app.embedding_models_service.reset_mock()
fake_provider_app.rerank_models_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_provider_app, http_controller_cls):
"""Create Quart test client (module scope to avoid route re-registration)."""
@@ -206,7 +216,7 @@ class TestProviderEndpoints:
assert data['data']['uuid'] == 'new-provider-uuid'
@pytest.mark.asyncio
async def test_update_provider_success(self, quart_test_client):
async def test_update_provider_success(self, quart_test_client, fake_provider_app):
"""PUT /api/v1/provider/providers/{uuid} updates provider."""
response = await quart_test_client.put(
'/api/v1/provider/providers/test-provider-uuid',
@@ -218,8 +228,14 @@ class TestProviderEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_provider_app.provider_service.update_provider
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'})
assert called.await_args.kwargs == {}
@pytest.mark.asyncio
async def test_delete_provider_success(self, quart_test_client):
async def test_delete_provider_success(self, quart_test_client, fake_provider_app):
"""DELETE /api/v1/provider/providers/{uuid} deletes provider."""
response = await quart_test_client.delete(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -227,8 +243,15 @@ class TestProviderEndpoints:
assert response.status_code == 200
called = fake_provider_app.provider_service.delete_provider
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_get_provider_includes_model_counts(self, quart_test_client):
async def test_get_provider_includes_model_counts(self, quart_test_client, fake_provider_app):
"""GET provider response includes model counts."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -243,13 +266,24 @@ class TestProviderEndpoints:
assert 'embedding_count' in provider_data
assert 'rerank_count' in provider_data
called = fake_provider_app.provider_service.get_provider_model_counts
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-provider-uuid',)
assert called.await_args.kwargs == {}
assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == {
'llm_count': 2,
'embedding_count': 1,
'rerank_count': 0,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestModelEndpoints:
"""Tests for /api/v1/provider/models endpoints."""
@pytest.mark.asyncio
async def test_get_llm_models_success(self, quart_test_client):
async def test_get_llm_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/llm returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'}
@@ -260,8 +294,15 @@ class TestModelEndpoints:
assert data['code'] == 0
assert 'data' in data
called = fake_provider_app.llm_model_service.get_llm_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]
@pytest.mark.asyncio
async def test_get_single_llm_model_success(self, quart_test_client):
async def test_get_single_llm_model_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/llm/{uuid} returns model."""
response = await quart_test_client.get(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -271,8 +312,15 @@ class TestModelEndpoints:
data = await response.get_json()
assert data['code'] == 0
called = fake_provider_app.llm_model_service.get_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-model-uuid',)
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'}
@pytest.mark.asyncio
async def test_create_llm_model_success(self, quart_test_client):
async def test_create_llm_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/llm creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/llm',
@@ -285,8 +333,15 @@ class TestModelEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.llm_model_service.create_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-model-uuid'}
@pytest.mark.asyncio
async def test_delete_llm_model_success(self, quart_test_client):
async def test_delete_llm_model_success(self, quart_test_client, fake_provider_app):
"""DELETE /api/v1/provider/models/llm/{uuid} deletes model."""
response = await quart_test_client.delete(
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
@@ -294,6 +349,13 @@ class TestModelEndpoints:
assert response.status_code == 200
called = fake_provider_app.llm_model_service.delete_llm_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ('test-model-uuid',)
assert called.await_args.kwargs == {}
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
@pytest.mark.parametrize(
('service_name', 'method_name', 'path'),
@@ -337,7 +399,7 @@ class TestEmbeddingModelEndpoints:
"""Tests for /api/v1/provider/models/embedding endpoints."""
@pytest.mark.asyncio
async def test_get_embedding_models_success(self, quart_test_client):
async def test_get_embedding_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/embedding returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'}
@@ -348,8 +410,15 @@ class TestEmbeddingModelEndpoints:
assert data['code'] == 0
assert 'models' in data['data']
called = fake_provider_app.embedding_models_service.get_embedding_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'embedding-model', 'name': 'Embedding'}]
@pytest.mark.asyncio
async def test_create_embedding_model_success(self, quart_test_client):
async def test_create_embedding_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/embedding creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/embedding',
@@ -362,13 +431,20 @@ class TestEmbeddingModelEndpoints:
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.embedding_models_service.create_embedding_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-embedding-uuid'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRerankModelEndpoints:
"""Tests for /api/v1/provider/models/rerank endpoints."""
@pytest.mark.asyncio
async def test_get_rerank_models_success(self, quart_test_client):
async def test_get_rerank_models_success(self, quart_test_client, fake_provider_app):
"""GET /api/v1/provider/models/rerank returns model list."""
response = await quart_test_client.get(
'/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'}
@@ -379,8 +455,15 @@ class TestRerankModelEndpoints:
assert data['code'] == 0
assert 'models' in data['data']
called = fake_provider_app.rerank_models_service.get_rerank_models
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ()
assert called.await_args.kwargs == {'include_secret': True}
assert data['data']['models'] == [{'uuid': 'rerank-model', 'name': 'Rerank'}]
@pytest.mark.asyncio
async def test_create_rerank_model_success(self, quart_test_client):
async def test_create_rerank_model_success(self, quart_test_client, fake_provider_app):
"""POST /api/v1/provider/models/rerank creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/rerank',
@@ -392,3 +475,10 @@ class TestRerankModelEndpoints:
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
called = fake_provider_app.rerank_models_service.create_rerank_model
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},)
assert called.await_args.kwargs == {}
assert data['data'] == {'uuid': 'new-rerank-uuid'}
+31 -32
View File
@@ -164,16 +164,6 @@ class TestHealthEndpoint:
data = await response.get_json()
assert data == {'code': 0, 'msg': 'ok', 'resources': {}}
@pytest.mark.asyncio
async def test_healthz_no_auth_required(self, quart_test_client):
"""
/healthz doesn't require authentication.
Tests that AuthType.NONE endpoints work without headers.
"""
response = await quart_test_client.get('/healthz')
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestSystemEndpoint:
@@ -241,33 +231,42 @@ class TestInvalidPayload:
"""Tests for error handling with invalid payloads."""
@pytest.mark.asyncio
async def test_missing_json_body(self, quart_test_client):
"""
POST endpoint without JSON body handles gracefully.
"""
# /api/v1/user/auth expects JSON with 'user' and 'password'
async def test_missing_json_body(self, quart_test_client, fake_api_app):
"""A missing login body is rejected before authentication."""
response = await quart_test_client.post('/api/v1/user/auth')
# Should return error (500, 400, or 401) with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
# Verify error response has expected structure
assert 'code' in data
assert 'msg' in data
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
fake_api_app.user_service.authenticate.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_json_structure(self, quart_test_client):
"""
POST with wrong JSON structure returns stable error.
"""
response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'})
@pytest.mark.parametrize(
'payload',
[
{'wrong_field': 'value'},
[],
{'user': None, 'password': 'secret'},
{'user': '[email protected]', 'password': 42},
],
)
async def test_invalid_json_structure(self, quart_test_client, fake_api_app, payload):
"""Malformed credential fields never reach the authentication service."""
response = await quart_test_client.post('/api/v1/user/auth', json=payload)
# Should return error with stable JSON structure
assert response.status_code in (400, 500, 401)
data = await response.get_json()
assert 'code' in data
assert 'msg' in data
assert response.status_code == 400
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
fake_api_app.user_service.authenticate.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize('password', ['', 'existing-password'])
async def test_string_credentials_reach_authentication(self, quart_test_client, fake_api_app, password):
response = await quart_test_client.post(
'/api/v1/user/auth', json={'user': '[email protected]', 'password': password}
)
assert response.status_code == 200
assert (await response.get_json())['data']['token'] == 'fake_token'
fake_api_app.user_service.authenticate.assert_awaited_once_with('[email protected]', password, None)
@pytest.mark.usefixtures('mock_circular_import_chain')
@@ -405,7 +405,7 @@ async def test_support_admin_persisted_expiry_and_revocation_are_enforced(suppor
async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api):
app, _client, _engine, private_key = support_admin_api
app, _client, engine, private_key = support_admin_api
launch = await _issue_support_token(app, private_key)
captured_contexts = []
@@ -414,7 +414,6 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
del data
captured_contexts.append(connection.execution_context)
await connection.send_queue.put({'type': 'handled'})
connection.is_active = False
app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1')))
app.platform_mgr = SimpleNamespace(
@@ -439,8 +438,17 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
handled = json.loads(await websocket.receive())
assert handled['type'] == 'handled'
async with engine.begin() as connection:
await connection.execute(
sqlalchemy.update(SupportAdminTemporarySession)
.where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash'])
.values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None))
)
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'after revoke'}]}))
rejected = json.loads(await websocket.receive())
assert rejected == {'type': 'error', 'message': 'Unauthorized'}
assert captured_contexts
assert len(captured_contexts) == 1
principal = captured_contexts[0].trigger_principal
assert principal is not None
assert principal.principal_type == PrincipalType.SUPPORT_ADMIN
+25 -34
View File
@@ -234,39 +234,6 @@ async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
assert all(payload['code'] == 1 for payload in payloads)
@pytest.mark.asyncio
async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
headers={'Origin': 'https://irrelevant.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
application, client = space_oauth_api
application.instance_config.data['api'] = {
'webui_url': '',
'webhook_prefix': 'https://langbot.example/base/path',
}
response = await client.get(
'/api/v1/user/space/authorize-url',
query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
headers={'Host': 'attacker.example'},
)
assert response.status_code == 200
assert (await response.get_json())['code'] == 0
@pytest.mark.asyncio
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
application, client = space_oauth_api
@@ -423,6 +390,9 @@ async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oa
)
assert response.status_code == 200
payload = await response.get_json()
assert payload['code'] == 0
assert payload['data']['token'] == 'space-login-token'
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
@@ -557,6 +527,7 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
application, client = space_oauth_api
application.user_service.consume_space_oauth_state.reset_mock()
application.user_service.consume_space_oauth_state_details.reset_mock()
application.space_service.exchange_oauth_code.reset_mock()
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
@@ -578,6 +549,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
expected_workspace_uuid=WORKSPACE_UUID,
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.user_service.consume_space_oauth_state_details.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
@@ -591,7 +563,24 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
status='active',
)
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
events = []
async def reconcile(_workspaces):
events.append('reconcile')
async def get_account(_account_uuid):
events.append('account')
return projected_account
access = application.workspace_collaboration_service.resolve_account_workspace.return_value
async def resolve_access(*_args, **_kwargs):
events.append('access')
return access
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock(side_effect=reconcile))
application.user_service.get_user_by_uuid.side_effect = get_account
application.workspace_collaboration_service.resolve_account_workspace.side_effect = resolve_access
response = await client.post(
'/api/v1/user/space/callback',
@@ -605,3 +594,5 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
assert events == ['reconcile', 'account', 'access']
+6
View File
@@ -623,6 +623,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
)
assert expired_capabilities.status_code == 401
await application.persistence_mgr.execute_async(
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.uuid == created['uuid']).values(expires_at=None)
)
valid_again = await client.get('/api/v1/system/context', headers={'X-API-Key': created['key']})
assert valid_again.status_code == 200
revoke_response = await client.delete(
f'/api/v1/apikeys/{created["id"]}',
headers=_auth(owner_token, workspace_uuid),
@@ -298,12 +298,13 @@ async def test_device_replacement_expiry_and_idempotent_cancel(auth):
async def test_device_accepts_issuer_iso_expiry(auth):
from datetime import datetime, timezone
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
expected_expiry = int(time.time()) + 600
expires = datetime.fromtimestamp(expected_expiry, timezone.utc).isoformat().replace('+00:00', 'Z')
auth._post = AsyncMock(
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
)
result = await auth.start(context(), 'p')
assert time.time() < result['expires_at'] < time.time() + 900
assert result['expires_at'] == expected_expiry
@pytest.mark.asyncio
@@ -39,7 +39,7 @@ ACCOUNT = '41100000-0000-4000-8000-000000000002'
NOW = datetime.datetime(2026, 9, 1)
@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=pytest.mark.slow)])
@pytest.fixture(params=['sqlite', pytest.param('postgresql', marks=[pytest.mark.slow, pytest.mark.postgres])])
async def convergence_engine(request, tmp_path):
if request.param == 'sqlite':
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "convergence.db"}')
@@ -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,19 @@ 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),
],
)
@pytest.mark.contract('migration_history')
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 +386,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')
@@ -345,12 +396,37 @@ async def test_historical_schema_and_populated_branch_upgrade(convergence_engine
@pytest.mark.asyncio
@pytest.mark.contract('migration_fresh_schema')
async def test_empty_database_startup_schema_then_real_migrations(convergence_engine):
engine = convergence_engine
async with engine.begin() as conn:
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 +556,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
@@ -12,7 +12,11 @@ from langbot.pkg.persistence.pipeline_admission import lock_pipeline_admission
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
URL = os.environ.get('LANGBOT_ADMISSION_TEST_URL')
pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required')]
pytestmark = [
pytest.mark.postgres,
pytest.mark.asyncio,
pytest.mark.skipif(not URL, reason='disposable PostgreSQL URL required'),
]
@pytest.fixture
@@ -111,6 +115,7 @@ async def test_real_writer_waits_and_revalidates_after_transaction(env, outcome)
assert (await writer)[0]['status'] == 'pending'
@pytest.mark.contract('admission_isolation')
async def test_real_writer_first_blocks_migration_and_other_tenant_isolated(env):
await write(env)
async with env.pm.tenant_uow(base.WS) as uow:
@@ -54,7 +54,7 @@ DOCUMENT_IDENTITY_REVISION = '0025_rag_document_identity'
CONTEXT = ExecutionContext(instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=5)
@pytest_asyncio.fixture(params=['sqlite', 'postgres'])
@pytest_asyncio.fixture(params=['sqlite', pytest.param('postgres', marks=pytest.mark.postgres)])
async def database(request, tmp_path):
admin = None
schema = 'ke_identity_' + uuid.uuid4().hex
@@ -183,6 +183,7 @@ async def read_file_row(runtime, file_id='host-id'):
@pytest.mark.parametrize(
'engine_id', ['dify-upstream', 'fastgpt-collection', 'ragflow-upstream', 'host-id', ' opaque ID ']
)
@pytest.mark.contract('rag_identity')
async def test_ingest_persists_exact_engine_identity_and_restart_delete(runtime, engine_id):
file = await seed(runtime)
runtime.ap.plugin_connector.call_rag_ingest.return_value['document_id'] = engine_id
@@ -25,6 +25,7 @@ def attach_task_manager(runtime):
@pytest.mark.asyncio
@pytest.mark.contract('rag_recovery')
async def test_cancelled_ingestion_retains_recovery_resources(runtime):
"""Local cancellation is interrupted observation, not remote quiescence."""
file = await seed(runtime)
@@ -25,7 +25,12 @@ from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.entity.persistence.event_log import EventLog
from langbot.pkg.entity.persistence.transcript import Transcript
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.asyncio]
pytestmark = [
pytest.mark.postgres,
pytest.mark.integration,
pytest.mark.slow,
pytest.mark.asyncio,
]
UTC = dt.timezone.utc
NOW = dt.datetime(2026, 9, 22, 4, 5, 6, 123000, tzinfo=UTC)
@@ -110,6 +115,7 @@ async def _legacy_run(engine, *, status='queued'):
)
@pytest.mark.contract('runner_timestamps')
async def test_run_lifecycle_deadline_events_and_stats(pg_engine):
store = run_ledger_store.RunLedgerStore(pg_engine)
deadline = NOW + dt.timedelta(minutes=5)
@@ -166,10 +166,14 @@ async def test_workspace_upgrade_repairs_ownerless_existing_local_workspace(lega
async with legacy_engine.connect() as conn:
workspace = (
await conn.execute(
sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"),
(
await conn.execute(
sa.text("SELECT uuid, created_by_account_uuid FROM workspaces WHERE source = 'local'"),
)
)
).mappings().one()
.mappings()
.one()
)
membership = (await conn.execute(sa.text('SELECT * FROM workspace_memberships'))).mappings().one()
assert workspace['uuid'] == workspace_uuid_from_instance_id('instance_migration_test')
@@ -196,7 +200,7 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
instance_id = 'instance_a711d9e4-0953-443f-a0e9-7dd50193a79f'
old_workspace_uuid = '11111111-1111-4111-8111-111111111111'
canonical_uuid = workspace_uuid_from_instance_id(instance_id)
canonical_uuid = 'a711d9e4-0953-443f-a0e9-7dd50193a79f'
schema = sa.MetaData()
sa.Table(
'metadata',
@@ -218,7 +222,15 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
)
sa.Table(
'workspace_metadata',
schema,
sa.Column('workspace_uuid', sa.String(36), sa.ForeignKey('workspaces.uuid'), nullable=False),
sa.Column('key', sa.String(255)),
sa.Column('value', sa.String(255)),
)
async with engine.begin() as conn:
await conn.execute(sa.text('PRAGMA foreign_keys = ON'))
await conn.run_sync(schema.create_all)
await conn.execute(
sa.text("INSERT INTO metadata (key, value) VALUES ('instance_uuid', :value)"), {'value': instance_id}
@@ -233,6 +245,14 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
sa.text('INSERT INTO tenant_rows (id, workspace_uuid) VALUES (1, :uuid)'),
{'uuid': old_workspace_uuid},
)
await conn.execute(
sa.text("INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :value)"),
{'value': old_workspace_uuid},
)
await conn.execute(
sa.text("INSERT INTO workspace_metadata VALUES (:uuid, 'migration_probe', 'present')"),
{'uuid': old_workspace_uuid},
)
await run_alembic_stamp(engine, '0016_support_admin_sessions')
await run_alembic_upgrade(engine, 'head')
@@ -240,6 +260,13 @@ async def test_existing_oss_workspace_is_rekeyed_to_instance_identity(tmp_path):
async with engine.connect() as conn:
assert (await conn.execute(sa.text('SELECT uuid FROM workspaces'))).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text('SELECT workspace_uuid FROM tenant_rows'))).scalar_one() == canonical_uuid
assert (
await conn.execute(sa.text('SELECT workspace_uuid FROM workspace_metadata'))
).scalar_one() == canonical_uuid
assert (
await conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
).scalar_one() == canonical_uuid
assert (await conn.execute(sa.text('PRAGMA foreign_key_check'))).all() == []
await engine.dispose()
@@ -497,51 +524,6 @@ async def test_persistence_startup_preserves_legacy_workspace_membership_with_fo
await manager.shutdown()
async def test_oss_workspace_identity_rekeys_fk_graph_and_metadata(tmp_path):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "workspace-rekey.db"}')
try:
await _create_legacy_schema(engine)
await run_alembic_stamp(engine, '0008_mcp_resource_prefs')
await run_alembic_upgrade(engine, '0016_support_admin_sessions')
async with engine.begin() as conn:
old_uuid = await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'"))
instance_uuid = await conn.scalar(sa.text("SELECT instance_uuid FROM workspaces WHERE source = 'local'"))
assert old_uuid
assert instance_uuid
await conn.execute(
sa.text(
'INSERT INTO workspace_metadata (workspace_uuid, key, value) '
"VALUES (:workspace_uuid, 'migration_probe', 'present')"
),
{'workspace_uuid': old_uuid},
)
await conn.execute(
sa.text(
"INSERT INTO metadata (key, value) VALUES ('oss_workspace_uuid', :workspace_uuid) "
'ON CONFLICT(key) DO UPDATE SET value = excluded.value'
),
{'workspace_uuid': old_uuid},
)
await run_alembic_upgrade(engine, 'head')
expected_uuid = workspace_uuid_from_instance_id(instance_uuid)
async with engine.connect() as conn:
assert await conn.scalar(sa.text("SELECT uuid FROM workspaces WHERE source = 'local'")) == expected_uuid
assert (
await conn.scalar(
sa.text("SELECT workspace_uuid FROM workspace_metadata WHERE key = 'migration_probe'")
)
== expected_uuid
)
assert (
await conn.scalar(sa.text("SELECT value FROM metadata WHERE key = 'oss_workspace_uuid'"))
== expected_uuid
)
finally:
await engine.dispose()
async def test_persistence_startup_rejects_instance_uuid_drift(tmp_path, monkeypatch):
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "instance-drift.db"}')
try:
+18 -3
View File
@@ -353,6 +353,9 @@ class TestPreProcessorStage:
# Check user_message content
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
assert [(part.type, part.text) for part in result.new_query.user_message.content] == [
('text', 'test message content')
]
@pytest.mark.usefixtures('load_pipeline_modules')
@@ -370,6 +373,14 @@ class TestProcessorStage:
# Create query
query = text_query('hello')
from langbot_plugin.api.entities.builtin.provider.session import Session
query.session = Session(
launcher_type=query.launcher_type,
launcher_id=query.launcher_id,
sender_id=query.sender_id,
using_conversation=await pipeline_app.sess_mgr.get_conversation(),
)
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
@@ -390,9 +401,13 @@ class TestProcessorStage:
# Collect results using helper
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) >= 1
# Check that resp_messages was populated
assert len(query.resp_messages) >= 1
from langbot.pkg.pipeline import entities
assert pipeline_app.agent_run_orchestrator.observed_queries == [query]
assert [result.result_type for result in results] == [entities.ResultType.CONTINUE]
assert [(message.role, message.content) for message in query.resp_messages] == [
('assistant', 'LANGBOT_FAKE_PONG')
]
@pytest.mark.asyncio
async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter):
+6 -6
View File
@@ -14,16 +14,14 @@ import uuid
import pytest
pytest.importorskip('pyseekdb')
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
pytestmark = [pytest.mark.integration, pytest.mark.slow]
pytestmark = [pytest.mark.integration, pytest.mark.slow, pytest.mark.seekdb]
@pytest.fixture
async def backend(tmp_path):
pytest.importorskip('pyseekdb')
from langbot.pkg.vector.vdbs.seekdb import SeekDBVectorDatabase
app = SimpleNamespace(
instance_config=SimpleNamespace(
data={
@@ -51,6 +49,7 @@ async def backend(tmp_path):
@pytest.mark.asyncio
@pytest.mark.contract('vector_round_trip')
async def test_upsert_and_text_round_trip(backend) -> None:
database, collection = backend
original = 'He said "hello".\nC:\\notes\\file.txt isn\'t empty. 中文'
@@ -80,6 +79,7 @@ async def test_upsert_and_text_round_trip(backend) -> None:
@pytest.mark.asyncio
@pytest.mark.contract('hybrid_relevance')
async def test_full_text_and_hybrid_results_keep_relevance_order(backend) -> None:
database, collection = backend
documents = [
+18 -5
View File
@@ -169,6 +169,7 @@ async def test_full_text_search(backend):
async def test_hybrid_filter_then_knn(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
documents = ['cats nearby', 'cats elsewhere', 'cats farther']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
@@ -180,10 +181,10 @@ async def test_hybrid_filter_then_knn(backend):
query_text='cats',
filter={'file_id': 'fileA'},
),
lambda r: len(r['ids'][0]) >= 1,
lambda r: len(r['ids'][0]) == 2,
)
# Only fileA docs (d1, d3) should be candidates.
assert set(result['ids'][0]).issubset({'d1', 'd3'})
assert result['ids'][0] == ['d1', 'd3']
assert [item['file_id'] for item in result['metadatas'][0]] == ['fileA', 'fileA']
@pytest.mark.asyncio
@@ -191,16 +192,17 @@ async def test_vector_weight_not_honored(backend):
"""Passing different vector_weight values must NOT change ranking."""
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
documents = ['cats nearby', 'cats elsewhere', 'cats farther']
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
common = dict(
collection=collection, query_embedding=[1.0, 0.0, 0.0, 0.0], k=3, search_type='hybrid', query_text='cats'
)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) >= 1)
await _poll_until(lambda: db.search(**common), lambda r: len(r['ids'][0]) == 3)
r_low = await db.search(**common, vector_weight=0.1)
r_high = await db.search(**common, vector_weight=0.9)
assert r_low['ids'][0] == r_high['ids'][0]
assert r_low['ids'][0] == r_high['ids'][0] == ['d1', 'd3', 'd2']
@pytest.mark.asyncio
@@ -257,6 +259,9 @@ async def test_delete_by_filter_returns_count(backend):
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
assert deleted == 2
items, total = await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda result: result[1] == 1)
assert total == 1
assert [item['id'] for item in items] == ['d2']
@pytest.mark.asyncio
@@ -273,6 +278,10 @@ async def test_list_by_filter_pagination(backend):
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
assert total == 3
assert len(page2) == 1
first_ids = {item['id'] for item in page1}
second_ids = {item['id'] for item in page2}
assert first_ids.isdisjoint(second_ids)
assert first_ids | second_ids == {'d1', 'd2', 'd3'}
@pytest.mark.asyncio
@@ -282,7 +291,11 @@ async def test_delete_collection(backend):
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
await _poll_until(lambda: db.list_by_filter(collection, limit=10), lambda r: r[1] >= 3)
client = await db._ensure_client()
keys = [f'kb:{collection}:{document_id}' for document_id in ids]
assert await client.exists(keys) == 3
await db.delete_collection(collection)
assert await client.exists(keys) == 0
# After dropping, search on a missing index returns empty.
result = await db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector')