"""Tests for API endpoints.""" from unittest.mock import AsyncMock, patch import pytest from fastapi.testclient import TestClient from web.api.app import create_app from web.api.dependencies import init_concurrency_limiter from web.config import SettingsCache, WebSettings from web.exceptions import ( ProviderError, RateLimitError, WebConnectionError, WebTimeoutError, ) from web.fetch.client import FetchClient from web.metasearch.client import SearXNGClient from web.orchestrator import Orchestrator from web.schemas.search import SearchResponse, SearchResult from web.search.paid import PaidSearchClient @pytest.fixture def mock_response() -> SearchResponse: """Create a mock search response.""" return SearchResponse( request_id="test-id", results=[ SearchResult( query="test", url="https://example.com", title="Test", snippet="Test snippet", rank=1, site="example.com", published_at=None, ) ], total_results=1, execution_time_ms=100.0, queries_processed=1, ) @pytest.fixture def test_client(test_settings: WebSettings) -> TestClient: """Create test client with mocked settings.""" SettingsCache.set(test_settings) init_concurrency_limiter(test_settings.max_concurrent_requests) app = create_app() app.state.settings = test_settings app.state.search_client = SearXNGClient(settings=test_settings) app.state.search_client_free = SearXNGClient(settings=test_settings) app.state.search_client_premium = PaidSearchClient(settings=test_settings) app.state.fetch_client = FetchClient(settings=test_settings) app.state.orchestrator_free = Orchestrator(settings=test_settings) app.state.orchestrator_premium = Orchestrator( settings=test_settings, llm_provider="openrouter" ) return TestClient(app) class TestHealthEndpoints: """Tests for health check endpoints.""" def test_ready_endpoint(self, test_client: TestClient) -> None: """Test /ready endpoint.""" response = test_client.get("/ready") assert response.status_code == 200 assert response.json() == {"ready": True} def test_health_endpoint(self, test_client: TestClient) -> None: """Test /health endpoint.""" with patch.object( SearXNGClient, "health_check", new_callable=AsyncMock ) as mock_health: mock_health.return_value = True response = test_client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" assert len(data["providers"]) == 1 assert data["providers"][0]["name"] == "searxng" class TestSearchEndpoint: """Tests for /v1/search endpoint.""" def test_search_success( self, test_client: TestClient, mock_response: SearchResponse ) -> None: """Test successful search.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.return_value = mock_response response = test_client.post( "/v1/search", json={"queries": ["test query"], "max_results": 5}, ) assert response.status_code == 200 data = response.json() assert data["total_results"] == 1 assert len(data["results"]) == 1 assert data["results"][0]["url"] == "https://example.com" def test_search_validation_empty_query(self, test_client: TestClient) -> None: """Test that empty query is rejected.""" response = test_client.post( "/v1/search", json={"queries": [""], "max_results": 5}, ) assert response.status_code == 422 def test_search_validation_too_many_queries(self, test_client: TestClient) -> None: """Test that too many queries are rejected.""" response = test_client.post( "/v1/search", json={"queries": [f"query{i}" for i in range(15)], "max_results": 5}, ) assert response.status_code == 422 def test_search_with_site_allowlist( self, test_client: TestClient, mock_response: SearchResponse ) -> None: """Test search with site allowlist.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.return_value = mock_response response = test_client.post( "/v1/search", json={ "queries": ["test"], "site_allowlist": ["example.com", "test.com"], }, ) assert response.status_code == 200 def test_request_id_header( self, test_client: TestClient, mock_response: SearchResponse ) -> None: """Test that X-Request-ID header is returned.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.return_value = mock_response response = test_client.post( "/v1/search", json={"queries": ["test"]}, headers={"X-Request-ID": "custom-request-id"}, ) assert response.status_code == 200 assert "X-Request-ID" in response.headers assert response.headers["X-Request-ID"] == "custom-request-id" class TestFetchEndpoint: """Tests for /v1/fetch endpoint.""" def test_fetch_rejects_private_urls(self, test_client: TestClient) -> None: """Test that private/localhost URLs are rejected (SSRF protection).""" response = test_client.post( "/v1/fetch", json={"urls": ["http://localhost/secret"]}, ) assert response.status_code == 422 def test_fetch_rejects_non_http_schemes(self, test_client: TestClient) -> None: """Test that non-HTTP(S) schemes are rejected.""" response = test_client.post( "/v1/fetch", json={"urls": ["ftp://example.com/file"]}, ) assert response.status_code == 422 def test_fetch_validation_empty_urls(self, test_client: TestClient) -> None: """Test that empty URL list is rejected.""" response = test_client.post( "/v1/fetch", json={"urls": []}, ) assert response.status_code == 422 class TestInfoEndpoint: """Tests for /v1/info endpoint.""" def test_info_returns_resource(self, test_client: TestClient) -> None: """Test that /v1/info returns resource information.""" response = test_client.get("/v1/info") assert response.status_code == 200 data = response.json() assert "resource" in data assert data["resource"]["slug"] == "web-factcheck" def test_info_returns_functions(self, test_client: TestClient) -> None: """Test that /v1/info returns function definitions.""" response = test_client.get("/v1/info") assert response.status_code == 200 data = response.json() assert "functions" in data assert len(data["functions"]) >= 2 slugs = [f["slug"] for f in data["functions"]] assert "web-gather-evidence" in slugs assert "web-search" in slugs assert "web-fetch" in slugs class TestSearchErrorResponses: """Tests for error response codes on /v1/search.""" def test_search_returns_429_on_rate_limit(self, test_client: TestClient) -> None: """Test that RateLimitError returns 429.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.side_effect = RateLimitError( "Rate limit exceeded", retry_after=5.0 ) response = test_client.post( "/v1/search", json={"queries": ["test"]}, ) assert response.status_code == 429 assert "Retry-After" in response.headers def test_search_returns_502_on_connection_error( self, test_client: TestClient ) -> None: """Test that WebConnectionError returns 502.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.side_effect = WebConnectionError("searxng", "refused") response = test_client.post( "/v1/search", json={"queries": ["test"]}, ) assert response.status_code == 502 data = response.json() assert data["detail"]["error"] == "connection_error" def test_search_returns_502_on_provider_error( self, test_client: TestClient ) -> None: """Test that ProviderError returns 502.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.side_effect = ProviderError("searxng", "Server error") response = test_client.post( "/v1/search", json={"queries": ["test"]}, ) assert response.status_code == 502 data = response.json() assert data["detail"]["error"] == "provider_error" def test_search_returns_504_on_timeout(self, test_client: TestClient) -> None: """Test that WebTimeoutError returns 504.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.side_effect = WebTimeoutError("Timeout", timeout=30.0) response = test_client.post( "/v1/search", json={"queries": ["test"]}, ) assert response.status_code == 504 data = response.json() assert data["detail"]["error"] == "timeout" class TestAuthFlow: """Tests for Bearer token authentication.""" @pytest.fixture def auth_settings(self) -> WebSettings: """Create settings with authentication enabled.""" return WebSettings( searxng_base_url="http://localhost:55100", llm_base_url="http://localhost:14011", external_url="http://localhost:51100", api_tokens="valid-token-123,valid-token-456", ) @pytest.fixture def auth_client(self, auth_settings: WebSettings) -> TestClient: """Create test client with auth enabled.""" SettingsCache.set(auth_settings) init_concurrency_limiter(auth_settings.max_concurrent_requests) app = create_app() app.state.settings = auth_settings app.state.search_client = SearXNGClient(settings=auth_settings) app.state.search_client_free = SearXNGClient(settings=auth_settings) app.state.search_client_premium = PaidSearchClient(settings=auth_settings) app.state.fetch_client = FetchClient(settings=auth_settings) app.state.orchestrator_free = Orchestrator(settings=auth_settings) app.state.orchestrator_premium = Orchestrator( settings=auth_settings, llm_provider="openrouter" ) return TestClient(app) def test_auth_required_returns_401_without_token( self, auth_client: TestClient ) -> None: """Test that missing auth header returns 401.""" response = auth_client.post( "/v1/search", json={"queries": ["test"]}, ) assert response.status_code == 401 def test_auth_required_returns_401_with_invalid_token( self, auth_client: TestClient ) -> None: """Test that invalid token returns 401.""" response = auth_client.post( "/v1/search", json={"queries": ["test"]}, headers={"Authorization": "Bearer wrong-token"}, ) assert response.status_code == 401 def test_auth_succeeds_with_valid_token(self, auth_client: TestClient) -> None: """Test that valid token allows access.""" with patch.object( SearXNGClient, "search", new_callable=AsyncMock ) as mock_search: mock_search.return_value = SearchResponse( request_id="test", results=[], total_results=0, execution_time_ms=10.0, queries_processed=1, ) response = auth_client.post( "/v1/search", json={"queries": ["test"]}, headers={"Authorization": "Bearer valid-token-123"}, ) assert response.status_code == 200 def test_auth_invalid_format_returns_401(self, auth_client: TestClient) -> None: """Test that non-Bearer auth format returns 401.""" response = auth_client.post( "/v1/search", json={"queries": ["test"]}, headers={"Authorization": "Basic dXNlcjpwYXNz"}, ) assert response.status_code == 401 def test_health_endpoints_skip_auth(self, auth_client: TestClient) -> None: """Test that health endpoints don't require auth.""" response = auth_client.get("/ready") assert response.status_code == 200