"""Unit tests for WebSearchTool — multi-backend web search.""" import pytest from unittest.mock import AsyncMock, patch, MagicMock from agentkit.tools.web_search import WebSearchTool class TestWebSearchToolSchema: """Test schema definitions.""" def test_input_schema_has_required_fields(self): tool = WebSearchTool() schema = tool.input_schema assert "query" in schema["properties"] assert "query" in schema["required"] assert "max_results" in schema["properties"] def test_output_schema_has_required_fields(self): tool = WebSearchTool() schema = tool.output_schema assert "results" in schema["properties"] assert "total" in schema["properties"] "backend" in schema["properties"] assert "success" in schema["properties"] class TestWebSearchToolValidation: """Test input validation.""" @pytest.mark.asyncio async def test_missing_query(self): tool = WebSearchTool() result = await tool.execute() assert result["success"] is False assert "query" in result["error"] @pytest.mark.asyncio async def test_empty_query(self): tool = WebSearchTool() result = await tool.execute(query="") assert result["success"] is False class TestWebSearchToolDuckDuckGo: """Test DuckDuckGo fallback parsing.""" def test_parse_html_with_results(self): html = """ Result 1 Title Snippet for result 1 Result 2 Title Snippet for result 2 """ results = WebSearchTool._parse_duckduckgo_html(html, 5) assert len(results) == 2 assert results[0]["title"] == "Result 1 Title" assert results[0]["url"] == "https://example.com/result1" assert results[0]["snippet"] == "Snippet for result 1" def test_parse_html_empty(self): results = WebSearchTool._parse_duckduckgo_html("", 5) assert results == [] def test_parse_html_skips_duckduckgo_links(self): html = """ Internal Good Result Good snippet """ results = WebSearchTool._parse_duckduckgo_html(html, 5) assert len(results) == 1 assert results[0]["url"] == "https://example.com/good" def test_parse_html_max_results(self): html = "" for i in range(10): html += f'Title {i}\n' html += f'Snippet {i}\n' results = WebSearchTool._parse_duckduckgo_html(html, 3) assert len(results) == 3 class TestWebSearchToolTavily: """Test Tavily API backend.""" @pytest.mark.asyncio async def test_tavily_success(self): tool = WebSearchTool(tavily_api_key="test-key") mock_response = MagicMock() mock_response.json.return_value = { "results": [ {"title": "Test", "url": "https://example.com", "content": "Test content"}, ] } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=None) mock_client_cls.return_value = mock_client result = await tool.execute(query="test query") assert result["success"] is True assert result["backend"] == "tavily" assert len(result["results"]) == 1 @pytest.mark.asyncio async def test_tavily_failure_falls_back(self): tool = WebSearchTool(tavily_api_key="test-key") with patch.object(tool, "_search_tavily", return_value={"success": False, "error": "API error", "results": [], "total": 0}): with patch.object(tool, "_search_duckduckgo", return_value={"results": [], "total": 0, "backend": "duckduckgo", "success": True}): result = await tool.execute(query="test") assert result["backend"] == "duckduckgo" class TestWebSearchToolSerper: """Test Serper API backend.""" @pytest.mark.asyncio async def test_serper_success(self): tool = WebSearchTool(serper_api_key="test-key") mock_response = MagicMock() mock_response.json.return_value = { "organic": [ {"title": "Test", "link": "https://example.com", "snippet": "Test snippet"}, ] } mock_response.raise_for_status = MagicMock() with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=None) mock_client_cls.return_value = mock_client result = await tool.execute(query="test query") assert result["success"] is True assert result["backend"] == "serper" assert len(result["results"]) == 1 class TestWebSearchToolPriority: """Test backend priority and fallback chain.""" @pytest.mark.asyncio async def test_tavily_over_serper(self): """Tavily should be tried before Serper when both keys are available.""" tool = WebSearchTool(tavily_api_key="t-key", serper_api_key="s-key") with patch.object(tool, "_search_tavily", return_value={"results": [], "total": 0, "backend": "tavily", "success": True}) as mock_tavily: result = await tool.execute(query="test") mock_tavily.assert_called_once() assert result["backend"] == "tavily" @pytest.mark.asyncio async def test_no_keys_uses_duckduckgo(self): """Without API keys, DuckDuckGo is used directly.""" tool = WebSearchTool() with patch.object(tool, "_search_duckduckgo", return_value={"results": [], "total": 0, "backend": "duckduckgo", "success": True}) as mock_ddg: result = await tool.execute(query="test") mock_ddg.assert_called_once() assert result["backend"] == "duckduckgo"