test(api): verify monitoring payloads and embed side effects

This commit is contained in:
huanghuoguoguo
2026-10-17 15:16:26 +08:00
parent bbf517edbc
commit d25c1e0f0c
2 changed files with 221 additions and 12 deletions
+92 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import json
import os
from pathlib import Path
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock
@@ -148,6 +149,11 @@ class TestEmbedWidgetEndpoint:
assert 'javascript' in response.content_type
fake_embed_app.platform_mgr.resolve_public_bot.assert_any_await('a1b2c3d4-5678-90ab-cdef-123456789abc')
body = await response.get_data(as_text=True)
assert 'a1b2c3d4-5678-90ab-cdef-123456789abc' in body
assert 'baseUrl: "http://localhost"' in body
assert '__LANGBOT_' not in body
@pytest.mark.asyncio
@pytest.mark.parametrize(
('query', 'expected_base'),
@@ -208,14 +214,21 @@ class TestEmbedLogoEndpoint:
assert response.status_code == 200
assert 'image/webp' in response.content_type
body = await response.get_data()
assert body == (Path(__file__).parents[3] / 'src/langbot/templates/embed/logo.webp').read_bytes()
assert body[:4] == b'RIFF' and body[8:12] == b'WEBP'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedTurnstileVerifyEndpoint:
"""Tests for Turnstile verification endpoint."""
@pytest.mark.asyncio
async def test_turnstile_verify_no_secret(self, quart_test_client):
async def test_turnstile_verify_no_secret(self, quart_test_client, monkeypatch):
"""POST turnstile verify without secret returns dummy token."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.time, 'time', lambda: 1720000000.25)
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/turnstile/verify', json={'token': 'test-token'}
)
@@ -225,6 +238,8 @@ class TestEmbedTurnstileVerifyEndpoint:
assert data['code'] == 0
assert 'token' in data['data']
assert data['data']['token'] == '1720000000.25.dummy'
@pytest.mark.asyncio
async def test_turnstile_verify_invalid_uuid(self, quart_test_client):
"""POST turnstile verify with invalid UUID returns 400."""
@@ -264,7 +279,7 @@ class TestEmbedMessagesEndpoint:
proxy_bot.adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'person', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_group_success(self, quart_test_client):
async def test_get_messages_group_success(self, quart_test_client, fake_embed_app):
"""GET messages/group returns messages."""
response = await quart_test_client.get(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
@@ -273,6 +288,12 @@ class TestEmbedMessagesEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test message'}]
adapter = fake_embed_app.platform_mgr.get_websocket_proxy_bot.return_value.adapter
adapter.get_websocket_messages.assert_called_with('test-pipeline-uuid', 'group', SESSION_ID)
@pytest.mark.asyncio
async def test_get_messages_requires_session_id(self, quart_test_client):
"""GET messages without a client session identifier returns 400."""
@@ -287,12 +308,14 @@ class TestEmbedMessagesEndpoint:
async def test_get_messages_invalid_session_type(self, quart_test_client):
"""GET messages with invalid session_type returns 400."""
response = await quart_test_client.get(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid',
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/invalid?session_id={SESSION_ID}',
headers={'Authorization': 'Bearer 1234567890.dummy'},
)
assert response.status_code == 400
assert (await response.get_json())['msg'] == 'session_type must be person or group'
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestEmbedResetEndpoint:
@@ -337,8 +360,12 @@ class TestEmbedFeedbackEndpoint:
"""Tests for feedback submission endpoint."""
@pytest.mark.asyncio
async def test_submit_feedback_like(self, quart_test_client):
async def test_submit_feedback_like(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=1 (like) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -350,9 +377,29 @@ class TestEmbedFeedbackEndpoint:
assert data['code'] == 0
assert 'feedback_id' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 1,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_dislike(self, quart_test_client):
async def test_submit_feedback_dislike(self, quart_test_client, fake_embed_app, monkeypatch):
"""POST feedback with type=2 (dislike) succeeds."""
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
monkeypatch.setattr(embed.uuid, 'uuid4', lambda: SimpleNamespace(hex='123456789abcdef0123456789abcdef0'))
fake_embed_app.monitoring_service.record_feedback.reset_mock()
response = await quart_test_client.post(
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/feedback',
headers={'Authorization': 'Bearer 1234567890.dummy'},
@@ -361,6 +408,22 @@ class TestEmbedFeedbackEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data']['feedback_id'] == 'embed_123456789abc'
called = fake_embed_app.monitoring_service.record_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == 'workspace-test'
assert called.await_args.kwargs == {
'feedback_id': 'embed_123456789abc',
'feedback_type': 2,
'bot_id': 'a1b2c3d4-5678-90ab-cdef-123456789abc',
'bot_name': 'Test Web Bot',
'pipeline_id': 'test-pipeline-uuid',
'message_id': 'msg-123',
'platform': 'web_page_bot',
}
@pytest.mark.asyncio
async def test_submit_feedback_invalid_type(self, quart_test_client):
"""POST feedback with invalid type returns 400."""
@@ -378,7 +441,30 @@ class TestEmbedWebSocketEndpoint:
"""The public socket authenticates before resolving shared runtime state."""
@pytest.mark.asyncio
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app):
async def test_authenticates_before_connecting(self, quart_test_client, fake_embed_app, monkeypatch):
import langbot.pkg.api.http.controller.groups.pipelines.embed as embed
authenticated = False
original_authenticate = embed.EmbedRouterGroup._authenticate_websocket
original_add = embed.ws_connection_manager.add_connection
proxy = fake_embed_app.platform_mgr.get_websocket_proxy_bot
async def authenticate(router, runtime_bot):
nonlocal authenticated
await original_authenticate(router, runtime_bot)
authenticated = True
async def get_proxy(*_args, **_kwargs):
assert authenticated, 'Runtime lookup must follow successful authentication'
return proxy.return_value
async def add_connection(*args, **kwargs):
assert authenticated, 'Connection registration must follow successful authentication'
return await original_add(*args, **kwargs)
monkeypatch.setattr(embed.EmbedRouterGroup, '_authenticate_websocket', authenticate)
monkeypatch.setattr(proxy, 'side_effect', get_proxy)
monkeypatch.setattr(embed.ws_connection_manager, 'add_connection', add_connection)
async with quart_test_client.websocket(
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/ws/connect'
f'?session_type=person&session_id={SESSION_ID}',
+129 -6
View File
@@ -8,6 +8,9 @@ Run: uv run pytest tests/integration/api/test_monitoring.py -q
from __future__ import annotations
import csv
import io
import pytest
from unittest.mock import MagicMock, AsyncMock, Mock, patch
from types import SimpleNamespace
@@ -122,17 +125,24 @@ def fake_monitoring_app():
)
app.monitoring_service.get_feedback_stats = AsyncMock(return_value={'like_count': 10})
app.monitoring_service.get_feedback_list = AsyncMock(return_value=([{'feedback_id': 'fb-1'}], 12))
app.monitoring_service.export_messages = AsyncMock(return_value=[{'id': 'msg-1'}])
app.monitoring_service.export_messages = AsyncMock(
return_value=[{'id': 'msg-1', 'message_content': 'hello, "quoted"'}]
)
app.monitoring_service.export_llm_calls = AsyncMock(return_value=[{'id': 'llm-1'}])
app.monitoring_service.export_errors = AsyncMock(return_value=[{'id': 'err-1'}])
app.monitoring_service.export_sessions = AsyncMock(return_value=[{'session_id': 'sess-1'}])
app.monitoring_service.export_feedback = AsyncMock(return_value=[{'id': 'fb-1'}])
app.monitoring_service.export_embedding_calls = AsyncMock(return_value=[{'id': 'emb-1'}])
app.monitoring_service._escape_csv_field = Mock(return_value='escaped')
app.monitoring_service._escape_csv_field = MonitoringService(app)._escape_csv_field
return app
@pytest.fixture(autouse=True)
def reset_monitoring_calls(fake_monitoring_app):
fake_monitoring_app.monitoring_service.reset_mock()
@pytest.fixture(scope='module')
async def quart_test_client(fake_monitoring_app, http_controller_cls):
"""Create Quart test client (module scope)."""
@@ -158,6 +168,14 @@ class TestMonitoringOverviewEndpoint:
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
@pytest.mark.asyncio
async def test_viewer_can_read_monitoring_but_cannot_export(
self,
@@ -203,6 +221,15 @@ class TestMonitoringMessagesEndpoint:
assert data['code'] == 0
assert 'messages' in data['data']
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {
'messages': [{'id': 'msg-1', 'content': 'test'}],
'total': 100,
'limit': 100,
'offset': 0,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringLLMCallsEndpoint:
@@ -217,6 +244,10 @@ class TestMonitoringLLMCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'llm_calls': [{'id': 'llm-1'}], 'total': 50, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringEmbeddingCallsEndpoint:
@@ -231,6 +262,10 @@ class TestMonitoringEmbeddingCallsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'embedding_calls': [{'id': 'emb-1'}], 'total': 10, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringSessionsEndpoint:
@@ -245,6 +280,10 @@ class TestMonitoringSessionsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'sessions': [{'session_id': 'sess-1'}], 'total': 20, 'limit': 100, 'offset': 0}
@pytest.mark.asyncio
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
@@ -275,6 +314,10 @@ class TestMonitoringErrorsEndpoint:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'errors': [{'id': 'err-1'}], 'total': 2, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringAllDataEndpoint:
@@ -298,6 +341,29 @@ class TestMonitoringAllDataEndpoint:
assert 'overview' in data['data']
assert data['data']['traffic'] == traffic
assert data['code'] == 0
assert data['data']['overview'] == {
'total_messages': 100,
'total_llm_calls': 50,
'total_sessions': 20,
'active_sessions': 5,
'total_errors': 2,
}
assert data['data']['messages'] == [{'id': 'msg-1', 'content': 'test'}]
assert data['data']['llmCalls'] == [{'id': 'llm-1'}]
assert data['data']['toolCalls'] == [{'id': 'tool-1'}]
assert data['data']['embeddingCalls'] == [{'id': 'emb-1'}]
assert data['data']['sessions'] == [{'session_id': 'sess-1'}]
assert data['data']['errors'] == [{'id': 'err-1'}]
assert data['data']['totalCount'] == {
'messages': 100,
'llmCalls': 50,
'toolCalls': 5,
'embeddingCalls': 10,
'sessions': 20,
'errors': 2,
}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringDetailsEndpoints:
@@ -327,6 +393,10 @@ class TestMonitoringDetailsEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'found': True, 'message_id': 'msg-1'}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringFeedbackEndpoints:
@@ -341,6 +411,10 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'like_count': 10}
@pytest.mark.asyncio
async def test_get_feedback_list(self, quart_test_client):
"""GET /api/v1/monitoring/feedback."""
@@ -350,13 +424,17 @@ class TestMonitoringFeedbackEndpoints:
assert response.status_code == 200
data = await response.get_json()
assert data['code'] == 0
assert data['data'] == {'feedback': [{'feedback_id': 'fb-1'}], 'total': 12, 'limit': 100, 'offset': 0}
@pytest.mark.usefixtures('mock_circular_import_chain')
class TestMonitoringExportEndpoint:
"""Tests for /api/v1/monitoring/export endpoint."""
@pytest.mark.asyncio
async def test_export_messages(self, quart_test_client):
async def test_export_messages(self, quart_test_client, fake_monitoring_app):
"""GET export?type=messages returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=messages', headers={'Authorization': 'Bearer test_token'}
@@ -365,8 +443,20 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'text/csv' in response.content_type
assert 'monitoring-messages-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'msg-1'
called = fake_monitoring_app.monitoring_service.export_messages
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
assert records[0]['message_content'] == 'hello, "quoted"'
@pytest.mark.asyncio
async def test_export_llm_calls(self, quart_test_client):
async def test_export_llm_calls(self, quart_test_client, fake_monitoring_app):
"""GET export?type=llm-calls returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=llm-calls', headers={'Authorization': 'Bearer test_token'}
@@ -374,8 +464,19 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-llm-calls-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'llm-1'
called = fake_monitoring_app.monitoring_service.export_llm_calls
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_sessions(self, quart_test_client):
async def test_export_sessions(self, quart_test_client, fake_monitoring_app):
"""GET export?type=sessions returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=sessions', headers={'Authorization': 'Bearer test_token'}
@@ -383,11 +484,33 @@ class TestMonitoringExportEndpoint:
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-sessions-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['session_id'] == 'sess-1'
called = fake_monitoring_app.monitoring_service.export_sessions
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000
@pytest.mark.asyncio
async def test_export_feedback(self, quart_test_client):
async def test_export_feedback(self, quart_test_client, fake_monitoring_app):
"""GET export?type=feedback returns CSV."""
response = await quart_test_client.get(
'/api/v1/monitoring/export?type=feedback', headers={'Authorization': 'Bearer test_token'}
)
assert response.status_code == 200
assert 'text/csv' in response.content_type
assert 'monitoring-feedback-' in response.headers['Content-Disposition']
body = (await response.get_data()).decode('utf-8-sig')
records = list(csv.DictReader(io.StringIO(body)))
assert len(records) == 1
assert records[0]['id'] == 'fb-1'
called = fake_monitoring_app.monitoring_service.export_feedback
assert called.await_count == 1
assert called.await_args.args[0].workspace_uuid == '00000000-0000-0000-0000-00000000000a'
assert called.await_args.kwargs['limit'] == 100000