173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""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 = """
|
|
<html><body>
|
|
<a class="result-link" href="https://example.com/result1">Result 1 Title</a>
|
|
<td class="result-snippet">Snippet for result 1</td>
|
|
<a class="result-link" href="https://example.com/result2">Result 2 Title</a>
|
|
<td class="result-snippet">Snippet for result 2</td>
|
|
</body></html>
|
|
"""
|
|
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("<html></html>", 5)
|
|
assert results == []
|
|
|
|
def test_parse_html_skips_duckduckgo_links(self):
|
|
html = """
|
|
<a class="result-link" href="https://duckduckgo.com/internal">Internal</a>
|
|
<a class="result-link" href="https://example.com/good">Good Result</a>
|
|
<td class="result-snippet">Good snippet</td>
|
|
"""
|
|
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'<a class="result-link" href="https://example.com/{i}">Title {i}</a>\n'
|
|
html += f'<td class="result-snippet">Snippet {i}</td>\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"
|