From d25c1e0f0cfb29ddc39e8ea1ec267fe6652f31ad Mon Sep 17 00:00:00 2001 From: huanghuoguoguo <60681390+huanghuoguoguo@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:16:26 +0800 Subject: [PATCH] test(api): verify monitoring payloads and embed side effects --- tests/integration/api/test_embed.py | 98 +++++++++++++++- tests/integration/api/test_monitoring.py | 135 ++++++++++++++++++++++- 2 files changed, 221 insertions(+), 12 deletions(-) diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py index d27b1e002..73204674f 100644 --- a/tests/integration/api/test_embed.py +++ b/tests/integration/api/test_embed.py @@ -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}', diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py index 84320afbe..f10540d41 100644 --- a/tests/integration/api/test_monitoring.py +++ b/tests/integration/api/test_monitoring.py @@ -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