"""Unit tests for SearXNGClient with mocked httpx.""" from unittest.mock import AsyncMock, patch import httpx import pytest from web.config import WebSettings from web.exceptions import ProviderError, WebTimeoutError from web.metasearch.client import SearXNGClient from web.schemas.search import SearchRequest @pytest.fixture def settings() -> WebSettings: """Create test settings.""" return WebSettings( searxng_base_url="http://localhost:55100", llm_base_url="http://localhost:14011", external_url="http://localhost:51100", max_retries=2, retry_min_wait=0.1, retry_max_wait=1.0, ) @pytest.fixture def client(settings: WebSettings) -> SearXNGClient: """Create a SearXNGClient.""" return SearXNGClient(settings) @pytest.fixture def search_request() -> SearchRequest: """Create a basic search request.""" return SearchRequest(queries=["test query"], max_results=5) @pytest.fixture def searxng_response() -> dict: """Create a mock SearXNG API response.""" return { "results": [ { "url": "https://example.com/article", "title": "Test Article", "content": "A test snippet.", "publishedDate": "2024-01-15", }, { "url": "https://news.example.com/story", "title": "Another Result", "content": "Another snippet.", "publishedDate": None, }, ] } class TestSearchClientSearch: """Tests for SearXNGClient.search.""" @pytest.mark.asyncio async def test_search_returns_results( self, client: SearXNGClient, search_request: SearchRequest, searxng_response: dict, ) -> None: """Test successful search returns parsed results.""" mock_response = httpx.Response(200, json=searxng_response) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response response = await client.search(search_request) assert response.total_results == 2 assert response.results[0].url == "https://example.com/article" assert response.results[0].title == "Test Article" assert response.results[0].rank == 1 assert response.results[1].rank == 2 assert response.queries_processed == 1 @pytest.mark.asyncio async def test_search_multiple_queries( self, client: SearXNGClient, searxng_response: dict ) -> None: """Test that multiple queries are run in parallel.""" request = SearchRequest(queries=["query1", "query2"], max_results=3) mock_response = httpx.Response(200, json=searxng_response) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response response = await client.search(request) assert response.queries_processed == 2 # 2 results per query * 2 queries assert response.total_results == 4 assert mock_get.call_count == 2 class TestSearchClientRetries: """Tests for retry logic in SearXNGClient.""" @pytest.mark.asyncio async def test_retries_on_timeout( self, client: SearXNGClient, search_request: SearchRequest, searxng_response: dict, ) -> None: """Test that transient timeouts are retried.""" mock_response = httpx.Response(200, json=searxng_response) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.side_effect = [ httpx.TimeoutException("timeout"), mock_response, ] response = await client.search(search_request) assert response.total_results == 2 assert mock_get.call_count == 2 @pytest.mark.asyncio async def test_retries_on_connection_error( self, client: SearXNGClient, search_request: SearchRequest, searxng_response: dict, ) -> None: """Test that connection errors are retried.""" mock_response = httpx.Response(200, json=searxng_response) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.side_effect = [ httpx.ConnectError("refused"), mock_response, ] response = await client.search(search_request) assert response.total_results == 2 @pytest.mark.asyncio async def test_raises_after_max_retries( self, client: SearXNGClient, search_request: SearchRequest ) -> None: """Test that error is raised after all retries exhausted.""" with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.side_effect = httpx.TimeoutException("timeout") with pytest.raises(WebTimeoutError): await client.search(search_request) # Initial attempt + 2 retries = 3 calls assert mock_get.call_count == 3 class TestSearchClientErrors: """Tests for error handling in SearXNGClient.""" @pytest.mark.asyncio async def test_raises_provider_error_on_429( self, client: SearXNGClient, search_request: SearchRequest ) -> None: """Test that 429 raises ProviderError without retry.""" mock_response = httpx.Response(429, json={}) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response with pytest.raises(ProviderError): await client.search(search_request) # ProviderError should not be retried assert mock_get.call_count == 1 @pytest.mark.asyncio async def test_raises_provider_error_on_500( self, client: SearXNGClient, search_request: SearchRequest ) -> None: """Test that 500 raises ProviderError after retries are exhausted.""" mock_response = httpx.Response(500, json={}) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response with pytest.raises(ProviderError): await client.search(search_request) # 500 server errors should be retried (1 initial + 2 retries = 3) assert mock_get.call_count == 3 @pytest.mark.asyncio async def test_retries_500_then_succeeds( self, client: SearXNGClient, search_request: SearchRequest ) -> None: """Test that 500 is retried and succeeds on subsequent attempt.""" fail_response = httpx.Response(500, json={}) success_response = httpx.Response( 200, json={ "results": [ { "url": "https://example.com/a", "title": "Result", "content": "Content", } ] }, ) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.side_effect = [fail_response, success_response] response = await client.search(search_request) assert response.total_results == 1 assert mock_get.call_count == 2 @pytest.mark.asyncio async def test_raises_provider_error_on_403( self, client: SearXNGClient, search_request: SearchRequest ) -> None: """Test that 403 raises ProviderError (not authorized).""" mock_response = httpx.Response(403, json={}) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response with pytest.raises(ProviderError): await client.search(search_request) class TestSearchClientSiteFilters: """Tests for site filtering in SearXNGClient.""" @pytest.mark.asyncio async def test_blocklist_filters_results(self, client: SearXNGClient) -> None: """Test that blocklist sites are removed from results.""" request = SearchRequest( queries=["test"], max_results=5, site_blocklist=["blocked.com"], ) searxng_response = { "results": [ { "url": "https://example.com/a", "title": "Good", "content": "Good result", }, { "url": "https://blocked.com/b", "title": "Blocked", "content": "Blocked result", }, ] } mock_response = httpx.Response(200, json=searxng_response) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response response = await client.search(request) assert response.total_results == 1 assert response.results[0].url == "https://example.com/a" class TestSearchClientHealthCheck: """Tests for SearXNGClient health check.""" @pytest.mark.asyncio async def test_health_check_returns_true_on_200( self, client: SearXNGClient ) -> None: """Test that health check returns True on 200.""" mock_response = httpx.Response(200, json={}) with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.return_value = mock_response result = await client.health_check() assert result is True @pytest.mark.asyncio async def test_health_check_returns_false_on_error( self, client: SearXNGClient ) -> None: """Test that health check returns False on exception.""" with patch.object(httpx.AsyncClient, "get", new_callable=AsyncMock) as mock_get: mock_get.side_effect = httpx.ConnectError("refused") result = await client.health_check() assert result is False