Fixes #1237 - Tests for google_web_search: no-key fallback, warning log, correct params passed to GoogleSearch, string return type - Tests for get_llm_client: client existence, completion method, response has .text attribute, prompt echoed in response serpapi stubbed at module level since it is not installed.
150 lines
5.2 KiB
Python
150 lines
5.2 KiB
Python
"""Unit tests for src/timmy/research_tools.py.
|
|
|
|
Refs #1237
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from types import ModuleType
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
# ── Stub serpapi before any import of research_tools ─────────────────────────
|
|
|
|
_serpapi_stub = ModuleType("serpapi")
|
|
_google_search_mock = MagicMock()
|
|
_serpapi_stub.GoogleSearch = _google_search_mock
|
|
sys.modules.setdefault("serpapi", _serpapi_stub)
|
|
|
|
|
|
# ── google_web_search ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGoogleWebSearch:
|
|
"""google_web_search returns results or degrades gracefully."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_empty_string_when_no_api_key(self, monkeypatch):
|
|
monkeypatch.delenv("SERPAPI_API_KEY", raising=False)
|
|
from timmy.research_tools import google_web_search
|
|
|
|
result = await google_web_search("test query")
|
|
assert result == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_logs_warning_when_no_api_key(self, monkeypatch, caplog):
|
|
import logging
|
|
|
|
monkeypatch.delenv("SERPAPI_API_KEY", raising=False)
|
|
from timmy.research_tools import google_web_search
|
|
|
|
with caplog.at_level(logging.WARNING, logger="timmy.research_tools"):
|
|
await google_web_search("test query")
|
|
|
|
assert any("SERPAPI_API_KEY" in rec.message for rec in caplog.records)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_calls_google_search_with_api_key(self, monkeypatch):
|
|
monkeypatch.setenv("SERPAPI_API_KEY", "fake-key-123")
|
|
|
|
mock_instance = MagicMock()
|
|
mock_instance.get_dict.return_value = {"organic_results": [{"title": "Result"}]}
|
|
|
|
with patch("timmy.research_tools.GoogleSearch", return_value=mock_instance) as mock_cls:
|
|
from timmy.research_tools import google_web_search
|
|
|
|
result = await google_web_search("hello world")
|
|
|
|
mock_cls.assert_called_once()
|
|
call_params = mock_cls.call_args[0][0]
|
|
assert call_params["q"] == "hello world"
|
|
assert call_params["api_key"] == "fake-key-123"
|
|
mock_instance.get_dict.assert_called_once()
|
|
assert "organic_results" in result
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_string_result(self, monkeypatch):
|
|
monkeypatch.setenv("SERPAPI_API_KEY", "key")
|
|
|
|
mock_instance = MagicMock()
|
|
mock_instance.get_dict.return_value = {"answer": 42}
|
|
|
|
with patch("timmy.research_tools.GoogleSearch", return_value=mock_instance):
|
|
from timmy.research_tools import google_web_search
|
|
|
|
result = await google_web_search("query")
|
|
|
|
assert isinstance(result, str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passes_query_to_params(self, monkeypatch):
|
|
monkeypatch.setenv("SERPAPI_API_KEY", "k")
|
|
|
|
mock_instance = MagicMock()
|
|
mock_instance.get_dict.return_value = {}
|
|
|
|
with patch("timmy.research_tools.GoogleSearch", return_value=mock_instance) as mock_cls:
|
|
from timmy.research_tools import google_web_search
|
|
|
|
await google_web_search("specific search term")
|
|
|
|
params = mock_cls.call_args[0][0]
|
|
assert params["q"] == "specific search term"
|
|
|
|
|
|
# ── get_llm_client ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestGetLLMClient:
|
|
"""get_llm_client returns a client with a completion method."""
|
|
|
|
def test_returns_non_none_client(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client = get_llm_client()
|
|
assert client is not None
|
|
|
|
def test_client_has_completion_method(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client = get_llm_client()
|
|
assert hasattr(client, "completion")
|
|
assert callable(client.completion)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completion_returns_object_with_text(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client = get_llm_client()
|
|
result = await client.completion("test prompt", max_tokens=100)
|
|
assert hasattr(result, "text")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completion_text_is_string(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client = get_llm_client()
|
|
result = await client.completion("any prompt", max_tokens=50)
|
|
assert isinstance(result.text, str)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_completion_text_contains_prompt(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client = get_llm_client()
|
|
result = await client.completion("my prompt", max_tokens=50)
|
|
assert "my prompt" in result.text
|
|
|
|
def test_each_call_returns_new_client(self):
|
|
from timmy.research_tools import get_llm_client
|
|
|
|
client_a = get_llm_client()
|
|
client_b = get_llm_client()
|
|
# Both should be functional clients (not necessarily the same instance)
|
|
assert hasattr(client_a, "completion")
|
|
assert hasattr(client_b, "completion")
|