"""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"