diff --git a/tests/unit/test_research_tools.py b/tests/unit/test_research_tools.py new file mode 100644 index 00000000..71ac879a --- /dev/null +++ b/tests/unit/test_research_tools.py @@ -0,0 +1,149 @@ +"""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")