97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10 unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both were visible only because the lint made me look. `webber version` did not exist. src/cli/commands/version.py defines show_version(), main.py imported it, and the registration line was never written — the CLI exposed chat and explore only. The import carried `# noqa: F401`, which is what kept the omission quiet: someone marked the symptom as intentional instead of asking why it was unused. show_version is not redundant with the --version flag; it prints the resolved Ollama URL, model and debug state, which is the form worth having when something is misconfigured. Registered, and the suppression dropped because the import is now genuinely used. test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched get_agent, and stopped at the comment "For now, verify the explore agent would be called correctly". It had been counted as a passing test. An AST sweep of all 238 test functions found it was the only one, which is worth knowing — the problem was contained, not systemic. It is now skipped with a reason, so it reports as unfinished rather than as passing. Reducing it rather than deleting its imports was the point: tidying the imports would have made a hollow test look clean. Two findings were false positives, and both are recorded rather than silently worked around: B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is awaited at line 326 before `continue` reaches the next iteration, so neither name can be rebound while the closure is pending, and the exception path cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays true if the await ever moves. I had called it a live bug before tracing it, which is the mistake Rule 5 exists for. RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its suggested fix — annotate ClassVar — would remove the field from the model. ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per instance; verified by constructing two and confirming their lists are distinct objects. Suppressed with that evidence in the comment. Ruff cannot see the pydantic base because BaseSchema is a local subclass of BaseModel. Also moved a stray `from src.shared.logging import ...` that had drifted below a function definition, and merged a nested if in the ollama provider. 215 passed, 23 skipped, unchanged except for the new skip. `webber version` exercised end to end. mypy is NOT addressed here and the gate still fails on it — 55 errors in 14 files, 35 of them no-any-return from pydantic_ai's untyped returns. That was hidden behind ruff, because the gate stops at the first failing stage. Co-Authored-By: Claude <noreply@anthropic.com>
219 lines
8.4 KiB
Python
219 lines
8.4 KiB
Python
"""
|
|
Tests for WebSearchTool.
|
|
"""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from src.domains.tools.search.web import WebSearchTool
|
|
|
|
|
|
class TestWebSearchTool:
|
|
"""Tests for WebSearchTool."""
|
|
|
|
@pytest.fixture
|
|
def tool(self):
|
|
return WebSearchTool(searxng_url="http://searxng:8080", timeout=5)
|
|
|
|
@pytest.fixture
|
|
def mock_search_response(self):
|
|
"""Sample SearXNG response."""
|
|
return {
|
|
"query": "test query",
|
|
"number_of_results": 3,
|
|
"results": [
|
|
{
|
|
"title": "First Result",
|
|
"url": "https://example.com/1",
|
|
"content": "This is the first result content.",
|
|
"engine": "google",
|
|
"publishedDate": "2024-01-15",
|
|
},
|
|
{
|
|
"title": "Second Result",
|
|
"url": "https://example.com/2",
|
|
"content": "This is the second result content.",
|
|
"engine": "brave",
|
|
"publishedDate": None,
|
|
},
|
|
{
|
|
"title": "Third Result",
|
|
"url": "https://example.com/3",
|
|
"content": "This is the third result content.",
|
|
"engine": "duckduckgo",
|
|
"publishedDate": "2024-01-10",
|
|
},
|
|
],
|
|
}
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_success(self, tool, mock_search_response):
|
|
"""Test successful search."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = mock_search_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test query", num_results=3)
|
|
|
|
assert result.success
|
|
assert "First Result" in result.data
|
|
assert "https://example.com/1" in result.data
|
|
assert result.metadata["result_count"] == 3
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_empty_query(self, tool):
|
|
"""Test empty query is rejected."""
|
|
result = await tool.execute(query="")
|
|
assert not result.success
|
|
assert "empty" in result.error.lower()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_whitespace_query(self, tool):
|
|
"""Test whitespace-only query is rejected."""
|
|
result = await tool.execute(query=" ")
|
|
assert not result.success
|
|
assert "empty" in result.error.lower()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_no_results(self, tool):
|
|
"""Test when search returns no results."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = {"query": "obscure", "results": []}
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="obscure nonexistent thing")
|
|
|
|
assert result.success
|
|
assert "No results" in result.data
|
|
assert result.metadata["result_count"] == 0
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_num_results_limit(self, tool, mock_search_response):
|
|
"""Test num_results limits output."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = mock_search_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test", num_results=2)
|
|
|
|
assert result.success
|
|
assert result.metadata["result_count"] == 2
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_num_results_clamped(self, tool, mock_search_response):
|
|
"""Test num_results is clamped to max."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = mock_search_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
# Request 100 but max is 10
|
|
result = await tool.execute(query="test", num_results=100)
|
|
|
|
assert result.success
|
|
# Should only get 3 (what's in mock response, capped at 10)
|
|
assert result.metadata["result_count"] <= 10
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_timeout_error(self, tool):
|
|
"""Test timeout handling."""
|
|
import httpx
|
|
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.side_effect = httpx.TimeoutException("timeout")
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test")
|
|
|
|
assert not result.success
|
|
assert "timed out" in result.error.lower()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_request_error(self, tool):
|
|
"""Test network error handling."""
|
|
import httpx
|
|
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.side_effect = httpx.RequestError("connection failed")
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test")
|
|
|
|
assert not result.success
|
|
assert "failed" in result.error.lower()
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_includes_engines_metadata(self, tool, mock_search_response):
|
|
"""Test engines used are included in metadata."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = mock_search_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test", num_results=3)
|
|
|
|
assert result.success
|
|
engines = result.metadata.get("engines_used", [])
|
|
assert "google" in engines or "brave" in engines
|
|
|
|
@pytest.mark.anyio
|
|
async def test_search_with_categories(self, tool, mock_search_response):
|
|
"""Test category parameter is passed."""
|
|
with patch("httpx.AsyncClient") as mock_client:
|
|
mock_response = MagicMock()
|
|
mock_response.json.return_value = mock_search_response
|
|
mock_response.raise_for_status = MagicMock()
|
|
|
|
mock_instance = AsyncMock()
|
|
mock_instance.get.return_value = mock_response
|
|
mock_instance.__aenter__.return_value = mock_instance
|
|
mock_instance.__aexit__.return_value = None
|
|
mock_client.return_value = mock_instance
|
|
|
|
result = await tool.execute(query="test", categories="it")
|
|
|
|
assert result.success
|
|
# Verify get was called with categories parameter
|
|
call_args = mock_instance.get.call_args
|
|
assert "categories" in call_args.kwargs["params"]
|
|
assert call_args.kwargs["params"]["categories"] == "it"
|