diff --git a/tests/integration/api/test_embed.py b/tests/integration/api/test_embed.py new file mode 100644 index 00000000..d9e3ec33 --- /dev/null +++ b/tests/integration/api/test_embed.py @@ -0,0 +1,302 @@ +""" +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): + """Create Quart test client (module scope).""" + from langbot.pkg.api.http.controller.main import HTTPController + + controller = HTTPController(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 \ No newline at end of file diff --git a/tests/integration/api/test_monitoring.py b/tests/integration/api/test_monitoring.py new file mode 100644 index 00000000..a8640c41 --- /dev/null +++ b/tests/integration/api/test_monitoring.py @@ -0,0 +1,332 @@ +""" +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_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): + """Create Quart test client (module scope).""" + from langbot.pkg.api.http.controller.main import HTTPController + + controller = HTTPController(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 \ No newline at end of file