"""Tests for web/api.py module - REST API endpoints.""" from unittest.mock import patch, MagicMock import time import pytest @pytest.fixture def client(): """Create a test client for the API.""" from flask import Flask from local_deep_research.web.api import api_blueprint app = Flask(__name__) app.config["TESTING"] = True app.config["SECRET_KEY"] = "test-secret-key" app.register_blueprint(api_blueprint) return app.test_client() @pytest.fixture def authenticated_client(): """Create a test client with authentication mocked.""" from flask import Flask from local_deep_research.web.api import api_blueprint app = Flask(__name__) app.config["TESTING"] = True app.config["SECRET_KEY"] = "test-secret-key" app.register_blueprint(api_blueprint) with app.test_client() as client: with client.session_transaction() as sess: sess["username"] = "testuser" yield client class TestHealthCheck: """Tests for /api/v1/health endpoint.""" def test_returns_ok_status(self, client): """Should return ok status.""" response = client.get("/api/v1/health") assert response.status_code == 200 data = response.get_json() assert data["status"] == "ok" assert data["message"] == "API is running" assert "timestamp" in data def test_returns_timestamp(self, client): """Should return a valid timestamp.""" response = client.get("/api/v1/health") data = response.get_json() # Timestamp should be close to current time assert abs(data["timestamp"] - time.time()) < 5 class TestApiDocumentation: """Tests for /api/v1/ endpoint.""" def test_returns_api_docs(self, authenticated_client): """Should return API documentation.""" with patch( "local_deep_research.web.api.get_user_db_session" ) as mock_session_ctx: mock_session = MagicMock() mock_session_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_session_ctx.return_value.__exit__ = MagicMock( return_value=None ) with patch( "local_deep_research.web.api.get_settings_manager" ) as mock_settings: mock_manager = MagicMock() mock_manager.get_setting.side_effect = lambda key, default: { "app.enable_api": True, }.get(key, default) mock_settings.return_value = mock_manager response = authenticated_client.get("/api/v1/") assert response.status_code == 200 data = response.get_json() assert data["api_version"] == "v1" assert "endpoints" in data assert len(data["endpoints"]) >= 1 def test_lists_available_endpoints(self, authenticated_client): """Should list all available endpoints.""" with patch( "local_deep_research.web.api.get_user_db_session" ) as mock_session_ctx: mock_session = MagicMock() mock_session_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_session_ctx.return_value.__exit__ = MagicMock( return_value=None ) with patch( "local_deep_research.web.api.get_settings_manager" ) as mock_settings: mock_manager = MagicMock() mock_manager.get_setting.side_effect = lambda key, default: { "app.enable_api": True, }.get(key, default) mock_settings.return_value = mock_manager response = authenticated_client.get("/api/v1/") data = response.get_json() # Check that key endpoints are documented endpoints = data["endpoints"] paths = [ep["path"] for ep in endpoints] assert "/api/v1/quick_summary" in paths assert "/api/v1/generate_report" in paths assert "/api/v1/analyze_documents" in paths class TestApiAccessControl: """Tests for API access control decorator.""" def test_returns_403_when_api_disabled(self, authenticated_client): """Should return 403 when API is disabled.""" with patch( "local_deep_research.web.api.get_user_db_session" ) as mock_session_ctx: mock_session = MagicMock() mock_session_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_session_ctx.return_value.__exit__ = MagicMock( return_value=None ) with patch( "local_deep_research.web.api.get_settings_manager" ) as mock_settings: mock_manager = MagicMock() mock_manager.get_setting.side_effect = lambda key, default: { "app.enable_api": False, # API disabled }.get(key, default) mock_settings.return_value = mock_manager response = authenticated_client.get("/api/v1/") assert response.status_code == 403 data = response.get_json() assert "disabled" in data["error"].lower() class TestRateLimiting: """Tests for rate limiting functionality.""" def test_allows_requests_under_limit(self, authenticated_client): """Should allow requests under the rate limit.""" with patch( "local_deep_research.web.api.get_user_db_session" ) as mock_session_ctx: mock_session = MagicMock() mock_session_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_session_ctx.return_value.__exit__ = MagicMock( return_value=None ) with patch( "local_deep_research.web.api.get_settings_manager" ) as mock_settings: mock_manager = MagicMock() mock_manager.get_setting.side_effect = lambda key, default: { "app.enable_api": True, }.get(key, default) mock_settings.return_value = mock_manager # Make a few requests for _ in range(3): response = authenticated_client.get("/api/v1/") assert response.status_code == 200 class TestQuickSummaryQueryTypeValidation: """Tests for query type validation in api_quick_summary (PR #2034). PR #2034 adds isinstance(query, str) check to reject non-string query values with 400 status. """ def _make_request(self, authenticated_client, json_data): """Helper to make a quick_summary request with mocked settings.""" with patch( "local_deep_research.web.api.get_user_db_session" ) as mock_session_ctx: mock_session = MagicMock() mock_session_ctx.return_value.__enter__ = MagicMock( return_value=mock_session ) mock_session_ctx.return_value.__exit__ = MagicMock( return_value=None ) with patch( "local_deep_research.web.api.get_settings_manager" ) as mock_settings: mock_manager = MagicMock() mock_manager.get_setting.side_effect = lambda key, default: { "app.enable_api": True, }.get(key, default) mock_settings.return_value = mock_manager return authenticated_client.post( "/api/v1/quick_summary", json=json_data, ) def test_rejects_integer_query(self, authenticated_client): """Integer query returns 400.""" response = self._make_request(authenticated_client, {"query": 12345}) assert response.status_code == 400 data = response.get_json() assert "string" in data["error"].lower() def test_rejects_list_query(self, authenticated_client): """List query returns 400.""" response = self._make_request( authenticated_client, {"query": ["a", "b"]} ) assert response.status_code == 400 def test_rejects_dict_query(self, authenticated_client): """Dict query returns 400.""" response = self._make_request( authenticated_client, {"query": {"nested": "value"}} ) assert response.status_code == 400 def test_rejects_null_query(self, authenticated_client): """None/null query returns 400.""" response = self._make_request(authenticated_client, {"query": None}) assert response.status_code == 400