From d9bc7e8c2d80829afd66dff968ea0e835ca83703 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 19:05:41 -0400 Subject: [PATCH] =?UTF-8?q?test:=20add=20unit=20tests=20for=20research=5Ft?= =?UTF-8?q?ools.py=20(0%=20=E2=86=92=20covered)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/unit/test_research_tools.py | 149 ++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/unit/test_research_tools.py 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") -- 2.43.0