mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-24 09:25:21 +08:00
chore(merge): sync master and preserve monitoring test coverage
This commit is contained in:
@@ -125,7 +125,7 @@ def fake_bot_app():
|
||||
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
|
||||
}
|
||||
)
|
||||
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
|
||||
app.bot_service.create_bot = AsyncMock(return_value='new-bot-uuid')
|
||||
app.bot_service.update_bot = AsyncMock(return_value={})
|
||||
app.bot_service.delete_bot = AsyncMock()
|
||||
app.bot_service.list_event_logs = AsyncMock(return_value=([{'uuid': 'log-1', 'message': 'test log'}], 1))
|
||||
@@ -179,6 +179,11 @@ def fake_bot_app():
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_service_calls(fake_bot_app):
|
||||
fake_bot_app.bot_service.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_bot_app, http_controller_cls):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
@@ -201,11 +206,17 @@ class TestBotEndpoints:
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert 'data' in data
|
||||
assert 'bots' in data['data']
|
||||
assert data['data']['bots'] == [
|
||||
{
|
||||
'uuid': 'test-bot-uuid',
|
||||
'name': 'Test Bot',
|
||||
'platform': 'telegram',
|
||||
'pipeline_uuid': 'test-pipeline-uuid',
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_bot_success(self, quart_test_client):
|
||||
async def test_create_bot_success(self, quart_test_client, fake_bot_app):
|
||||
"""POST /api/v1/platform/bots creates new bot."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/platform/bots',
|
||||
@@ -218,8 +229,17 @@ class TestBotEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_bot_app.bot_service.create_bot
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == (
|
||||
{'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'},
|
||||
)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'new-bot-uuid'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_bot_success(self, quart_test_client):
|
||||
async def test_get_single_bot_success(self, quart_test_client, fake_bot_app):
|
||||
"""GET /api/v1/platform/bots/{uuid} returns bot with runtime info."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -230,8 +250,21 @@ class TestBotEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'bot' in data['data']
|
||||
|
||||
called = fake_bot_app.bot_service.get_runtime_bot_info
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-bot-uuid',)
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['bot'] == {
|
||||
'uuid': 'test-bot-uuid',
|
||||
'name': 'Test Bot',
|
||||
'platform': 'telegram',
|
||||
'pipeline_uuid': 'test-pipeline-uuid',
|
||||
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_bot_success(self, quart_test_client):
|
||||
async def test_update_bot_success(self, quart_test_client, fake_bot_app):
|
||||
"""PUT /api/v1/platform/bots/{uuid} updates bot."""
|
||||
response = await quart_test_client.put(
|
||||
'/api/v1/platform/bots/test-bot-uuid',
|
||||
@@ -243,8 +276,14 @@ class TestBotEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_bot_app.bot_service.update_bot
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-bot-uuid', {'name': 'Updated Bot'})
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_bot_success(self, quart_test_client):
|
||||
async def test_delete_bot_success(self, quart_test_client, fake_bot_app):
|
||||
"""DELETE /api/v1/platform/bots/{uuid} deletes bot."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/platform/bots/test-bot-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -252,13 +291,20 @@ class TestBotEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
called = fake_bot_app.bot_service.delete_bot
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-bot-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestBotLogsEndpoint:
|
||||
"""Tests for bot logs endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bot_logs_success(self, quart_test_client):
|
||||
async def test_get_bot_logs_success(self, quart_test_client, fake_bot_app):
|
||||
"""POST /api/v1/platform/bots/{uuid}/logs returns logs."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/platform/bots/test-bot-uuid/logs',
|
||||
@@ -272,6 +318,13 @@ class TestBotLogsEndpoint:
|
||||
assert 'logs' in data['data']
|
||||
assert 'total_count' in data['data']
|
||||
|
||||
called = fake_bot_app.bot_service.list_event_logs
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-bot-uuid', -1, 10)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'logs': [{'uuid': 'log-1', 'message': 'test log'}], 'total_count': 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_can_read_ordinary_bot_logs(self, quart_test_client, fake_bot_app):
|
||||
access = fake_bot_app.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
@@ -366,7 +419,7 @@ class TestBotSendMessageEndpoint:
|
||||
"""Tests for bot send message endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_success(self, quart_test_client):
|
||||
async def test_send_message_success(self, quart_test_client, fake_bot_app):
|
||||
"""POST /api/v1/platform/bots/{uuid}/send_message sends message."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/platform/bots/test-bot-uuid/send_message',
|
||||
@@ -383,6 +436,12 @@ class TestBotSendMessageEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert data['data']['sent'] is True
|
||||
|
||||
called = fake_bot_app.bot_service.send_message
|
||||
called.assert_awaited_once()
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-bot-uuid', 'person', 'user123', [{'type': 'text', 'text': 'Hello'}])
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_missing_target_type(self, quart_test_client):
|
||||
"""POST send_message without target_type returns 400."""
|
||||
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock
|
||||
@@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint:
|
||||
assert 'javascript' in response.content_type
|
||||
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
|
||||
|
||||
body = await response.get_data(as_text=True)
|
||||
assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body
|
||||
assert 'baseUrl: "http://localhost"' in body
|
||||
assert '__LANGBOT_' not in body
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('query', 'expected_base'),
|
||||
@@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint:
|
||||
assert response.status_code == 200
|
||||
assert 'image/webp' in response.content_type
|
||||
|
||||
body = await response.get_data()
|
||||
assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes()
|
||||
assert body[:4] == b'RIFF' and body[8:12] == b'WEBP'
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestEmbedTurnstileVerifyEndpoint:
|
||||
"""Tests for Turnstile verification endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turnstile_verify_no_secret(self, quart_test_client):
|
||||
async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch):
|
||||
"""POST turnstile verify without secret returns dummy token."""
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
|
||||
|
||||
monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25)
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
|
||||
)
|
||||
@@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert 'token' in data['data']
|
||||
|
||||
assert data['data']['token'] == '1720000000.25.dummy'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
|
||||
"""POST turnstile verify with invalid UUID returns 400."""
|
||||
@@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint:
|
||||
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_group_success(self, quart_test_client):
|
||||
async def test_get_messages_group_success(self, quart_test_client, fake_embed_app):
|
||||
"""GET messages/group returns messages."""
|
||||
response = await quart_test_client.get(
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
|
||||
@@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}]
|
||||
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
|
||||
adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_requires_session_id(self, quart_test_client):
|
||||
"""GET messages without a client session identifier returns 400."""
|
||||
@@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint:
|
||||
async def test_get_messages_invalid_session_type(self, quart_test_client):
|
||||
"""GET messages with invalid session_type returns 400."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid',
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
assert (await response.get_json())['msg'] == 'session_type must be person or group'
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestEmbedResetEndpoint:
|
||||
@@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint:
|
||||
"""Tests for feedback submission endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_feedback_like(self, quart_test_client):
|
||||
async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch):
|
||||
"""POST feedback with type=1 (like) succeeds."""
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
|
||||
|
||||
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
|
||||
fake_embed_app.monitoring_service.record_feedback.reset_mock()
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
@@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert 'feedback_id' in data['data']
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['feedback_id'] == 'embed_123456789abc'
|
||||
called = fake_embed_app.monitoring_service.record_feedback
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.kwargs == {
|
||||
'feedback_id': 'embed_123456789abc',
|
||||
'feedback_type': 1,
|
||||
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
|
||||
'bot_name': 'Test Web Bot',
|
||||
'pipeline_id': 'test-pipeline-uuid',
|
||||
'message_id': 'msg-123',
|
||||
'platform': 'web_page_bot',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_feedback_dislike(self, quart_test_client):
|
||||
async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch):
|
||||
"""POST feedback with type=2 (dislike) succeeds."""
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
|
||||
|
||||
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
|
||||
fake_embed_app.monitoring_service.record_feedback.reset_mock()
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
@@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data']['feedback_id'] == 'embed_123456789abc'
|
||||
called = fake_embed_app.monitoring_service.record_feedback
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.kwargs == {
|
||||
'feedback_id': 'embed_123456789abc',
|
||||
'feedback_type': 2,
|
||||
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
|
||||
'bot_name': 'Test Web Bot',
|
||||
'pipeline_id': 'test-pipeline-uuid',
|
||||
'message_id': 'msg-123',
|
||||
'platform': 'web_page_bot',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_feedback_invalid_type(self, quart_test_client):
|
||||
"""POST feedback with invalid type returns 400."""
|
||||
@@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint:
|
||||
"""The public socket authenticates before resolving shared runtime state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
|
||||
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch):
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
|
||||
|
||||
authenticated = False
|
||||
original_authenticate = embed.EmbedRouterGroup._authenticate_websocket
|
||||
original_add = embed.ws_connection_manager.add_connection
|
||||
proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot
|
||||
|
||||
async def authenticate(router, runtime_bot):
|
||||
nonlocal authenticated
|
||||
await original_authenticate(router, runtime_bot)
|
||||
authenticated = True
|
||||
|
||||
async def get_proxy(*_args, **_kwargs):
|
||||
assert authenticated, 'Runtime lookup must follow successful authentication'
|
||||
return proxy.return_value
|
||||
|
||||
async def add_connection(*args, **kwargs):
|
||||
assert authenticated, 'Connection registration must follow successful authentication'
|
||||
return await original_add(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate)
|
||||
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
|
||||
monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection)
|
||||
async with quart_test_client.websocket(
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
|
||||
f'?session_type=person&session_id={SESSION_ID}',
|
||||
|
||||
@@ -117,13 +117,13 @@ def fake_knowledge_app():
|
||||
'engine_plugin_id': 'test/engine',
|
||||
}
|
||||
)
|
||||
app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': 'new-kb-uuid'})
|
||||
app.knowledge_service.create_knowledge_base = AsyncMock(return_value='new-kb-uuid')
|
||||
app.knowledge_service.update_knowledge_base = AsyncMock(return_value={})
|
||||
app.knowledge_service.delete_knowledge_base = AsyncMock()
|
||||
app.knowledge_service.get_files_by_knowledge_base = AsyncMock(
|
||||
return_value=[{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
|
||||
)
|
||||
app.knowledge_service.store_file = AsyncMock(return_value={'task_id': 'test-task-id'})
|
||||
app.knowledge_service.store_file = AsyncMock(return_value='test-task-id')
|
||||
app.knowledge_service.delete_file = AsyncMock()
|
||||
app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}])
|
||||
|
||||
@@ -133,6 +133,11 @@ def fake_knowledge_app():
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_service_calls(fake_knowledge_app):
|
||||
fake_knowledge_app.knowledge_service.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_knowledge_app, http_controller_cls):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
@@ -148,7 +153,7 @@ class TestKnowledgeBaseEndpoints:
|
||||
"""Tests for /api/v1/knowledge/bases endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_knowledge_bases_success(self, quart_test_client):
|
||||
async def test_get_knowledge_bases_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""GET /api/v1/knowledge/bases returns knowledge base list."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -160,8 +165,24 @@ class TestKnowledgeBaseEndpoints:
|
||||
assert 'data' in data
|
||||
assert 'bases' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.get_knowledge_bases
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ()
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['bases'] == [
|
||||
{
|
||||
'uuid': 'test-kb-uuid',
|
||||
'name': 'Test Knowledge Base',
|
||||
'description': 'Test KB description',
|
||||
'engine_plugin_id': 'test/engine',
|
||||
'created_at': '2024-01-01T00:00:00',
|
||||
'updated_at': '2024-01-01T00:00:00',
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_knowledge_base_success(self, quart_test_client):
|
||||
async def test_create_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""POST /api/v1/knowledge/bases creates new knowledge base."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/knowledge/bases',
|
||||
@@ -174,8 +195,15 @@ class TestKnowledgeBaseEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.create_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ({'name': 'New KB', 'engine_plugin_id': 'test/engine'},)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'new-kb-uuid'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_knowledge_base_success(self, quart_test_client):
|
||||
async def test_get_single_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""GET /api/v1/knowledge/bases/{uuid} returns knowledge base."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -186,8 +214,20 @@ class TestKnowledgeBaseEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'base' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.get_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid',)
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['base'] == {
|
||||
'uuid': 'test-kb-uuid',
|
||||
'name': 'Test Knowledge Base',
|
||||
'description': 'Test KB description',
|
||||
'engine_plugin_id': 'test/engine',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_knowledge_base_success(self, quart_test_client):
|
||||
async def test_update_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""PUT /api/v1/knowledge/bases/{uuid} updates knowledge base."""
|
||||
response = await quart_test_client.put(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid',
|
||||
@@ -199,8 +239,14 @@ class TestKnowledgeBaseEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.update_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid', {'name': 'Updated KB'})
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_knowledge_base_success(self, quart_test_client):
|
||||
async def test_delete_knowledge_base_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""DELETE /api/v1/knowledge/bases/{uuid} deletes knowledge base."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -208,13 +254,20 @@ class TestKnowledgeBaseEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.delete_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestKnowledgeBaseFilesEndpoints:
|
||||
"""Tests for knowledge base files endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_files_success(self, quart_test_client):
|
||||
async def test_get_files_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""GET /api/v1/knowledge/bases/{uuid}/files returns files."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid/files', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -225,8 +278,15 @@ class TestKnowledgeBaseFilesEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'files' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.get_files_by_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data']['files'] == [{'uuid': 'test-file-uuid', 'filename': 'test.pdf'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_file_to_knowledge_base(self, quart_test_client):
|
||||
async def test_add_file_to_knowledge_base(self, quart_test_client, fake_knowledge_app):
|
||||
"""POST /api/v1/knowledge/bases/{uuid}/files adds file."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid/files',
|
||||
@@ -239,8 +299,15 @@ class TestKnowledgeBaseFilesEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'task_id' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.store_file
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-id')
|
||||
assert called.await_args.kwargs == {'parser_plugin_id': 'test/parser'}
|
||||
assert data['data'] == {'task_id': 'test-task-id'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_from_knowledge_base(self, quart_test_client):
|
||||
async def test_delete_file_from_knowledge_base(self, quart_test_client, fake_knowledge_app):
|
||||
"""DELETE /api/v1/knowledge/bases/{uuid}/files/{file_id}."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid/files/test-file-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -248,13 +315,20 @@ class TestKnowledgeBaseFilesEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.delete_file
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid', 'test-file-uuid')
|
||||
assert called.await_args.kwargs == {}
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestKnowledgeBaseRetrieveEndpoint:
|
||||
"""Tests for knowledge base retrieval endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_knowledge_success(self, quart_test_client):
|
||||
async def test_retrieve_knowledge_success(self, quart_test_client, fake_knowledge_app):
|
||||
"""POST /api/v1/knowledge/bases/{uuid}/retrieve."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/knowledge/bases/test-kb-uuid/retrieve',
|
||||
@@ -267,6 +341,13 @@ class TestKnowledgeBaseRetrieveEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert 'results' in data['data']
|
||||
|
||||
called = fake_knowledge_app.knowledge_service.retrieve_knowledge_base
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.args[1:] == ('test-kb-uuid', 'test query', {'top_k': 5})
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data']['results'] == [{'content': 'test result', 'score': 0.95}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_without_query_returns_error(self, quart_test_client):
|
||||
"""POST retrieve without query returns 400."""
|
||||
|
||||
@@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
@@ -122,17 +125,24 @@ def fake_monitoring_app():
|
||||
)
|
||||
app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10})
|
||||
app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12))
|
||||
app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}])
|
||||
app.monitoring_service.export_messages = AsyncMock(
|
||||
return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}]
|
||||
)
|
||||
app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}])
|
||||
app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}])
|
||||
app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}])
|
||||
app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}])
|
||||
app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}])
|
||||
app.monitoring_service._escape_csv_field = Mock(return_value='escaped')
|
||||
app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_monitoring_calls(fake_monitoring_app):
|
||||
fake_monitoring_app.monitoring_service.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_monitoring_app, http_controller_cls):
|
||||
"""Create Quart test client (module scope)."""
|
||||
@@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
assert data['data'] == {
|
||||
'total_messages': 100,
|
||||
'total_llm_calls': 50,
|
||||
'total_sessions': 20,
|
||||
'active_sessions': 5,
|
||||
'total_errors': 2,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_can_read_monitoring_but_cannot_export(
|
||||
self,
|
||||
@@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert 'messages' in data['data']
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {
|
||||
'messages': [{'id': 'msg-1', 'content': 'test'}],
|
||||
'total': 100,
|
||||
'limit': 100,
|
||||
'offset': 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringLLMCallsEndpoint:
|
||||
@@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringEmbeddingCallsEndpoint:
|
||||
@@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringSessionsEndpoint:
|
||||
@@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||
@@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringAllDataEndpoint:
|
||||
@@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint:
|
||||
assert 'overview' in data['data']
|
||||
assert data['data']['traffic'] == traffic
|
||||
|
||||
assert data['code'] == 0
|
||||
assert data['data']['overview'] == {
|
||||
'total_messages': 100,
|
||||
'total_llm_calls': 50,
|
||||
'total_sessions': 20,
|
||||
'active_sessions': 5,
|
||||
'total_errors': 2,
|
||||
}
|
||||
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}]
|
||||
assert data['data']['llmCalls'] == [{'id': 'llm-1'}]
|
||||
assert data['data']['toolCalls'] == [{'id': 'tool-1'}]
|
||||
assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}]
|
||||
assert data['data']['sessions'] == [{'session_id': 'sess-1'}]
|
||||
assert data['data']['errors'] == [{'id': 'err-1'}]
|
||||
assert data['data']['totalCount'] == {
|
||||
'messages': 100,
|
||||
'llmCalls': 50,
|
||||
'toolCalls': 5,
|
||||
'embeddingCalls': 10,
|
||||
'sessions': 20,
|
||||
'errors': 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringDetailsEndpoints:
|
||||
@@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'found': True, 'message_id': 'msg-1'}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringFeedbackEndpoints:
|
||||
@@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'like_count': 10}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_feedback_list(self, quart_test_client):
|
||||
"""GET /api/v1/monitoring/feedback."""
|
||||
@@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestMonitoringExportEndpoint:
|
||||
"""Tests for /api/v1/monitoring/export endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_messages(self, quart_test_client):
|
||||
async def test_export_messages(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET export?type=messages returns CSV."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint:
|
||||
assert response.status_code == 200
|
||||
assert 'text/csv' in response.content_type
|
||||
|
||||
assert 'text/csv' in response.content_type
|
||||
assert 'monitoring-messages-' in response.headers['Content-Disposition']
|
||||
body = (await response.get_data()).decode('utf-8-sig')
|
||||
records = list(csv.DictReader(io.StringIO(body)))
|
||||
assert len(records) == 1
|
||||
assert records[0]['id'] == 'msg-1'
|
||||
called = fake_monitoring_app.monitoring_service.export_messages
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.kwargs['limit'] == 100000
|
||||
assert records[0]['message_content'] == 'hello, "quoted"'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_llm_calls(self, quart_test_client):
|
||||
async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET export?type=llm-calls returns CSV."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert 'text/csv' in response.content_type
|
||||
assert 'monitoring-llm-calls-' in response.headers['Content-Disposition']
|
||||
body = (await response.get_data()).decode('utf-8-sig')
|
||||
records = list(csv.DictReader(io.StringIO(body)))
|
||||
assert len(records) == 1
|
||||
assert records[0]['id'] == 'llm-1'
|
||||
called = fake_monitoring_app.monitoring_service.export_llm_calls
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.kwargs['limit'] == 100000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_sessions(self, quart_test_client):
|
||||
async def test_export_sessions(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET export?type=sessions returns CSV."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert 'text/csv' in response.content_type
|
||||
assert 'monitoring-sessions-' in response.headers['Content-Disposition']
|
||||
body = (await response.get_data()).decode('utf-8-sig')
|
||||
records = list(csv.DictReader(io.StringIO(body)))
|
||||
assert len(records) == 1
|
||||
assert records[0]['session_id'] == 'sess-1'
|
||||
called = fake_monitoring_app.monitoring_service.export_sessions
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.kwargs['limit'] == 100000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_feedback(self, quart_test_client):
|
||||
async def test_export_feedback(self, quart_test_client, fake_monitoring_app):
|
||||
"""GET export?type=feedback returns CSV."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert 'text/csv' in response.content_type
|
||||
assert 'monitoring-feedback-' in response.headers['Content-Disposition']
|
||||
body = (await response.get_data()).decode('utf-8-sig')
|
||||
records = list(csv.DictReader(io.StringIO(body)))
|
||||
assert len(records) == 1
|
||||
assert records[0]['id'] == 'fb-1'
|
||||
called = fake_monitoring_app.monitoring_service.export_feedback
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
|
||||
assert called.await_args.kwargs['limit'] == 100000
|
||||
|
||||
@@ -128,10 +128,10 @@ def fake_pipeline_app():
|
||||
'config': {},
|
||||
}
|
||||
)
|
||||
app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': 'new-pipeline-uuid'})
|
||||
app.pipeline_service.create_pipeline = AsyncMock(return_value='new-pipeline-uuid')
|
||||
app.pipeline_service.update_pipeline = AsyncMock(return_value={})
|
||||
app.pipeline_service.delete_pipeline = AsyncMock()
|
||||
app.pipeline_service.copy_pipeline = AsyncMock(return_value={'uuid': 'copied-pipeline-uuid'})
|
||||
app.pipeline_service.copy_pipeline = AsyncMock(return_value='copied-pipeline-uuid')
|
||||
|
||||
# Bot service
|
||||
app.bot_service = Mock()
|
||||
@@ -161,6 +161,11 @@ def fake_pipeline_app():
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_service_calls(fake_pipeline_app):
|
||||
fake_pipeline_app.pipeline_service.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_pipeline_app, http_controller_cls):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
@@ -179,7 +184,7 @@ class TestPipelineMetadataEndpoint:
|
||||
"""Tests for /api/v1/pipelines/_/metadata endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_metadata_success(self, quart_test_client):
|
||||
async def test_get_pipeline_metadata_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""GET /api/v1/pipelines/_/metadata returns metadata list."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -191,6 +196,13 @@ class TestPipelineMetadataEndpoint:
|
||||
assert 'data' in data
|
||||
assert isinstance(data['data'], dict)
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.get_pipeline_metadata
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ()
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'configs': [{'name': 'trigger', 'stages': []}, {'name': 'ai', 'stages': []}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_metadata_requires_auth(self, quart_test_client):
|
||||
"""Pipeline metadata endpoint requires authentication."""
|
||||
@@ -203,7 +215,7 @@ class TestPipelinesListEndpoint:
|
||||
"""Tests for /api/v1/pipelines endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipelines_success(self, quart_test_client):
|
||||
async def test_get_pipelines_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""GET /api/v1/pipelines returns pipeline list."""
|
||||
response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'})
|
||||
|
||||
@@ -212,24 +224,46 @@ class TestPipelinesListEndpoint:
|
||||
assert data['code'] == 0
|
||||
assert 'data' in data
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.get_pipelines
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('created_at', 'DESC')
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['pipelines'] == [
|
||||
{
|
||||
'uuid': 'test-pipeline-uuid',
|
||||
'name': 'Test Pipeline',
|
||||
'description': 'Test description',
|
||||
'created_at': '2024-01-01T00:00:00',
|
||||
'updated_at': '2024-01-01T00:00:00',
|
||||
'is_default': False,
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipelines_with_sort_param(self, quart_test_client):
|
||||
async def test_get_pipelines_with_sort_param(self, quart_test_client, fake_pipeline_app):
|
||||
"""GET pipelines with sort parameter."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/pipelines?sort_by=created_at&sort_order=DESC', headers={'Authorization': 'Bearer test_token'}
|
||||
'/api/v1/pipelines?sort_by=name&sort_order=ASC', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.get_pipelines
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('name', 'ASC')
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestPipelinesCRUDEndpoints:
|
||||
"""Tests for pipeline CRUD operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_pipeline_success(self, quart_test_client):
|
||||
async def test_get_single_pipeline_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""GET /api/v1/pipelines/{uuid} returns pipeline."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -240,8 +274,15 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'data' in data
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.get_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['pipeline'] == {'uuid': 'test-pipeline-uuid', 'name': 'Test Pipeline', 'config': {}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pipeline_success(self, quart_test_client):
|
||||
async def test_create_pipeline_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines creates new pipeline."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines',
|
||||
@@ -254,6 +295,13 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.create_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ({'name': 'New Pipeline', 'config': {}},)
|
||||
assert called.await_args.kwargs == {'default': False}
|
||||
assert data['data'] == {'uuid': 'new-pipeline-uuid'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||
@@ -271,7 +319,7 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert call.args[1]['is_default'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_success(self, quart_test_client):
|
||||
async def test_update_pipeline_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||
response = await quart_test_client.put(
|
||||
'/api/v1/pipelines/test-pipeline-uuid',
|
||||
@@ -283,8 +331,14 @@ class TestPipelinesCRUDEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.update_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-pipeline-uuid', {'name': 'Updated Pipeline'})
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pipeline_success(self, quart_test_client):
|
||||
async def test_delete_pipeline_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""DELETE /api/v1/pipelines/{uuid} deletes pipeline."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -294,8 +348,14 @@ class TestPipelinesCRUDEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.delete_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_pipeline_success(self, quart_test_client):
|
||||
async def test_copy_pipeline_success(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines/{uuid}/copy copies pipeline."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines/test-pipeline-uuid/copy', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -306,14 +366,24 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.copy_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'copied-pipeline-uuid'}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestPipelineExtensionsEndpoint:
|
||||
"""Tests for pipeline extensions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_extensions(self, quart_test_client):
|
||||
async def test_get_extensions(self, quart_test_client, fake_pipeline_app):
|
||||
"""GET /api/v1/pipelines/{uuid}/extensions."""
|
||||
fake_pipeline_app.plugin_connector.list_plugins.return_value = [{'name': 'extension-plugin'}]
|
||||
fake_pipeline_app.mcp_service.get_mcp_servers.return_value = [{'uuid': 'extension-mcp'}]
|
||||
fake_pipeline_app.skill_service.list_skills.return_value = [{'name': 'extension-skill'}]
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
@@ -323,6 +393,21 @@ class TestPipelineExtensionsEndpoint:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_pipeline_app.pipeline_service.get_pipeline
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-pipeline-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data']['available_plugins'] == [{'name': 'extension-plugin'}]
|
||||
assert data['data']['available_mcp_servers'] == [{'uuid': 'extension-mcp'}]
|
||||
assert data['data']['available_skills'] == [{'name': 'extension-skill'}]
|
||||
assert data['data']['bound_plugins'] == []
|
||||
assert data['data']['bound_mcp_servers'] == []
|
||||
assert data['data']['bound_skills'] == []
|
||||
assert data['data']['enable_all_plugins'] is False
|
||||
assert data['data']['enable_all_mcp_servers'] is False
|
||||
assert data['data']['enable_all_skills'] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_extensions_redacts_available_plugin_secrets(
|
||||
self,
|
||||
@@ -377,7 +462,31 @@ class TestPipelineExtensionsEndpoint:
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestPipelineDashboardWebSocket:
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app):
|
||||
async def test_websocket_authenticates_before_registering(self, quart_test_client, fake_pipeline_app, monkeypatch):
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
|
||||
|
||||
authenticated = False
|
||||
original_authenticate = chat.WebSocketChatRouterGroup._authenticate_websocket
|
||||
original_add = chat.ws_connection_manager.add_connection
|
||||
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
|
||||
|
||||
async def authenticate(router):
|
||||
nonlocal authenticated
|
||||
result = await original_authenticate(router)
|
||||
authenticated = True
|
||||
return result
|
||||
|
||||
async def get_proxy(*_args, **_kwargs):
|
||||
assert authenticated, 'Runtime lookup must follow authentication'
|
||||
return proxy.return_value
|
||||
|
||||
async def add_connection(*args, **kwargs):
|
||||
assert authenticated, 'Connection registration must follow authentication'
|
||||
return await original_add(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(chat.WebSocketChatRouterGroup, '_authenticate_websocket', authenticate)
|
||||
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
|
||||
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
|
||||
async with quart_test_client.websocket(
|
||||
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
|
||||
headers={'Origin': 'http://localhost'},
|
||||
@@ -403,7 +512,13 @@ class TestPipelineDashboardWebSocket:
|
||||
fake_pipeline_app.platform_mgr.get_websocket_proxy_bot.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client):
|
||||
async def test_websocket_rejects_non_auth_first_frame(self, quart_test_client, fake_pipeline_app, monkeypatch):
|
||||
import langbot.pkg.api.http.controller.groups.pipelines.websocket_chat as chat
|
||||
|
||||
proxy = fake_pipeline_app.platform_mgr.get_websocket_proxy_bot
|
||||
proxy.reset_mock()
|
||||
add_connection = AsyncMock(wraps=chat.ws_connection_manager.add_connection)
|
||||
monkeypatch.setattr(chat.ws_connection_manager, 'add_connection', add_connection)
|
||||
async with quart_test_client.websocket(
|
||||
'/api/v1/pipelines/test-pipeline-uuid/ws/connect?session_type=person',
|
||||
headers={'Origin': 'http://localhost'},
|
||||
@@ -412,6 +527,9 @@ class TestPipelineDashboardWebSocket:
|
||||
response = json.loads(await websocket.receive())
|
||||
assert response == {'type': 'error', 'message': 'Unauthorized'}
|
||||
|
||||
proxy.assert_not_awaited()
|
||||
add_connection.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_rechecks_revocable_membership_before_each_message(
|
||||
self,
|
||||
|
||||
@@ -109,21 +109,23 @@ def fake_provider_app():
|
||||
app.llm_model_service = Mock()
|
||||
app.llm_model_service.get_llm_models = AsyncMock(return_value=[{'uuid': 'test-model-uuid', 'name': 'gpt-4'}])
|
||||
app.llm_model_service.get_llm_model = AsyncMock(return_value={'uuid': 'test-model-uuid', 'name': 'gpt-4'})
|
||||
app.llm_model_service.create_llm_model = AsyncMock(return_value={'uuid': 'new-model-uuid'})
|
||||
app.llm_model_service.create_llm_model = AsyncMock(return_value='new-model-uuid')
|
||||
app.llm_model_service.update_llm_model = AsyncMock(return_value={})
|
||||
app.llm_model_service.delete_llm_model = AsyncMock()
|
||||
app.llm_model_service.test_llm_model = AsyncMock()
|
||||
|
||||
# Embedding model service
|
||||
app.embedding_models_service = Mock()
|
||||
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
|
||||
app.embedding_models_service.create_embedding_model = AsyncMock(return_value={'uuid': 'new-embedding-uuid'})
|
||||
app.embedding_models_service.get_embedding_models = AsyncMock(
|
||||
return_value=[{'uuid': 'embedding-model', 'name': 'Embedding'}]
|
||||
)
|
||||
app.embedding_models_service.create_embedding_model = AsyncMock(return_value='new-embedding-uuid')
|
||||
app.embedding_models_service.test_embedding_model = AsyncMock()
|
||||
|
||||
# Rerank model service
|
||||
app.rerank_models_service = Mock()
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
app.rerank_models_service.create_rerank_model = AsyncMock(return_value={'uuid': 'new-rerank-uuid'})
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[{'uuid': 'rerank-model', 'name': 'Rerank'}])
|
||||
app.rerank_models_service.create_rerank_model = AsyncMock(return_value='new-rerank-uuid')
|
||||
app.rerank_models_service.test_rerank_model = AsyncMock()
|
||||
|
||||
# Model manager
|
||||
@@ -134,6 +136,14 @@ def fake_provider_app():
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_service_calls(fake_provider_app):
|
||||
fake_provider_app.provider_service.reset_mock()
|
||||
fake_provider_app.llm_model_service.reset_mock()
|
||||
fake_provider_app.embedding_models_service.reset_mock()
|
||||
fake_provider_app.rerank_models_service.reset_mock()
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_provider_app, http_controller_cls):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
@@ -206,7 +216,7 @@ class TestProviderEndpoints:
|
||||
assert data['data']['uuid'] == 'new-provider-uuid'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_provider_success(self, quart_test_client):
|
||||
async def test_update_provider_success(self, quart_test_client, fake_provider_app):
|
||||
"""PUT /api/v1/provider/providers/{uuid} updates provider."""
|
||||
response = await quart_test_client.put(
|
||||
'/api/v1/provider/providers/test-provider-uuid',
|
||||
@@ -218,8 +228,14 @@ class TestProviderEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_provider_app.provider_service.update_provider
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-provider-uuid', {'name': 'Updated Provider'})
|
||||
assert called.await_args.kwargs == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_provider_success(self, quart_test_client):
|
||||
async def test_delete_provider_success(self, quart_test_client, fake_provider_app):
|
||||
"""DELETE /api/v1/provider/providers/{uuid} deletes provider."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -227,8 +243,15 @@ class TestProviderEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
called = fake_provider_app.provider_service.delete_provider
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-provider-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_provider_includes_model_counts(self, quart_test_client):
|
||||
async def test_get_provider_includes_model_counts(self, quart_test_client, fake_provider_app):
|
||||
"""GET provider response includes model counts."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -243,13 +266,24 @@ class TestProviderEndpoints:
|
||||
assert 'embedding_count' in provider_data
|
||||
assert 'rerank_count' in provider_data
|
||||
|
||||
called = fake_provider_app.provider_service.get_provider_model_counts
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-provider-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert {key: provider_data[key] for key in ('llm_count', 'embedding_count', 'rerank_count')} == {
|
||||
'llm_count': 2,
|
||||
'embedding_count': 1,
|
||||
'rerank_count': 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestModelEndpoints:
|
||||
"""Tests for /api/v1/provider/models endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_llm_models_success(self, quart_test_client):
|
||||
async def test_get_llm_models_success(self, quart_test_client, fake_provider_app):
|
||||
"""GET /api/v1/provider/models/llm returns model list."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/provider/models/llm', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -260,8 +294,15 @@ class TestModelEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'data' in data
|
||||
|
||||
called = fake_provider_app.llm_model_service.get_llm_models
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ()
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['models'] == [{'uuid': 'test-model-uuid', 'name': 'gpt-4'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_single_llm_model_success(self, quart_test_client):
|
||||
async def test_get_single_llm_model_success(self, quart_test_client, fake_provider_app):
|
||||
"""GET /api/v1/provider/models/llm/{uuid} returns model."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -271,8 +312,15 @@ class TestModelEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
|
||||
called = fake_provider_app.llm_model_service.get_llm_model
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-model-uuid',)
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['model'] == {'uuid': 'test-model-uuid', 'name': 'gpt-4'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_llm_model_success(self, quart_test_client):
|
||||
async def test_create_llm_model_success(self, quart_test_client, fake_provider_app):
|
||||
"""POST /api/v1/provider/models/llm creates new model."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/provider/models/llm',
|
||||
@@ -285,8 +333,15 @@ class TestModelEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_provider_app.llm_model_service.create_llm_model
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ({'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'new-model-uuid'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_llm_model_success(self, quart_test_client):
|
||||
async def test_delete_llm_model_success(self, quart_test_client, fake_provider_app):
|
||||
"""DELETE /api/v1/provider/models/llm/{uuid} deletes model."""
|
||||
response = await quart_test_client.delete(
|
||||
'/api/v1/provider/models/llm/test-model-uuid', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -294,6 +349,13 @@ class TestModelEndpoints:
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
called = fake_provider_app.llm_model_service.delete_llm_model
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ('test-model-uuid',)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('service_name', 'method_name', 'path'),
|
||||
@@ -337,7 +399,7 @@ class TestEmbeddingModelEndpoints:
|
||||
"""Tests for /api/v1/provider/models/embedding endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_embedding_models_success(self, quart_test_client):
|
||||
async def test_get_embedding_models_success(self, quart_test_client, fake_provider_app):
|
||||
"""GET /api/v1/provider/models/embedding returns model list."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/provider/models/embedding', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -348,8 +410,15 @@ class TestEmbeddingModelEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'models' in data['data']
|
||||
|
||||
called = fake_provider_app.embedding_models_service.get_embedding_models
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ()
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['models'] == [{'uuid': 'embedding-model', 'name': 'Embedding'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_embedding_model_success(self, quart_test_client):
|
||||
async def test_create_embedding_model_success(self, quart_test_client, fake_provider_app):
|
||||
"""POST /api/v1/provider/models/embedding creates new model."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/provider/models/embedding',
|
||||
@@ -362,13 +431,20 @@ class TestEmbeddingModelEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_provider_app.embedding_models_service.create_embedding_model
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ({'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'new-embedding-uuid'}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestRerankModelEndpoints:
|
||||
"""Tests for /api/v1/provider/models/rerank endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rerank_models_success(self, quart_test_client):
|
||||
async def test_get_rerank_models_success(self, quart_test_client, fake_provider_app):
|
||||
"""GET /api/v1/provider/models/rerank returns model list."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/provider/models/rerank', headers={'Authorization': 'Bearer test_token'}
|
||||
@@ -379,8 +455,15 @@ class TestRerankModelEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'models' in data['data']
|
||||
|
||||
called = fake_provider_app.rerank_models_service.get_rerank_models
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ()
|
||||
assert called.await_args.kwargs == {'include_secret': True}
|
||||
assert data['data']['models'] == [{'uuid': 'rerank-model', 'name': 'Rerank'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rerank_model_success(self, quart_test_client):
|
||||
async def test_create_rerank_model_success(self, quart_test_client, fake_provider_app):
|
||||
"""POST /api/v1/provider/models/rerank creates new model."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/provider/models/rerank',
|
||||
@@ -392,3 +475,10 @@ class TestRerankModelEndpoints:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
called = fake_provider_app.rerank_models_service.create_rerank_model
|
||||
assert called.await_count == 1
|
||||
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
|
||||
assert called.await_args.args[1:] == ({'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},)
|
||||
assert called.await_args.kwargs == {}
|
||||
assert data['data'] == {'uuid': 'new-rerank-uuid'}
|
||||
|
||||
@@ -164,16 +164,6 @@ class TestHealthEndpoint:
|
||||
data = await response.get_json()
|
||||
assert data == {'code': 0, 'msg': 'ok', 'resources': {}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_healthz_no_auth_required(self, quart_test_client):
|
||||
"""
|
||||
/healthz doesn't require authentication.
|
||||
|
||||
Tests that AuthType.NONE endpoints work without headers.
|
||||
"""
|
||||
response = await quart_test_client.get('/healthz')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
class TestSystemEndpoint:
|
||||
@@ -241,33 +231,42 @@ class TestInvalidPayload:
|
||||
"""Tests for error handling with invalid payloads."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_json_body(self, quart_test_client):
|
||||
"""
|
||||
POST endpoint without JSON body handles gracefully.
|
||||
"""
|
||||
# /api/v1/user/auth expects JSON with 'user' and 'password'
|
||||
async def test_missing_json_body(self, quart_test_client, fake_api_app):
|
||||
"""A missing login body is rejected before authentication."""
|
||||
response = await quart_test_client.post('/api/v1/user/auth')
|
||||
|
||||
# Should return error (500, 400, or 401) with stable JSON structure
|
||||
assert response.status_code in (400, 500, 401)
|
||||
data = await response.get_json()
|
||||
|
||||
# Verify error response has expected structure
|
||||
assert 'code' in data
|
||||
assert 'msg' in data
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
|
||||
fake_api_app.user_service.authenticate.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_structure(self, quart_test_client):
|
||||
"""
|
||||
POST with wrong JSON structure returns stable error.
|
||||
"""
|
||||
response = await quart_test_client.post('/api/v1/user/auth', json={'wrong_field': 'value'})
|
||||
@pytest.mark.parametrize(
|
||||
'payload',
|
||||
[
|
||||
{'wrong_field': 'value'},
|
||||
[],
|
||||
{'user': None, 'password': 'secret'},
|
||||
{'user': '[email protected]', 'password': 42},
|
||||
],
|
||||
)
|
||||
async def test_invalid_json_structure(self, quart_test_client, fake_api_app, payload):
|
||||
"""Malformed credential fields never reach the authentication service."""
|
||||
response = await quart_test_client.post('/api/v1/user/auth', json=payload)
|
||||
|
||||
# Should return error with stable JSON structure
|
||||
assert response.status_code in (400, 500, 401)
|
||||
data = await response.get_json()
|
||||
assert 'code' in data
|
||||
assert 'msg' in data
|
||||
assert response.status_code == 400
|
||||
assert await response.get_json() == {'code': -1, 'msg': 'Invalid request parameters'}
|
||||
fake_api_app.user_service.authenticate.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('password', ['', 'existing-password'])
|
||||
async def test_string_credentials_reach_authentication(self, quart_test_client, fake_api_app, password):
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/user/auth', json={'user': '[email protected]', 'password': password}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['data']['token'] == 'fake_token'
|
||||
fake_api_app.user_service.authenticate.assert_awaited_once_with('[email protected]', password, None)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||
|
||||
@@ -405,7 +405,7 @@ async def test_support_admin_persisted_expiry_and_revocation_are_enforced(suppor
|
||||
|
||||
|
||||
async def test_support_admin_websocket_preserves_actor_and_revalidates(support_admin_api):
|
||||
app, _client, _engine, private_key = support_admin_api
|
||||
app, _client, engine, private_key = support_admin_api
|
||||
launch = await _issue_support_token(app, private_key)
|
||||
captured_contexts = []
|
||||
|
||||
@@ -414,7 +414,6 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
|
||||
del data
|
||||
captured_contexts.append(connection.execution_context)
|
||||
await connection.send_queue.put({'type': 'handled'})
|
||||
connection.is_active = False
|
||||
|
||||
app.pipeline_service = SimpleNamespace(get_pipeline=AsyncMock(return_value=SimpleNamespace(uuid='pipeline-1')))
|
||||
app.platform_mgr = SimpleNamespace(
|
||||
@@ -439,8 +438,17 @@ async def test_support_admin_websocket_preserves_actor_and_revalidates(support_a
|
||||
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'hi'}]}))
|
||||
handled = json.loads(await websocket.receive())
|
||||
assert handled['type'] == 'handled'
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(
|
||||
sqlalchemy.update(SupportAdminTemporarySession)
|
||||
.where(SupportAdminTemporarySession.grant_jti_hash == launch['grant_jti_hash'])
|
||||
.values(revoked_at=datetime.datetime.now(datetime.UTC).replace(tzinfo=None))
|
||||
)
|
||||
await websocket.send(json.dumps({'type': 'message', 'message': [{'type': 'text', 'text': 'after revoke'}]}))
|
||||
rejected = json.loads(await websocket.receive())
|
||||
assert rejected == {'type': 'error', 'message': 'Unauthorized'}
|
||||
|
||||
assert captured_contexts
|
||||
assert len(captured_contexts) == 1
|
||||
principal = captured_contexts[0].trigger_principal
|
||||
assert principal is not None
|
||||
assert principal.principal_type == PrincipalType.SUPPORT_ADMIN
|
||||
|
||||
@@ -234,39 +234,6 @@ async def test_redirect_rejects_invalid_callback_shape(space_oauth_api):
|
||||
assert all(payload['code'] == 1 for payload in payloads)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_server_side_webui_origin_supports_split_dev_server(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.instance_config.data['api'] = {'webui_url': 'http://localhost:5173'}
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'http://localhost:5173/auth/space/callback'},
|
||||
headers={'Origin': 'https://irrelevant.example'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.instance_config.data['api'] = {
|
||||
'webui_url': '',
|
||||
'webhook_prefix': 'https://langbot.example/base/path',
|
||||
}
|
||||
|
||||
response = await client.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
query_string={'redirect_uri': 'https://langbot.example/auth/space/callback'},
|
||||
headers={'Host': 'attacker.example'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())['code'] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
@@ -423,6 +390,9 @@ async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oa
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = await response.get_json()
|
||||
assert payload['code'] == 0
|
||||
assert payload['data']['token'] == 'space-login-token'
|
||||
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -557,6 +527,7 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
||||
async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space_oauth_api):
|
||||
application, client = space_oauth_api
|
||||
application.user_service.consume_space_oauth_state.reset_mock()
|
||||
application.user_service.consume_space_oauth_state_details.reset_mock()
|
||||
application.space_service.exchange_oauth_code.reset_mock()
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
|
||||
@@ -578,6 +549,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
||||
expected_workspace_uuid=WORKSPACE_UUID,
|
||||
)
|
||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||
application.user_service.consume_space_oauth_state_details.assert_not_awaited()
|
||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -591,7 +563,24 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
|
||||
status='active',
|
||||
)
|
||||
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||
events = []
|
||||
|
||||
async def reconcile(_workspaces):
|
||||
events.append('reconcile')
|
||||
|
||||
async def get_account(_account_uuid):
|
||||
events.append('account')
|
||||
return projected_account
|
||||
|
||||
access = application.workspace_collaboration_service.resolve_account_workspace.return_value
|
||||
|
||||
async def resolve_access(*_args, **_kwargs):
|
||||
events.append('access')
|
||||
return access
|
||||
|
||||
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock(side_effect=reconcile))
|
||||
application.user_service.get_user_by_uuid.side_effect = get_account
|
||||
application.workspace_collaboration_service.resolve_account_workspace.side_effect = resolve_access
|
||||
|
||||
response = await client.post(
|
||||
'/api/v1/user/space/callback',
|
||||
@@ -605,3 +594,5 @@ async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(
|
||||
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||
|
||||
assert events == ['reconcile', 'account', 'access']
|
||||
|
||||
@@ -623,6 +623,12 @@ async def test_api_key_context_returns_bound_identity_without_workspace_permissi
|
||||
)
|
||||
assert expired_capabilities.status_code == 401
|
||||
|
||||
await application.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(apikey.ApiKey).where(apikey.ApiKey.uuid == created['uuid']).values(expires_at=None)
|
||||
)
|
||||
valid_again = await client.get('/api/v1/system/context', headers={'X-API-Key': created['key']})
|
||||
assert valid_again.status_code == 200
|
||||
|
||||
revoke_response = await client.delete(
|
||||
f'/api/v1/apikeys/{created["id"]}',
|
||||
headers=_auth(owner_token, workspace_uuid),
|
||||
|
||||
@@ -298,12 +298,13 @@ async def test_device_replacement_expiry_and_idempotent_cancel(auth):
|
||||
async def test_device_accepts_issuer_iso_expiry(auth):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
expires = datetime.fromtimestamp(time.time() + 600, timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
expected_expiry = int(time.time()) + 600
|
||||
expires = datetime.fromtimestamp(expected_expiry, timezone.utc).isoformat().replace('+00:00', 'Z')
|
||||
auth._post = AsyncMock(
|
||||
return_value=httpx.Response(200, json={'device_auth_id': 'device', 'user_code': 'CODE', 'expires_at': expires})
|
||||
)
|
||||
result = await auth.start(context(), 'p')
|
||||
assert time.time() < result['expires_at'] < time.time() + 900
|
||||
assert result['expires_at'] == expected_expiry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user