chore: import upstream snapshot with attribution
Test Migrations / Migrations (SQLite) (push) Has been cancelled
Build Dev Image / build-dev-image (push) Has been cancelled
Check i18n Keys / Check i18n Key Consistency (push) Has been cancelled
Lint / Ruff Lint & Format (push) Has been cancelled
Lint / Frontend Lint (push) Has been cancelled
Test Migrations / Migrations (PostgreSQL) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:44:22 +08:00
commit 534bb94eea
1288 changed files with 266913 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
"""
Integration tests package.
These tests validate real system behavior with actual database/network resources.
Run with: uv run pytest tests/integration/ -m "not slow" -q
"""
+5
View File
@@ -0,0 +1,5 @@
"""
API integration tests package.
Tests for HTTP API endpoints using Quart test client.
"""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import pytest
def dedupe_preregistered_groups() -> None:
"""Keep API integration route registration isolated across test modules."""
from langbot.pkg.api.http.controller import group
seen: set[tuple[str, str]] = set()
unique_groups = []
for group_cls in group.preregistered_groups:
key = (group_cls.name, group_cls.path)
if key in seen:
continue
seen.add(key)
unique_groups.append(group_cls)
group.preregistered_groups[:] = unique_groups
@pytest.fixture(scope='module')
def http_controller_cls(mock_circular_import_chain):
"""Import HTTPController under each module's circular-import isolation."""
from langbot.pkg.api.http.controller.main import HTTPController
dedupe_preregistered_groups()
return HTTPController
+252
View File
@@ -0,0 +1,252 @@
"""
API integration tests for bot endpoints.
Tests real HTTP API behavior for bot management.
Run: uv run pytest tests/integration/api/test_bots.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.platform',
'langbot.pkg.api.http.controller.groups.platform.bots',
'langbot.pkg.api.http.controller.groups.platform.adapters',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.platform.bots as _bots # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_bot_app():
"""Create FakeApp with bot services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Bot service
app.bot_service = Mock()
app.bot_service.get_bots = AsyncMock(
return_value=[
{
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
}
]
)
app.bot_service.get_runtime_bot_info = AsyncMock(
return_value={
'uuid': 'test-bot-uuid',
'name': 'Test Bot',
'platform': 'telegram',
'pipeline_uuid': 'test-pipeline-uuid',
'webhook_url': 'https://example.com/webhook/test-bot-uuid',
}
)
app.bot_service.create_bot = AsyncMock(return_value={'uuid': '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))
app.bot_service.send_message = AsyncMock()
# Platform manager
app.platform_mgr = Mock()
return app
@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)."""
controller = http_controller_cls(fake_bot_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotEndpoints:
"""Tests for /api/v1/platform/bots endpoints."""
@pytest.mark.asyncio
async def test_get_bots_success(self, quart_test_client):
"""GET /api/v1/platform/bots returns bot list."""
response = await quart_test_client.get('/api/v1/platform/bots', headers={'Authorization': 'Bearer test_token'})
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bots' in data['data']
@pytest.mark.asyncio
async def test_create_bot_success(self, quart_test_client):
"""POST /api/v1/platform/bots creates new bot."""
response = await quart_test_client.post(
'/api/v1/platform/bots',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Bot', 'platform': 'telegram', 'pipeline_uuid': 'test-pipeline'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_get_single_bot_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'bot' in data['data']
@pytest.mark.asyncio
async def test_update_bot_success(self, quart_test_client):
"""PUT /api/v1/platform/bots/{uuid} updates bot."""
response = await quart_test_client.put(
'/api/v1/platform/bots/test-bot-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Bot'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_bot_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@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):
"""POST /api/v1/platform/bots/{uuid}/logs returns logs."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/logs',
headers={'Authorization': 'Bearer test_token'},
json={'from_index': -1, 'max_count': 10},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'logs' in data['data']
assert 'total_count' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestBotSendMessageEndpoint:
"""Tests for bot send message endpoint."""
@pytest.mark.asyncio
async def test_send_message_success(self, quart_test_client):
"""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',
headers={'Authorization': 'Bearer test_api_key'},
json={
'target_type': 'person',
'target_id': 'user123',
'message_chain': [{'type': 'text', 'text': 'Hello'}],
},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['sent'] is True
@pytest.mark.asyncio
async def test_send_message_missing_target_type(self, quart_test_client):
"""POST send_message without target_type returns 400."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
headers={'Authorization': 'Bearer test_api_key'},
json={'target_id': 'user123', 'message_chain': [{'type': 'text', 'text': 'Hello'}]},
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
@pytest.mark.asyncio
async def test_send_message_invalid_target_type(self, quart_test_client):
"""POST send_message with invalid target_type returns 400."""
response = await quart_test_client.post(
'/api/v1/platform/bots/test-bot-uuid/send_message',
headers={'Authorization': 'Bearer test_api_key'},
json={
'target_type': 'invalid',
'target_id': 'user123',
'message_chain': [{'type': 'text', 'text': 'Hello'}],
},
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
+291
View File
@@ -0,0 +1,291 @@
"""
API integration tests for embed widget endpoints.
Tests real HTTP API behavior for embed widget functionality.
Run: uv run pytest tests/integration/api/test_embed.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.embed',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.pipelines.embed as _embed # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_embed_app():
"""Create FakeApp with embed widget services (module scope)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Create mock web_page_bot with valid UUID format
mock_bot_entity = Mock()
mock_bot_entity.uuid = 'a1b2c3d4-5678-90ab-cdef-123456789abc'
mock_bot_entity.adapter = 'web_page_bot'
mock_bot_entity.enable = True
mock_bot_entity.use_pipeline_uuid = 'test-pipeline-uuid'
mock_bot_entity.name = 'Test Web Bot'
mock_bot_entity.adapter_config = {
'turnstile_secret_key': '',
'turnstile_site_key': '',
'language': 'en_US',
'bubble_icon': 'logo',
}
mock_runtime_bot = Mock()
mock_runtime_bot.bot_entity = mock_bot_entity
# Platform manager with bots
app.platform_mgr = Mock()
app.platform_mgr.bots = [mock_runtime_bot]
# WebSocket proxy bot with adapter
mock_websocket_adapter = Mock()
mock_websocket_adapter.get_websocket_messages = Mock(return_value=[{'id': 'msg-1', 'content': 'test message'}])
mock_websocket_adapter.reset_session = Mock()
mock_websocket_adapter.handle_websocket_message = AsyncMock()
mock_ws_proxy_bot = Mock()
mock_ws_proxy_bot.adapter = mock_websocket_adapter
app.platform_mgr.websocket_proxy_bot = mock_ws_proxy_bot
# Monitoring service for feedback
app.monitoring_service = Mock()
app.monitoring_service.record_feedback = AsyncMock()
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_embed_app, http_controller_cls):
"""Create Quart test client (module scope)."""
controller = http_controller_cls(fake_embed_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedWidgetEndpoint:
"""Tests for widget.js endpoint."""
@pytest.mark.asyncio
async def test_get_widget_js_success(self, quart_test_client):
"""GET /api/v1/embed/{bot_uuid}/widget.js returns JS."""
response = await quart_test_client.get('/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/widget.js')
assert response.status_code == 200
assert 'javascript' in response.content_type
@pytest.mark.asyncio
async def test_get_widget_js_invalid_uuid(self, quart_test_client):
"""GET widget.js with invalid UUID returns 400."""
response = await quart_test_client.get('/api/v1/embed/invalid-uuid/widget.js')
assert response.status_code == 400
@pytest.mark.asyncio
async def test_get_widget_js_bot_not_found(self, quart_test_client):
"""GET widget.js for non-existent bot returns 404."""
response = await quart_test_client.get('/api/v1/embed/00000000-0000-0000-0000-000000000000/widget.js')
assert response.status_code == 404
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedLogoEndpoint:
"""Tests for logo endpoint."""
@pytest.mark.asyncio
async def test_get_logo_success(self, quart_test_client):
"""GET /api/v1/embed/logo returns image."""
response = await quart_test_client.get('/api/v1/embed/logo')
assert response.status_code == 200
assert 'image/webp' in response.content_type
@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):
"""POST turnstile verify without secret returns dummy token."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'token' in data['data']
@pytest.mark.asyncio
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
"""POST turnstile verify with invalid UUID returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/invalid-uuid/turnstile/verify', json={'token': 'test-token'}
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_turnstile_verify_missing_token(self, quart_test_client):
"""POST turnstile verify without token returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={}
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedMessagesEndpoint:
"""Tests for messages endpoint."""
@pytest.mark.asyncio
async def test_get_messages_person_success(self, quart_test_client):
"""GET messages/person returns messages."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
@pytest.mark.asyncio
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',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedResetEndpoint:
"""Tests for session reset endpoint."""
@pytest.mark.asyncio
async def test_reset_session_person_success(self, quart_test_client):
"""POST reset/person resets session."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_reset_session_invalid_uuid(self, quart_test_client):
"""POST reset with invalid UUID returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/invalid-uuid/reset/person', headers={'Authorization': 'Bearer 1234567890.dummy'}
)
assert response.status_code == 400
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedFeedbackEndpoint:
"""Tests for feedback submission endpoint."""
@pytest.mark.asyncio
async def test_submit_feedback_like(self, quart_test_client):
"""POST feedback with type=1 (like) succeeds."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 1},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'feedback_id' in data['data']
@pytest.mark.asyncio
async def test_submit_feedback_dislike(self, quart_test_client):
"""POST feedback with type=2 (dislike) succeeds."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 2},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_submit_feedback_invalid_type(self, quart_test_client):
"""POST feedback with invalid type returns 400."""
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
json={'message_id': 'msg-123', 'feedback_type': 99},
)
assert response.status_code == 400
+257
View File
@@ -0,0 +1,257 @@
"""
API integration tests for knowledge base endpoints.
Tests real HTTP API behavior for knowledge base management.
Run: uv run pytest tests/integration/api/test_knowledge.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.knowledge',
'langbot.pkg.api.http.controller.groups.knowledge.base',
'langbot.pkg.api.http.controller.groups.knowledge.engines',
'langbot.pkg.api.http.controller.groups.knowledge.parsers',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.knowledge.base as _knowledge # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_knowledge_app():
"""Create FakeApp with knowledge services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Knowledge service
app.knowledge_service = Mock()
app.knowledge_service.get_knowledge_bases = AsyncMock(
return_value=[
{
'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',
}
]
)
app.knowledge_service.get_knowledge_base = AsyncMock(
return_value={
'uuid': 'test-kb-uuid',
'name': 'Test Knowledge Base',
'description': 'Test KB description',
'engine_plugin_id': 'test/engine',
}
)
app.knowledge_service.create_knowledge_base = AsyncMock(return_value={'uuid': '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.delete_file = AsyncMock()
app.knowledge_service.retrieve_knowledge_base = AsyncMock(return_value=[{'content': 'test result', 'score': 0.95}])
# RAG manager
app.rag_mgr = Mock()
return app
@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)."""
controller = http_controller_cls(fake_knowledge_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestKnowledgeBaseEndpoints:
"""Tests for /api/v1/knowledge/bases endpoints."""
@pytest.mark.asyncio
async def test_get_knowledge_bases_success(self, quart_test_client):
"""GET /api/v1/knowledge/bases returns knowledge base list."""
response = await quart_test_client.get(
'/api/v1/knowledge/bases', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert 'bases' in data['data']
@pytest.mark.asyncio
async def test_create_knowledge_base_success(self, quart_test_client):
"""POST /api/v1/knowledge/bases creates new knowledge base."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New KB', 'engine_plugin_id': 'test/engine'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_get_single_knowledge_base_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'base' in data['data']
@pytest.mark.asyncio
async def test_update_knowledge_base_success(self, quart_test_client):
"""PUT /api/v1/knowledge/bases/{uuid} updates knowledge base."""
response = await quart_test_client.put(
'/api/v1/knowledge/bases/test-kb-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated KB'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_knowledge_base_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@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):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'files' in data['data']
@pytest.mark.asyncio
async def test_add_file_to_knowledge_base(self, quart_test_client):
"""POST /api/v1/knowledge/bases/{uuid}/files adds file."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/files',
headers={'Authorization': 'Bearer test_token'},
json={'file_id': 'test-file-id', 'parser_plugin_id': 'test/parser'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'task_id' in data['data']
@pytest.mark.asyncio
async def test_delete_file_from_knowledge_base(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@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):
"""POST /api/v1/knowledge/bases/{uuid}/retrieve."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve',
headers={'Authorization': 'Bearer test_token'},
json={'query': 'test query', 'retrieval_settings': {'top_k': 5}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'results' in data['data']
@pytest.mark.asyncio
async def test_retrieve_without_query_returns_error(self, quart_test_client):
"""POST retrieve without query returns 400."""
response = await quart_test_client.post(
'/api/v1/knowledge/bases/test-kb-uuid/retrieve', headers={'Authorization': 'Bearer test_token'}, json={}
)
assert response.status_code == 400
data = await response.get_json()
assert data['code'] == -1
+313
View File
@@ -0,0 +1,313 @@
"""
API integration tests for monitoring endpoints.
Tests real HTTP API behavior for monitoring data retrieval.
Run: uv run pytest tests/integration/api/test_monitoring.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.monitoring',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.monitoring as _monitoring # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_monitoring_app():
"""Create FakeApp with monitoring services (module scope)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services - USER_TOKEN auth requires jwt verification AND get_user_by_email
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
# Monitoring service
app.monitoring_service = Mock()
app.monitoring_service.get_overview_metrics = AsyncMock(
return_value={
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
)
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5))
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
app.monitoring_service.get_session_analysis = AsyncMock(
return_value={
'found': True,
'session_id': 'sess-1',
}
)
app.monitoring_service.get_message_details = AsyncMock(
return_value={
'found': True,
'message_id': 'msg-1',
}
)
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_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')
return app
@pytest.fixture(scope='module')
async def quart_test_client(fake_monitoring_app, http_controller_cls):
"""Create Quart test client (module scope)."""
controller = http_controller_cls(fake_monitoring_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringOverviewEndpoint:
"""Tests for /api/v1/monitoring/overview endpoint."""
@pytest.mark.asyncio
async def test_get_overview_success(self, quart_test_client):
"""GET /api/v1/monitoring/overview returns metrics."""
response = await quart_test_client.get(
'/api/v1/monitoring/overview', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringMessagesEndpoint:
"""Tests for /api/v1/monitoring/messages endpoint."""
@pytest.mark.asyncio
async def test_get_messages_success(self, quart_test_client):
"""GET /api/v1/monitoring/messages returns message list."""
response = await quart_test_client.get(
'/api/v1/monitoring/messages', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'messages' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringLLMCallsEndpoint:
"""Tests for /api/v1/monitoring/llm-calls endpoint."""
@pytest.mark.asyncio
async def test_get_llm_calls_success(self, quart_test_client):
"""GET /api/v1/monitoring/llm-calls."""
response = await quart_test_client.get(
'/api/v1/monitoring/llm-calls', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringEmbeddingCallsEndpoint:
"""Tests for /api/v1/monitoring/embedding-calls endpoint."""
@pytest.mark.asyncio
async def test_get_embedding_calls_success(self, quart_test_client):
"""GET /api/v1/monitoring/embedding-calls."""
response = await quart_test_client.get(
'/api/v1/monitoring/embedding-calls', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringSessionsEndpoint:
"""Tests for /api/v1/monitoring/sessions endpoint."""
@pytest.mark.asyncio
async def test_get_sessions_success(self, quart_test_client):
"""GET /api/v1/monitoring/sessions."""
response = await quart_test_client.get(
'/api/v1/monitoring/sessions', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringErrorsEndpoint:
"""Tests for /api/v1/monitoring/errors endpoint."""
@pytest.mark.asyncio
async def test_get_errors_success(self, quart_test_client):
"""GET /api/v1/monitoring/errors."""
response = await quart_test_client.get(
'/api/v1/monitoring/errors', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringAllDataEndpoint:
"""Tests for /api/v1/monitoring/data endpoint."""
@pytest.mark.asyncio
async def test_get_all_data_success(self, quart_test_client):
"""GET /api/v1/monitoring/data returns all data."""
response = await quart_test_client.get(
'/api/v1/monitoring/data', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert 'overview' in data['data']
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints."""
@pytest.mark.asyncio
async def test_get_session_analysis(self, quart_test_client):
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
response = await quart_test_client.get(
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_message_details(self, quart_test_client):
"""GET /api/v1/monitoring/messages/{id}/details."""
response = await quart_test_client.get(
'/api/v1/monitoring/messages/msg-1/details', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints:
"""Tests for feedback endpoints."""
@pytest.mark.asyncio
async def test_get_feedback_stats(self, quart_test_client):
"""GET /api/v1/monitoring/feedback/stats."""
response = await quart_test_client.get(
'/api/v1/monitoring/feedback/stats', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_feedback_list(self, quart_test_client):
"""GET /api/v1/monitoring/feedback."""
response = await quart_test_client.get(
'/api/v1/monitoring/feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@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):
"""GET export?type=messages returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
@pytest.mark.asyncio
async def test_export_llm_calls(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_sessions(self, quart_test_client):
"""GET export?type=sessions returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_export_feedback(self, quart_test_client):
"""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
+280
View File
@@ -0,0 +1,280 @@
"""
API integration tests for pipeline endpoints.
Tests real HTTP API behavior using Quart test client with mocked services.
Extends test_smoke.py coverage for pipeline-related endpoints.
Run: uv run pytest tests/integration/api/test_pipelines.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.pipelines',
'langbot.pkg.api.http.controller.groups.pipelines.embed',
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
# Import groups after mocking to populate preregistered_groups
import langbot.pkg.api.http.controller.groups.pipelines.pipelines as _pipelines # noqa: E402, F401
yield
# ============== FAKE APPLICATION WITH PIPELINE SERVICES ==============
@pytest.fixture(scope='module')
def fake_pipeline_app():
"""Create FakeApp with pipeline-specific services (module scope for reuse)."""
app = FakeApp()
# Pipeline config
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Pipeline service
app.pipeline_service = Mock()
app.pipeline_service.get_pipeline_metadata = AsyncMock(
return_value=[
{'name': 'trigger', 'stages': []},
{'name': 'ai', 'stages': []},
]
)
app.pipeline_service.get_pipelines = AsyncMock(
return_value=[
{
'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,
}
]
)
app.pipeline_service.get_pipeline = AsyncMock(
return_value={
'uuid': 'test-pipeline-uuid',
'name': 'Test Pipeline',
'config': {},
}
)
app.pipeline_service.create_pipeline = AsyncMock(return_value={'uuid': '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'})
# Bot service
app.bot_service = Mock()
app.bot_service.get_bots = AsyncMock(return_value=[])
app.bot_service.create_bot = AsyncMock(return_value={'uuid': 'new-bot-uuid'})
# MCP service (for extensions endpoint)
app.mcp_service = Mock()
app.mcp_service.get_mcp_servers = AsyncMock(return_value=[])
# Skill service (for extensions endpoint)
app.skill_service = Mock()
app.skill_service.list_skills = AsyncMock(return_value=[])
# Plugin connector (for extensions endpoint)
app.plugin_connector.list_plugins = AsyncMock(return_value=[])
return app
@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)."""
controller = http_controller_cls(fake_pipeline_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
# ============== PIPELINE ENDPOINT TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineMetadataEndpoint:
"""Tests for /api/v1/pipelines/_/metadata endpoint."""
@pytest.mark.asyncio
async def test_get_pipeline_metadata_success(self, quart_test_client):
"""GET /api/v1/pipelines/_/metadata returns metadata list."""
response = await quart_test_client.get(
'/api/v1/pipelines/_/metadata', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
assert isinstance(data['data'], dict)
@pytest.mark.asyncio
async def test_get_pipeline_metadata_requires_auth(self, quart_test_client):
"""Pipeline metadata endpoint requires authentication."""
response = await quart_test_client.get('/api/v1/pipelines/_/metadata')
assert response.status_code == 401
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelinesListEndpoint:
"""Tests for /api/v1/pipelines endpoint."""
@pytest.mark.asyncio
async def test_get_pipelines_success(self, quart_test_client):
"""GET /api/v1/pipelines returns pipeline list."""
response = await quart_test_client.get('/api/v1/pipelines', headers={'Authorization': 'Bearer test_token'})
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_get_pipelines_with_sort_param(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@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):
"""GET /api/v1/pipelines/{uuid} returns pipeline."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_create_pipeline_success(self, quart_test_client):
"""POST /api/v1/pipelines creates new pipeline."""
response = await quart_test_client.post(
'/api/v1/pipelines',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Pipeline', 'config': {}},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_update_pipeline_success(self, quart_test_client):
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
response = await quart_test_client.put(
'/api/v1/pipelines/test-pipeline-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Pipeline'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_pipeline_success(self, quart_test_client):
"""DELETE /api/v1/pipelines/{uuid} deletes pipeline."""
response = await quart_test_client.delete(
'/api/v1/pipelines/test-pipeline-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_copy_pipeline_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@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):
"""GET /api/v1/pipelines/{uuid}/extensions."""
response = await quart_test_client.get(
'/api/v1/pipelines/test-pipeline-uuid/extensions', headers={'Authorization': 'Bearer test_token'}
)
# Should return 200 if pipeline found
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
+337
View File
@@ -0,0 +1,337 @@
"""
API integration tests for provider/model endpoints.
Tests real HTTP API behavior for provider and model management.
Run: uv run pytest tests/integration/api/test_providers.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""Break circular import chain for API controller."""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.provider',
'langbot.pkg.api.http.controller.groups.provider.providers',
'langbot.pkg.api.http.controller.groups.provider.models',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
import langbot.pkg.api.http.controller.groups.provider.providers as _providers # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.provider.models as _models # noqa: E402, F401
yield
@pytest.fixture(scope='module')
def fake_provider_app():
"""Create FakeApp with provider/model services (module scope for reuse)."""
app = FakeApp()
app.instance_config.data.update(
{
'api': {'port': 5300},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# Auth services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=True)
app.user_service.verify_jwt_token = AsyncMock(return_value='test@example.com')
app.user_service.get_user_by_email = AsyncMock(return_value=Mock(email='test@example.com'))
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
# Provider service
app.provider_service = Mock()
app.provider_service.get_providers = AsyncMock(
return_value=[{'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'}]
)
app.provider_service.get_provider = AsyncMock(
return_value={'uuid': 'test-provider-uuid', 'name': 'OpenAI', 'requester': 'chatcmpl'}
)
app.provider_service.create_provider = AsyncMock(return_value='new-provider-uuid')
app.provider_service.update_provider = AsyncMock(return_value={})
app.provider_service.delete_provider = AsyncMock()
app.provider_service.get_provider_model_counts = AsyncMock(
return_value={'llm_count': 2, 'embedding_count': 1, 'rerank_count': 0}
)
# LLM model service
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.update_llm_model = AsyncMock(return_value={})
app.llm_model_service.delete_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'})
# 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'})
# Model manager
app.model_mgr = Mock()
app.model_mgr.load_provider = AsyncMock()
app.model_mgr.unload_provider = AsyncMock()
return app
@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)."""
controller = http_controller_cls(fake_provider_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProviderEndpoints:
"""Tests for /api/v1/provider endpoints."""
@pytest.mark.asyncio
async def test_get_providers_success(self, quart_test_client):
"""GET /api/v1/provider/providers returns provider list with complete structure."""
response = await quart_test_client.get(
'/api/v1/provider/providers', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
# Verify response structure completeness
providers = data['data']['providers']
assert isinstance(providers, list)
assert len(providers) == 1
# Verify required fields in provider object
provider = providers[0]
assert 'uuid' in provider
assert 'name' in provider
assert 'requester' in provider
assert provider['uuid'] == 'test-provider-uuid'
assert provider['name'] == 'OpenAI'
@pytest.mark.asyncio
async def test_get_single_provider_success(self, quart_test_client):
"""GET /api/v1/provider/providers/{uuid} returns complete provider structure."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Verify response structure
provider = data['data']['provider']
assert 'uuid' in provider
assert 'name' in provider
assert 'requester' in provider
assert provider['uuid'] == 'test-provider-uuid'
@pytest.mark.asyncio
async def test_create_provider_success(self, quart_test_client):
"""POST /api/v1/provider/providers creates new provider with uuid returned."""
response = await quart_test_client.post(
'/api/v1/provider/providers',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Provider', 'requester': 'chatcmpl'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Verify uuid is present and matches expected
assert 'data' in data
assert 'uuid' in data['data']
assert data['data']['uuid'] == 'new-provider-uuid'
@pytest.mark.asyncio
async def test_update_provider_success(self, quart_test_client):
"""PUT /api/v1/provider/providers/{uuid} updates provider."""
response = await quart_test_client.put(
'/api/v1/provider/providers/test-provider-uuid',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'Updated Provider'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_delete_provider_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_get_provider_includes_model_counts(self, quart_test_client):
"""GET provider response includes model counts."""
response = await quart_test_client.get(
'/api/v1/provider/providers/test-provider-uuid', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
# Model counts are embedded in provider response
provider_data = data['data']['provider']
assert 'llm_count' in provider_data
assert 'embedding_count' in provider_data
assert 'rerank_count' in provider_data
@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):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'data' in data
@pytest.mark.asyncio
async def test_get_single_llm_model_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
@pytest.mark.asyncio
async def test_create_llm_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/llm creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/llm',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@pytest.mark.asyncio
async def test_delete_llm_model_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbeddingModelEndpoints:
"""Tests for /api/v1/provider/models/embedding endpoints."""
@pytest.mark.asyncio
async def test_get_embedding_models_success(self, quart_test_client):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'models' in data['data']
@pytest.mark.asyncio
async def test_create_embedding_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/embedding creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/embedding',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Embedding Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
@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):
"""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'}
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'models' in data['data']
@pytest.mark.asyncio
async def test_create_rerank_model_success(self, quart_test_client):
"""POST /api/v1/provider/models/rerank creates new model."""
response = await quart_test_client.post(
'/api/v1/provider/models/rerank',
headers={'Authorization': 'Bearer test_token'},
json={'name': 'New Rerank Model', 'provider_uuid': 'test-provider-uuid'},
)
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert 'uuid' in data['data']
+347
View File
@@ -0,0 +1,347 @@
"""
API smoke integration tests.
Tests real HTTP API behavior using Quart test client.
Validates controller/service/routing wiring without real provider/platform.
Run: uv run pytest tests/integration/api/test_smoke.py -q
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
from tests.factories import FakeApp
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain for API controller using isolated_sys_modules.
Chain: http_controller → groups/plugins → core.app → pipeline entities
We need to mock core.app to prevent the circular chain when importing HTTPController.
But we must allow groups to be imported to populate preregistered_groups.
"""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
# Mock core.app with minimal Application that groups can reference
class FakeMinimalApplication:
pass
mock_app = MagicMock()
mock_app.Application = FakeMinimalApplication
# Mock core.entities with proper Enum
mock_entities = MagicMock()
mock_entities.LifecycleControlScope = MockLifecycleControlScope
# Modules to clear (force re-import after mocking)
clear = [
'langbot.pkg.api.http.controller.group',
'langbot.pkg.api.http.controller.groups',
'langbot.pkg.api.http.controller.groups.system',
'langbot.pkg.api.http.controller.groups.user',
'langbot.pkg.api.http.controller.main',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.app': mock_app,
'langbot.pkg.core.entities': mock_entities,
},
clear=clear,
):
# Import groups after mocking core.app/core.entities
import langbot.pkg.api.http.controller.group as _group_module # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.system as _system_group # noqa: E402, F401
import langbot.pkg.api.http.controller.groups.user as _user_group # noqa: E402, F401
yield
# ============== FAKE APPLICATION FOR API TESTS ==============
@pytest.fixture
def fake_api_app():
"""
Create minimal FakeApp for API smoke tests with all required services.
Uses tests.factories.FakeApp as base and adds API-specific services.
"""
app = FakeApp()
# API-specific config
app.instance_config.data.update(
{
'api': {'port': 5300},
'plugin': {'enable_marketplace': True},
'space': {'url': 'https://space.langbot.app'},
'system': {'allow_modify_login_info': True, 'limitation': {}},
}
)
# API-specific services
app.user_service = Mock()
app.user_service.is_initialized = AsyncMock(return_value=False)
app.user_service.authenticate = AsyncMock(return_value='fake_token')
app.user_service.create_user = AsyncMock()
app.user_service.verify_jwt_token = AsyncMock(side_effect=ValueError('Invalid token'))
app.user_service.get_user_by_email = AsyncMock(return_value=Mock())
app.user_service.generate_jwt_token = AsyncMock(return_value='fake_token')
app.apikey_service = Mock()
app.apikey_service.verify_api_key = AsyncMock(return_value=True)
app.maintenance_service = Mock()
app.maintenance_service.get_storage_analysis = AsyncMock(return_value={})
app.plugin_connector.is_enable_plugin = False
app.plugin_connector.ping_plugin_runtime = AsyncMock()
app.task_mgr.get_tasks_dict = Mock(return_value={'tasks': []})
app.task_mgr.get_task_by_id = Mock(return_value=None)
# Required by controller groups
app.model_mgr = Mock()
app.platform_mgr = Mock()
app.pipeline_pool = Mock()
app.pipeline_mgr = Mock()
return app
# ============== QUART TEST CLIENT FIXTURE ==============
@pytest.fixture
async def quart_test_client(fake_api_app, http_controller_cls):
"""
Create Quart test client with real HTTPController and route registration.
Requires mock_circular_import_chain fixture to run first (usefixtures).
"""
controller = http_controller_cls(fake_api_app)
await controller.initialize()
client = controller.quart_app.test_client()
yield client
# ============== API SMOKE TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestHealthEndpoint:
"""Tests for /healthz endpoint - simplest smoke test."""
@pytest.mark.asyncio
async def test_healthz_returns_ok(self, quart_test_client):
"""
/healthz endpoint returns {'code': 0, 'msg': 'ok'}.
This tests:
- HTTPController instantiation
- Quart app creation
- Route registration
- Basic response handling
"""
response = await quart_test_client.get('/healthz')
assert response.status_code == 200
data = await response.get_json()
assert data == {'code': 0, 'msg': 'ok'}
@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:
"""Tests for /api/v1/system endpoints."""
@pytest.mark.asyncio
async def test_system_info_no_auth(self, quart_test_client):
"""
/api/v1/system/info returns system information without auth.
AuthType.NONE endpoint.
"""
response = await quart_test_client.get('/api/v1/system/info')
assert response.status_code == 200
data = await response.get_json()
# Verify response structure
assert data['code'] == 0
assert data['msg'] == 'ok'
assert 'data' in data
# Verify expected fields
system_data = data['data']
assert 'version' in system_data
assert 'debug' in system_data
assert 'edition' in system_data
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProtectedEndpoints:
"""Tests for authentication/authorization behavior."""
@pytest.mark.asyncio
async def test_protected_endpoint_rejects_no_token(self, quart_test_client):
"""
Protected endpoint (USER_TOKEN) returns 401 without auth.
Tests that AuthType.USER_TOKEN properly rejects unauthorized requests.
"""
# /api/v1/user/check-token requires USER_TOKEN
response = await quart_test_client.get('/api/v1/user/check-token')
assert response.status_code == 401
data = await response.get_json()
# Verify error response structure
assert data['code'] == -1
assert 'msg' in data
@pytest.mark.asyncio
async def test_protected_endpoint_with_invalid_token(self, quart_test_client):
"""
Protected endpoint returns 401 with invalid token.
"""
response = await quart_test_client.get(
'/api/v1/user/check-token', headers={'Authorization': 'Bearer invalid_token'}
)
assert response.status_code == 401
@pytest.mark.usefixtures('mock_circular_import_chain')
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'
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
@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'})
# 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
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestUserInitEndpoint:
"""Tests for /api/v1/user/init endpoint."""
@pytest.mark.asyncio
async def test_user_init_get_returns_not_initialized(self, quart_test_client):
"""
GET /api/v1/user/init returns initialized status.
Uses fake user_service.is_initialized() = False.
"""
response = await quart_test_client.get('/api/v1/user/init')
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['msg'] == 'ok'
assert data['data']['initialized'] is False
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRealImports:
"""Tests that verify real production code is imported."""
def test_http_controller_real_import(self):
"""
Verify HTTPController is real production class, not mock.
"""
from langbot.pkg.api.http.controller.main import HTTPController
assert HTTPController.__name__ == 'HTTPController'
assert hasattr(HTTPController, 'initialize')
assert hasattr(HTTPController, 'register_routes')
def test_group_real_import(self):
"""
Verify RouterGroup and AuthType are real production classes.
"""
from langbot.pkg.api.http.controller.group import RouterGroup, AuthType, preregistered_groups
assert RouterGroup.__name__ == 'RouterGroup'
assert hasattr(AuthType, 'NONE')
assert hasattr(AuthType, 'USER_TOKEN')
assert isinstance(preregistered_groups, list)
def test_system_group_registered(self):
"""
Verify SystemRouterGroup is registered in preregistered_groups.
"""
from langbot.pkg.api.http.controller.group import preregistered_groups
# Find system group
system_group = None
for g in preregistered_groups:
if g.name == 'system':
system_group = g
break
assert system_group is not None
assert system_group.path == '/api/v1/system'
def test_user_group_registered(self):
"""
Verify UserRouterGroup is registered in preregistered_groups.
"""
from langbot.pkg.api.http.controller.group import preregistered_groups
# Find user group
user_group = None
for g in preregistered_groups:
if g.name == 'user':
user_group = g
break
assert user_group is not None
assert user_group.path == '/api/v1/user'
@@ -0,0 +1,5 @@
"""
Persistence integration tests package.
Tests for database migrations and storage behavior.
"""
@@ -0,0 +1,267 @@
"""
SQLite migration integration tests.
Tests real Alembic migration behavior using temporary SQLite databases.
Validates the migration workflow from .github/workflows/test-migrations.yml.
Run: uv run pytest tests/integration/persistence/test_migrations.py -q
"""
from __future__ import annotations
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
get_alembic_current,
_ALEMBIC_DIR,
)
from alembic.config import Config
from alembic.script import ScriptDirectory
def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory.
Avoids hardcoding a revision number in assertions so adding a new
migration doesn't require editing the migration tests.
"""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
return ScriptDirectory.from_config(cfg).get_current_head()
pytestmark = pytest.mark.integration
@pytest.fixture
def sqlite_db_url(tmp_path):
"""Create SQLite URL with temporary database file."""
db_file = tmp_path / 'test_migrations.db'
return f'sqlite+aiosqlite:///{db_file}'
@pytest.fixture
async def sqlite_engine(sqlite_db_url):
"""Create async SQLite engine."""
engine = create_async_engine(sqlite_db_url)
yield engine
await engine.dispose()
class TestSQLiteMigrationBaseline:
"""Tests for baseline stamp workflow."""
@pytest.mark.asyncio
async def test_baseline_stamp_sets_revision(self, sqlite_engine):
"""
Stamp baseline on existing tables sets correct revision.
Workflow:
1. Create tables via Base.metadata.create_all
2. Stamp with '0001_baseline'
3. Verify current revision is '0001_baseline'
"""
# Create all tables (simulates existing DB created by ORM)
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')
# Verify revision
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}"
@pytest.mark.asyncio
async def test_baseline_stamp_on_empty_db(self, sqlite_engine):
"""
Stamp on empty database (no tables) still sets revision.
This is an edge case - stamping without tables.
"""
# Don't create tables - stamp directly
await run_alembic_stamp(sqlite_engine, '0001_baseline')
rev = await get_alembic_current(sqlite_engine)
assert rev == '0001_baseline'
class TestSQLiteMigrationUpgrade:
"""Tests for upgrade to head workflow."""
@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}'
class TestSQLiteMigrationFreshDatabase:
"""Tests for fresh database workflow."""
@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}'
)
class TestSQLiteMigrationGetCurrent:
"""Tests for get_alembic_current behavior."""
@pytest.mark.asyncio
async def test_get_current_on_unstamped_db_returns_none(self, sqlite_engine):
"""
get_alembic_current returns None for unstamped database.
"""
# Create tables but don't stamp
async with sqlite_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 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'
@@ -0,0 +1,225 @@
"""
PostgreSQL migration integration tests.
Tests real Alembic migration behavior using PostgreSQL database.
Marked as slow - requires external PostgreSQL service.
Run locally (requires PostgreSQL):
TEST_POSTGRES_URL=postgresql+asyncpg://user:pass@localhost:5432/test_db \
uv run pytest tests/integration/persistence/test_migrations_postgres.py -q
CI runs automatically with PostgreSQL service container.
"""
from __future__ import annotations
import os
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import (
run_alembic_upgrade,
run_alembic_stamp,
get_alembic_current,
_ALEMBIC_DIR,
)
from alembic.config import Config
from alembic.script import ScriptDirectory
def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory.
Avoids hardcoding a revision number in assertions so adding a new
migration doesn't require editing the migration tests.
"""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
return ScriptDirectory.from_config(cfg).get_current_head()
pytestmark = [pytest.mark.integration, pytest.mark.slow]
@pytest.fixture
def postgres_url():
"""Get PostgreSQL URL from environment."""
url = os.environ.get('TEST_POSTGRES_URL')
if not url:
pytest.skip('TEST_POSTGRES_URL not set')
return url
@pytest.fixture
async def postgres_engine(postgres_url):
"""Create async PostgreSQL engine."""
engine = create_async_engine(postgres_url, isolation_level='AUTOCOMMIT')
yield engine
await engine.dispose()
@pytest.fixture
async def clean_tables(postgres_engine):
"""Drop all tables before and after each test for isolation."""
# Drop all tables before test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
yield
# Drop all tables after test
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
async def clean_alembic_version(postgres_engine):
"""Drop alembic_version table before and after each test."""
async with postgres_engine.begin() as conn:
# Drop alembic_version table if exists
try:
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
except Exception:
pass
yield
async with postgres_engine.begin() as conn:
try:
await conn.execute(text('DROP TABLE IF EXISTS alembic_version'))
except Exception:
pass
class TestPostgreSQLMigrationBaseline:
"""Tests for baseline stamp workflow on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_baseline_stamp_sets_revision(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Stamp baseline on existing tables sets correct revision.
Workflow:
1. Create tables via Base.metadata.create_all
2. Stamp with '0001_baseline'
3. Verify current revision is '0001_baseline'
"""
# Create all tables (simulates existing DB created by ORM)
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')
# Verify revision
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline', f"Expected '0001_baseline', got {rev}"
@pytest.mark.asyncio
async def test_postgres_baseline_stamp_on_empty_db(self, postgres_engine, clean_tables, clean_alembic_version):
"""
Stamp on empty database (no tables) still sets revision.
This is an edge case - stamping without tables.
"""
# Don't create tables - stamp directly
await run_alembic_stamp(postgres_engine, '0001_baseline')
rev = await get_alembic_current(postgres_engine)
assert rev == '0001_baseline'
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}'
class TestPostgreSQLMigrationGetCurrent:
"""Tests for get_alembic_current behavior on PostgreSQL."""
@pytest.mark.asyncio
async def test_postgres_get_current_on_unstamped_db_returns_none(
self, postgres_engine, clean_tables, clean_alembic_version
):
"""
get_alembic_current returns None for unstamped database.
"""
# Create tables but don't stamp
async with postgres_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# No stamp - should return None
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'
+5
View File
@@ -0,0 +1,5 @@
"""
Pipeline integration tests package.
Tests for full pipeline flow using fake provider/runner.
"""
@@ -0,0 +1,892 @@
"""
Pipeline full-flow integration tests.
Tests real pipeline stages with fake runner/provider.
Validates message processing through PreProcessor, Processor, and SendResponseBackStage.
Uses RuntimePipeline directly (not PipelineManager) to avoid DB dependency.
Run: uv run pytest tests/integration/pipeline -q --tb=short
"""
from __future__ import annotations
import pytest
import asyncio
from unittest.mock import AsyncMock, Mock
import sys
from tests.factories import FakeApp, text_query, mock_platform_adapter
from tests.factories.provider import FakeProvider
from tests.factories.platform import FakePlatform
pytestmark = pytest.mark.integration
# ============== FIXTURE FOR SYS.MODULES ISOLATION ==============
@pytest.fixture(scope='module')
def mock_circular_import_chain():
"""
Break circular import chain for pipeline modules using isolated_sys_modules.
Chain: pipeline → core.app → provider.runner → http_controller → groups/plugins
We mock minimal modules to allow importing RuntimePipeline, StageInstContainer,
and stage classes without triggering full application initialization.
After mocking, we import the stage modules so decorators register them.
"""
from tests.utils.import_isolation import isolated_sys_modules, MockLifecycleControlScope
# Mock core.entities with LifecycleControlScope enum
mock_core_entities = Mock()
mock_core_entities.LifecycleControlScope = MockLifecycleControlScope
# Mock core.app - Application class is referenced but not instantiated
mock_core_app = Mock()
# Mock provider.runner with preregistered_runners list
mock_runner = Mock()
mock_runner.preregistered_runners = [] # Will be populated in tests
# Mock utils.importutil - prevents auto-import of runners
mock_importutil = Mock()
mock_importutil.import_modules_in_pkg = lambda pkg: None
mock_importutil.import_modules_in_pkgs = lambda pkgs: None
# Modules to clear (force re-import after mocking)
clear = [
'langbot.pkg.pipeline.stage',
'langbot.pkg.pipeline.entities',
'langbot.pkg.pipeline.pipelinemgr',
'langbot.pkg.pipeline.preproc.preproc',
'langbot.pkg.pipeline.process.process',
'langbot.pkg.pipeline.process.handler',
'langbot.pkg.pipeline.process.handlers.chat',
'langbot.pkg.pipeline.process.handlers.command',
'langbot.pkg.pipeline.respback.respback',
'langbot.pkg.provider.runner',
]
with isolated_sys_modules(
mocks={
'langbot.pkg.core.entities': mock_core_entities,
'langbot.pkg.core.app': mock_core_app,
'langbot.pkg.provider.runner': mock_runner,
'langbot.pkg.utils.importutil': mock_importutil,
'langbot.pkg.pipeline.controller': Mock(),
'langbot.pkg.pipeline.pipelinemgr': Mock(),
},
clear=clear,
):
# Import stage modules AFTER clearing so decorators register them
from importlib import import_module
# Import stage base first
import_module('langbot.pkg.pipeline.stage')
# Import entities
import_module('langbot.pkg.pipeline.entities')
# Import specific stages to register them
import_module('langbot.pkg.pipeline.preproc.preproc')
import_module('langbot.pkg.pipeline.process.process')
import_module('langbot.pkg.pipeline.respback.respback')
# Import pipelinemgr for RuntimePipeline
import_module('langbot.pkg.pipeline.pipelinemgr')
yield
# ============== FAKE RUNNER ==============
class FakeRunner:
"""Minimal fake runner class for pipeline integration tests.
Note: preregistered_runners expects a CLASS, not an instance.
The handler calls runner_cls(self.ap, query.pipeline_config) to instantiate.
"""
name = 'local-agent'
def __init__(self, app=None, config=None):
self.app = app
self.config = config or {}
self._provider = FakeProvider()
# Instance-level configuration set via class attribute
self._response_text = 'fake response'
self._raise_error = None
@classmethod
def returns(cls, text: str):
"""Create a runner class configured to return specific text."""
# We create a subclass with configured response
class ConfiguredRunner(cls):
name = cls.name
_response_text = text
_raise_error = None
def __init__(self, app=None, config=None):
super().__init__(app, config)
self._response_text = text
return ConfiguredRunner
@classmethod
def raises(cls, error: Exception):
"""Create a runner class configured to raise an error."""
class ConfiguredRunner(cls):
name = cls.name
_response_text = None
_raise_error = error
def __init__(self, app=None, config=None):
super().__init__(app, config)
self._raise_error = error
return ConfiguredRunner
async def run(self, query):
"""Run the fake provider and yield messages."""
from langbot_plugin.api.entities.builtin.provider.message import Message
# Use the configured response/error
if self._raise_error:
raise self._raise_error
# Yield a simple message
yield Message(role='assistant', content=self._response_text)
# ============== PIPELINE APP FIXTURE ==============
@pytest.fixture
def pipeline_app():
"""
Create FakeApp with all dependencies required by pipeline stages.
PreProcessor needs: sess_mgr, model_mgr, tool_mgr, plugin_connector
Processor needs: instance_config, plugin_connector
SendResponseBackStage needs: logger
ChatMessageHandler needs: telemetry, survey
"""
app = FakeApp()
# Session/conversation mocks for PreProcessor
mock_session = Mock()
mock_session.launcher_type = Mock()
mock_session.launcher_type.value = 'person'
mock_session.launcher_id = 12345
mock_session.sender_id = 12345
mock_session.use_prompt_name = 'default'
mock_session.using_conversation = None
# Create a simple class to mimic Prompt behavior
class MockPrompt:
def __init__(self, name, messages):
self.name = name
self.messages = messages
def copy(self):
return MockPrompt(self.name, list(self.messages))
# Create real lists for messages
prompt_messages_list = []
messages_list = []
mock_prompt = MockPrompt('default', prompt_messages_list)
mock_conversation = Mock()
mock_conversation.prompt = mock_prompt
mock_conversation.messages = messages_list
mock_conversation.uuid = 'test-conversation-uuid'
mock_conversation.update_time = None
mock_conversation.create_time = None
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
# Model mock for PreProcessor
mock_model = Mock()
mock_model.model_entity = Mock()
mock_model.model_entity.uuid = 'test-model-uuid'
mock_model.model_entity.name = 'test-model'
mock_model.model_entity.abilities = ['func_call', 'vision']
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
# Tool manager mock
app.tool_mgr.get_all_tools = AsyncMock(return_value=[])
# Telemetry mock (required by ChatMessageHandler)
app.telemetry = Mock()
app.telemetry.start_send_task = AsyncMock()
# Survey mock
app.survey = None
return app
@pytest.fixture
def fake_platform_adapter():
"""Create a fake platform adapter for outbound capture."""
platform = FakePlatform(stream_output_supported=False)
adapter = mock_platform_adapter(platform)
return adapter, platform
@pytest.fixture
def set_fake_runner():
"""Factory fixture to set a fake runner CLASS in preregistered_runners."""
def _set_runner(runner_cls):
# preregistered_runners expects a list of runner classes
sys.modules['langbot.pkg.provider.runner'].preregistered_runners = [runner_cls]
return _set_runner
# ============== PIPELINE CONFIGURATION ==============
def create_minimal_pipeline_config():
"""Create minimal pipeline configuration for tests."""
return {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': None},
'local-agent': {
'model': {'primary': 'test-model-uuid', 'fallbacks': []},
'prompt': 'default',
'knowledge-bases': [],
},
},
'output': {
'force-delay': {'min': 0.0, 'max': 0.0},
'misc': {
'at-sender': False,
'quote-origin': False,
'exception-handling': 'show-hint',
'failure-hint': 'Request failed.',
},
},
'trigger': {
'misc': {'combine-quote-message': False},
},
}
# ============== HELPER TO PROCESS COROUTINE/GENERATOR ==============
async def collect_processor_results(processor, query, stage_name):
"""
Helper to handle the coroutine -> async_generator pattern.
Processor.process() returns a coroutine that yields an async_generator.
This helper handles both cases like RuntimePipeline does.
"""
result = processor.process(query, stage_name)
# Handle coroutine (await it to get async_generator)
if asyncio.iscoroutine(result):
result = await result
# Now iterate over async_generator
results = []
async for item in result:
results.append(item)
return results
# ============== TESTS ==============
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPipelineStageChainReal:
"""Tests for real pipeline stage chain."""
@pytest.mark.asyncio
async def test_import_pipeline_modules(self):
"""Verify we can import real pipeline modules."""
from langbot.pkg.pipeline import stage, entities
from langbot.pkg.pipeline import pipelinemgr
assert hasattr(stage, 'PipelineStage')
assert hasattr(stage, 'preregistered_stages')
assert hasattr(entities, 'ResultType')
assert hasattr(entities, 'StageProcessResult')
assert hasattr(pipelinemgr, 'RuntimePipeline')
assert hasattr(pipelinemgr, 'StageInstContainer')
@pytest.mark.asyncio
async def test_stage_preregistration(self):
"""Verify stages are preregistered after fixture imports them."""
from langbot.pkg.pipeline import stage
# Check that our target stages are registered
assert 'PreProcessor' in stage.preregistered_stages
assert 'MessageProcessor' in stage.preregistered_stages
assert 'SendResponseBackStage' in stage.preregistered_stages
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestPreProcessorStage:
"""Tests for PreProcessor stage alone."""
@pytest.mark.asyncio
async def test_preproc_continues_on_valid_query(self, pipeline_app, fake_platform_adapter):
"""PreProcessor should return CONTINUE for valid text query."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
adapter, platform = fake_platform_adapter
# Create query with adapter
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector for PromptPreProcessing event
mock_event_ctx = Mock()
mock_event_ctx.event = Mock()
mock_event_ctx.event.default_prompt = [] # Real list
mock_event_ctx.event.prompt = [] # Real list
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create PreProcessor stage
preproc_stage = preproc.PreProcessor(pipeline_app)
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
assert result.new_query.session is not None
assert result.new_query.user_message is not None
@pytest.mark.asyncio
async def test_preproc_sets_user_message(self, pipeline_app, fake_platform_adapter):
"""PreProcessor should set user_message from message_chain."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
adapter, platform = fake_platform_adapter
query = text_query('test message content')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector for PromptPreProcessing event
mock_event_ctx = Mock()
mock_event_ctx.event = Mock()
mock_event_ctx.event.default_prompt = []
mock_event_ctx.event.prompt = []
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
preproc_stage = preproc.PreProcessor(pipeline_app)
result = await preproc_stage.process(query, 'PreProcessor')
assert result.result_type == entities.ResultType.CONTINUE
# Check user_message content
assert result.new_query.user_message is not None
assert result.new_query.user_message.role == 'user'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestProcessorStage:
"""Tests for MessageProcessor stage."""
@pytest.mark.asyncio
async def test_processor_calls_chat_handler(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""Processor should route to ChatMessageHandler for non-command messages."""
adapter, platform = fake_platform_adapter
# Set fake runner that returns pong
fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG')
set_fake_runner(fake_runner)
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
# 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
@pytest.mark.asyncio
async def test_processor_prevent_default_without_reply_interrupts(self, pipeline_app, fake_platform_adapter):
"""Processor should INTERRUPT when plugin prevents default without reply."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector to prevent default without reply
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=True)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
@pytest.mark.asyncio
async def test_processor_prevent_default_with_reply_continues(self, pipeline_app, fake_platform_adapter):
"""Processor should CONTINUE when plugin prevents default with reply."""
from langbot.pkg.pipeline import entities
from tests.factories.message import text_chain
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = []
# Create reply chain
reply_chain = text_chain('plugin response')
# Mock plugin_connector to prevent default with reply
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=True)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = reply_chain
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.CONTINUE
assert len(query.resp_messages) == 1
assert query.resp_messages[0] == reply_chain
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestRunnerExceptionFlow:
"""Tests for runner exception handling."""
@pytest.mark.asyncio
async def test_runner_exception_yields_interrupt(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""Runner exception should yield INTERRUPT with error notices."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises exception
fake_runner = FakeRunner().raises(ValueError('API Error: rate limit exceeded'))
set_fake_runner(fake_runner)
# Create query with exception handling config
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'show-hint'
config['output']['misc']['failure-hint'] = 'Request failed.'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].user_notice == 'Request failed.'
assert results[0].error_notice is not None
@pytest.mark.asyncio
async def test_runner_exception_show_error_mode(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""show-error mode should show actual exception message."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises specific exception
fake_runner = FakeRunner().raises(RuntimeError('Custom runtime error'))
set_fake_runner(fake_runner)
# Create query with show-error mode
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'show-error'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert 'Custom runtime error' in results[0].user_notice
@pytest.mark.asyncio
async def test_runner_exception_hide_mode(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""hide mode should not show user notice."""
from langbot.pkg.pipeline import entities
adapter, platform = fake_platform_adapter
# Set fake runner that raises exception
fake_runner = FakeRunner().raises(Exception('Hidden error'))
set_fake_runner(fake_runner)
# Create query with hide mode
config = create_minimal_pipeline_config()
config['output']['misc']['exception-handling'] = 'hide'
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = config
# Mock plugin_connector to not prevent default
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
# Create Processor stage
from langbot.pkg.pipeline.process import process
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
assert results[0].user_notice is None
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestSendResponseBackStage:
"""Tests for SendResponseBackStage."""
@pytest.mark.asyncio
async def test_send_response_calls_adapter(self, pipeline_app, fake_platform_adapter):
"""SendResponseBackStage should call adapter.reply_message."""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
adapter, platform = fake_platform_adapter
# Create query with response message
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Add response message
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
# Create SendResponseBackStage
respback_stage = respback.SendResponseBackStage(pipeline_app)
result = await respback_stage.process(query, 'SendResponseBackStage')
assert result.result_type == entities.ResultType.CONTINUE
# Check that adapter was called
outbound = platform.get_outbound_messages()
assert len(outbound) == 1
assert outbound[0]['type'] == 'reply'
@pytest.mark.asyncio
async def test_send_response_failure_notifies_plugin_diagnostic(self, pipeline_app):
"""Plugin-provided deferred replies should report delivery failures."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.current_stage_name = 'SendResponseBackStage'
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
[
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
],
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_awaited_once()
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['code'] == 'response_delivery_failed'
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert payload['query']['event_name'] == 'NormalMessageResponded'
assert payload['delivery']['error_type'] == 'RuntimeError'
assert 'attribution_warning' not in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_warns_for_old_runtime_attribution(self, pipeline_app):
"""Older plugin runtimes without response_sources should get approximate diagnostics."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
None,
[{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert 'attribution_warning' in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_ignores_query_variable_spoofing(self, pipeline_app):
"""Plugin-controlled query variables must not mask delivery failures."""
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
query.variables['_plugin_response_sources'] = {0: ['malformed']}
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called()
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestStageChainIntegration:
"""Tests for full stage chain (PreProcessor -> Processor -> SendResponseBackStage)."""
@pytest.mark.asyncio
async def test_full_chain_text_message_flow(self, pipeline_app, fake_platform_adapter, set_fake_runner):
"""
Full chain: text message -> PreProcessor -> Processor -> SendResponseBackStage.
Validates:
- PreProcessor sets up session, user_message
- Processor calls runner and populates resp_messages
- SendResponseBackStage calls adapter.reply_message
"""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
from langbot.pkg.pipeline.process import process
from langbot.pkg.pipeline.respback import respback
adapter, platform = fake_platform_adapter
# Set fake runner
fake_runner = FakeRunner().returns('LANGBOT_FAKE_PONG')
set_fake_runner(fake_runner)
# Create query
config = create_minimal_pipeline_config()
query = text_query('ping')
query.adapter = adapter
query.pipeline_config = config
query.resp_messages = []
query.resp_message_chain = []
# Mock plugin_connector for PreProcessor and Processor events
mock_event_ctx_preproc = Mock()
mock_event_ctx_preproc.event = Mock()
mock_event_ctx_preproc.event.default_prompt = []
mock_event_ctx_preproc.event.prompt = []
mock_event_ctx_processor = Mock()
mock_event_ctx_processor.is_prevented_default = Mock(return_value=False)
mock_event_ctx_processor.event = Mock()
mock_event_ctx_processor.event.user_message_alter = None
pipeline_app.plugin_connector.emit_event = AsyncMock()
pipeline_app.plugin_connector.emit_event.side_effect = [
mock_event_ctx_preproc, # PreProcessor PromptPreProcessing
mock_event_ctx_processor, # Processor NormalMessageReceived
]
# Create stages
preproc_stage = preproc.PreProcessor(pipeline_app)
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(config)
respback_stage = respback.SendResponseBackStage(pipeline_app)
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
query = result1.new_query
# Run Processor
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) >= 1
# Build resp_message_chain from resp_messages
from tests.factories.message import text_chain
for resp_msg in query.resp_messages:
if resp_msg.content:
query.resp_message_chain.append(text_chain(resp_msg.content))
# Run SendResponseBackStage
result3 = await respback_stage.process(query, 'SendResponseBackStage')
assert result3.result_type == entities.ResultType.CONTINUE
# Verify adapter was called
outbound = platform.get_outbound_messages()
assert len(outbound) >= 1
@pytest.mark.asyncio
async def test_chain_stops_on_interrupt(self, pipeline_app, fake_platform_adapter):
"""
Chain should stop when a stage returns INTERRUPT.
PreProcessor returns CONTINUE, Processor returns INTERRUPT (prevent_default).
"""
from langbot.pkg.pipeline import entities
from langbot.pkg.pipeline.preproc import preproc
from langbot.pkg.pipeline.process import process
adapter, platform = fake_platform_adapter
# Create query
query = text_query('hello')
query.adapter = adapter
query.pipeline_config = create_minimal_pipeline_config()
# Mock plugin_connector - PreProcessor continues, Processor interrupts
mock_event_ctx_preproc = Mock()
mock_event_ctx_preproc.event = Mock()
mock_event_ctx_preproc.event.default_prompt = []
mock_event_ctx_preproc.event.prompt = []
mock_event_ctx_processor = Mock()
mock_event_ctx_processor.is_prevented_default = Mock(return_value=True)
mock_event_ctx_processor.event = Mock()
mock_event_ctx_processor.event.reply_message_chain = None
pipeline_app.plugin_connector.emit_event = AsyncMock()
pipeline_app.plugin_connector.emit_event.side_effect = [
mock_event_ctx_preproc, # PreProcessor PromptPreProcessing
mock_event_ctx_processor, # Processor NormalMessageReceived
]
# Create stages
preproc_stage = preproc.PreProcessor(pipeline_app)
processor_stage = process.Processor(pipeline_app)
await processor_stage.initialize(query.pipeline_config)
# Run PreProcessor
result1 = await preproc_stage.process(query, 'PreProcessor')
assert result1.result_type == entities.ResultType.CONTINUE
query = result1.new_query
# Run Processor - should INTERRUPT
results = await collect_processor_results(processor_stage, query, 'MessageProcessor')
assert len(results) == 1
assert results[0].result_type == entities.ResultType.INTERRUPT
# Chain stops here - no resp_messages
assert len(query.resp_messages) == 0
@@ -0,0 +1,344 @@
"""Integration tests for the Valkey Search VDB backend.
These are SLOW, real-server tests. They are gated on ``TEST_VALKEY_URL`` and
skipped when it is unset (same precedent as the PostgreSQL migration tests).
Run locally against valkey/valkey-bundle:9.1.0::
podman run -d --name valkey-test-langbot -p 6380:6379 valkey/valkey-bundle:9.1.0
TEST_VALKEY_URL=valkey://localhost:6380 \\
uv run pytest tests/integration/vector/test_valkey_search.py -m slow -q
The default upstream fast CI lane (``-m "not slow"``) skips these; the local
supervisor validator MUST run them.
"""
from __future__ import annotations
import asyncio
import os
import uuid
from types import SimpleNamespace
from urllib.parse import urlparse
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.slow]
def _parse_valkey_url(url: str) -> tuple[str, int, int]:
"""Parse ``valkey://host:port/db`` into ``(host, port, db)``."""
parsed = urlparse(url)
host = parsed.hostname or 'localhost'
port = parsed.port or 6379
db = 0
if parsed.path and parsed.path.strip('/'):
try:
db = int(parsed.path.strip('/'))
except ValueError:
db = 0
return host, port, db
@pytest.fixture
def valkey_config():
url = os.environ.get('TEST_VALKEY_URL')
if not url:
pytest.skip('TEST_VALKEY_URL not set')
host, port, db = _parse_valkey_url(url)
return {
'host': host,
'port': port,
'db': db,
'password': '',
'username': '',
'tls': False,
'index_algorithm': 'HNSW',
'distance_metric': 'COSINE',
}
def _make_ap(valkey_config):
"""Build a minimal fake ``ap`` with the config + a no-op logger."""
logger = SimpleNamespace(
info=lambda *a, **k: None,
warning=lambda *a, **k: None,
error=lambda *a, **k: None,
debug=lambda *a, **k: None,
)
instance_config = SimpleNamespace(data={'vdb': {'valkey_search': valkey_config}})
return SimpleNamespace(instance_config=instance_config, logger=logger)
@pytest.fixture
async def backend(valkey_config):
"""Create a Valkey Search backend, skip if module/server unavailable."""
from langbot.pkg.vector.vdbs.valkey_search import (
ValkeySearchVectorDatabase,
VALKEY_SEARCH_AVAILABLE,
)
if not VALKEY_SEARCH_AVAILABLE:
pytest.skip('valkey-glide not installed')
from glide import ft
ap = _make_ap(valkey_config)
db = ValkeySearchVectorDatabase(ap)
client = await db._ensure_client()
# Module-presence gate: FT.LIST must be available (Search module loaded).
try:
await ft.list(client)
except Exception as exc: # noqa: BLE001
await client.close()
pytest.skip(f'Valkey Search module not available: {exc}')
collection = f'test_{uuid.uuid4().hex[:12]}'
yield db, collection
# Cleanup
try:
await db.delete_collection(collection)
except Exception:
pass
if db._client is not None:
await db._client.close()
async def _poll_until(coro_factory, predicate, timeout=5.0, interval=0.2):
"""Poll an async result until predicate is true (indexer is async)."""
deadline = asyncio.get_event_loop().time() + timeout
result = await coro_factory()
while not predicate(result) and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(interval)
result = await coro_factory()
return result
def _sample_docs():
ids = ['d1', 'd2', 'd3']
embeddings = [
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.9, 0.1, 0.0, 0.0],
]
metadatas = [
{'file_id': 'fileA', 'topic': 'cats'},
{'file_id': 'fileB', 'topic': 'dogs'},
{'file_id': 'fileA', 'topic': 'cats'},
]
documents = [
'the quick brown fox',
'lazy dogs sleeping',
'foxes and cats playing',
]
return ids, embeddings, metadatas, documents
@pytest.mark.asyncio
async def test_add_and_vector_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [1.0, 0.0, 0.0, 0.0], k=3, search_type='vector'),
lambda r: len(r['ids'][0]) >= 1,
)
assert len(result['ids'][0]) >= 1
# Closest to [1,0,0,0] should be d1.
assert result['ids'][0][0] == 'd1'
assert all(isinstance(d, float) for d in result['distances'][0])
@pytest.mark.asyncio
async def test_full_text_search(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(collection, [0.0, 0.0, 0.0, 0.0], k=5, search_type='full_text', query_text='dogs'),
lambda r: len(r['ids'][0]) >= 1,
)
assert 'd2' in result['ids'][0]
@pytest.mark.asyncio
async def test_hybrid_filter_then_knn(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
result = await _poll_until(
lambda: db.search(
collection,
[1.0, 0.0, 0.0, 0.0],
k=5,
search_type='hybrid',
query_text='cats',
filter={'file_id': 'fileA'},
),
lambda r: len(r['ids'][0]) >= 1,
)
# Only fileA docs (d1, d3) should be candidates.
assert set(result['ids'][0]).issubset({'d1', 'd3'})
@pytest.mark.asyncio
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()
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)
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]
@pytest.mark.asyncio
async def test_filter_operators(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
await db.add_embeddings(collection, ids, embeddings, metadatas, documents)
# Wait for indexing.
await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] >= 3,
)
# $eq
items, total = await db.list_by_filter(collection, filter={'file_id': 'fileA'})
assert total == 2
assert {it['id'] for it in items} == {'d1', 'd3'}
# $ne
items, total = await db.list_by_filter(collection, filter={'file_id': {'$ne': 'fileA'}})
assert {it['id'] for it in items} == {'d2'}
# $in
items, total = await db.list_by_filter(collection, filter={'file_id': {'$in': ['fileA', 'fileB']}})
assert total == 3
# $nin
items, total = await db.list_by_filter(collection, filter={'file_id': {'$nin': ['fileB']}})
assert {it['id'] for it in items} == {'d1', 'd3'}
@pytest.mark.asyncio
async def test_delete_by_file_id(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
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)
await db.delete_by_file_id(collection, 'fileA')
items, total = await _poll_until(
lambda: db.list_by_filter(collection, limit=10),
lambda r: r[1] <= 1,
)
assert {it['id'] for it in items} == {'d2'}
@pytest.mark.asyncio
async def test_delete_by_filter_returns_count(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
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)
deleted = await db.delete_by_filter(collection, filter={'file_id': 'fileA'})
assert deleted == 2
@pytest.mark.asyncio
async def test_list_by_filter_pagination(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
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)
page1, total = await db.list_by_filter(collection, limit=2, offset=0)
assert total == 3
assert len(page1) == 2
page2, total = await db.list_by_filter(collection, limit=2, offset=2)
assert total == 3
assert len(page2) == 1
@pytest.mark.asyncio
async def test_delete_collection(backend):
db, collection = backend
ids, embeddings, metadatas, documents = _sample_docs()
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)
await db.delete_collection(collection)
# 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')
assert result['ids'][0] == []
@pytest.mark.asyncio
async def test_adversarial_filter_and_query_input(backend):
"""Crafted FT special chars in file_id / query_text must not break out.
Guarantees locked in here:
* A file_id full of injection-style chars (quotes, parens, ``|``, ``@``,
``:``, spaces, dashes) only ever matches its own row — the payload is
escaped to literal TAG content, never interpreted as extra clauses.
* A query_text full of FT operators does not raise and does not widen the
result set.
* A file_id containing FT-unsafe chars (``{`` / ``}`` / ``*``) is
percent-encoded, so it round-trips correctly: an exact match returns ONLY
its own row and never widens to an unrelated row, and the query does not
raise.
"""
db, collection = backend
# Injection-style file_id WITHOUT FT-unsafe chars (the realistic surface).
injection_fid = 'evil") @file_id (".id|x-y:z'
# file_id WITH FT-unsafe chars that previously could not be queried.
brace_fid = 'x} @file_id:{*'
ids = ['adv1', 'benign2', 'brace3']
embeddings = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]
metadatas = [{'file_id': injection_fid}, {'file_id': 'plainB'}, {'file_id': brace_fid}]
documents = ['payload row content', 'unrelated benign content', 'brace row content']
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)
# Exact-match on the crafted file_id returns ONLY its own row.
items, total = await db.list_by_filter(collection, filter={'file_id': injection_fid})
assert total == 1
assert {it['id'] for it in items} == {'adv1'}
# A query_text packed with FT operators must not raise and must not match
# the benign row (escaped to literal terms, none of which it contains).
result = await db.search(
collection,
[0.0, 0.0, 0.0, 0.0],
k=5,
search_type='full_text',
query_text='@document:{*} | -()~ "evil"',
)
assert 'benign2' not in result['ids'][0]
# The brace/star-bearing file_id is encoded, so it round-trips: exact match
# returns ONLY its own row and never widens. No RequestError is raised.
b_items, b_total = await db.list_by_filter(collection, filter={'file_id': brace_fid})
assert b_total == 1
assert {it['id'] for it in b_items} == {'brace3'}
# And deletion by that file_id removes exactly its own row.
deleted = await db.delete_by_filter(collection, filter={'file_id': brace_fid})
assert deleted == 1