"""Tests for ResearchTools: web_search and fetch_url.""" import json import httpx from unittest.mock import MagicMock, patch, PropertyMock import pytest from gitea.tools.research_tools import ResearchTools, _MAX_CONTENT_CHARS @pytest.fixture def tools() -> ResearchTools: return ResearchTools() # ------------------------------------------------------------------ # # _smart_truncate # ------------------------------------------------------------------ # class TestSmartTruncate: def test_no_truncation_if_short(self, tools: ResearchTools) -> None: text = "hello world" assert tools._smart_truncate(text, max_chars=100) == text def test_cuts_at_paragraph_boundary(self, tools: ResearchTools) -> None: # Two paragraphs; the boundary falls after the 70% mark of max_chars para1 = "A" * 80 para2 = "B" * 80 text = para1 + "\n\n" + para2 result = tools._smart_truncate(text, max_chars=100) # Should cut at the \n\n, not mid-word assert "truncated" in result assert result.startswith(para1) def test_hard_cut_when_no_good_boundary(self, tools: ResearchTools) -> None: # Single block — no paragraph boundary available text = "x" * 200 result = tools._smart_truncate(text, max_chars=100) assert "truncated" in result assert result.startswith("x" * 100) def test_exact_length_not_truncated(self, tools: ResearchTools) -> None: text = "a" * 100 assert tools._smart_truncate(text, max_chars=100) == text # ------------------------------------------------------------------ # # _html_to_markdown — regex fallback (no optional deps needed) # ------------------------------------------------------------------ # class TestHtmlToMarkdown: def _patch_imports(self, tools: ResearchTools) -> str: """Return result when optional deps are unavailable.""" # Force all optional imports to fail → regex fallback import builtins real_import = builtins.__import__ def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] if name in ("trafilatura", "readability", "markdownify"): raise ImportError(f"mocked missing: {name}") return real_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): return tools._html_to_markdown("

Hello world

") def test_regex_fallback_removes_tags(self, tools: ResearchTools) -> None: result = self._patch_imports(tools) assert "Hello" in result assert "world" in result assert "

" not in result assert "" not in result def test_regex_fallback_removes_script(self, tools: ResearchTools) -> None: import builtins real_import = builtins.__import__ def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] if name in ("trafilatura", "readability", "markdownify"): raise ImportError return real_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): result = tools._html_to_markdown("visible") assert "evil" not in result assert "visible" in result def test_regex_fallback_decodes_entities(self, tools: ResearchTools) -> None: import builtins real_import = builtins.__import__ def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] if name in ("trafilatura", "readability", "markdownify"): raise ImportError return real_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): result = tools._html_to_markdown("Tom & Jerry <3>") assert "Tom & Jerry <3>" in result # ------------------------------------------------------------------ # # _format_results # ------------------------------------------------------------------ # class TestFormatResults: def test_numbered_list(self, tools: ResearchTools) -> None: results = [ {"title": "Title A", "href": "https://a.com", "body": "Snippet A"}, {"title": "Title B", "href": "https://b.com", "body": "Snippet B"}, ] output = tools._format_results(results, "test query") assert "[1]" in output assert "[2]" in output assert "https://a.com" in output assert "Snippet A" in output def test_includes_date_when_present(self, tools: ResearchTools) -> None: results = [{"title": "T", "href": "https://x.com", "body": "S", "published_date": "2024-01"}] output = tools._format_results(results, "q") assert "Date: 2024-01" in output def test_no_date_field_when_absent(self, tools: ResearchTools) -> None: results = [{"title": "T", "href": "https://x.com", "body": "S"}] output = tools._format_results(results, "q") assert "Date:" not in output def test_snippet_truncated_to_250_chars(self, tools: ResearchTools) -> None: long_body = "x" * 500 results = [{"title": "T", "href": "u", "body": long_body}] output = tools._format_results(results, "q") assert "x" * 251 not in output # body was truncated before formatting # ------------------------------------------------------------------ # # _search_searxng # ------------------------------------------------------------------ # class TestSearchSearxng: @pytest.fixture(autouse=True) def mock_searxng_url(self) -> "Generator[None, None, None]": from typing import Generator with patch("gitea.tools.research_tools._SEARXNG_URL", "http://localhost"): yield def _make_searxng_response(self, results: list[dict]) -> MagicMock: mock_resp = MagicMock() mock_resp.json.return_value = {"results": results} mock_resp.raise_for_status = MagicMock() return mock_resp @patch("gitea.tools.research_tools.httpx.Client") def test_returns_formatted_results_on_success( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: results = [ {"title": "SearXNG Result", "url": "https://example.com", "content": "snippet"}, ] mock_cls.return_value.__enter__.return_value.get.return_value = ( self._make_searxng_response(results) ) output = tools._search_searxng("test query", num_results=5) assert output is not None assert "[1]" in output assert "example.com" in output @patch("gitea.tools.research_tools.httpx.Client") def test_returns_none_when_empty_results( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_cls.return_value.__enter__.return_value.get.return_value = ( self._make_searxng_response([]) ) assert tools._search_searxng("nothing", num_results=5) is None @patch("gitea.tools.research_tools.httpx.Client") def test_returns_none_on_connection_error( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_cls.return_value.__enter__.return_value.get.side_effect = ( httpx.ConnectError("refused") ) assert tools._search_searxng("query", num_results=5) is None @patch("gitea.tools.research_tools.httpx.Client") def test_respects_time_range_param( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_cls.return_value.__enter__.return_value.get.return_value = ( self._make_searxng_response([]) ) tools._search_searxng("q", num_results=5, time_range="month") call_kwargs = mock_cls.return_value.__enter__.return_value.get.call_args params = call_kwargs[1].get("params", call_kwargs[0][1] if len(call_kwargs[0]) > 1 else {}) assert params.get("time_range") == "month" @patch("gitea.tools.research_tools.httpx.Client") def test_passes_basic_auth_if_configured( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_cls.return_value.__enter__.return_value.get.return_value = ( self._make_searxng_response([]) ) with patch("gitea.tools.research_tools._SEARXNG_USERNAME", "user"), \ patch("gitea.tools.research_tools._SEARXNG_PASSWORD", "pass"): tools._search_searxng("q", num_results=5) mock_cls.assert_called_once() kwargs = mock_cls.call_args[1] assert kwargs.get("auth") == ("user", "pass") @patch("gitea.tools.research_tools.httpx.Client") def test_web_search_uses_searxng_first( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: """web_search should return SearXNG results without touching DDGS.""" results = [{"title": "From SearXNG", "url": "https://sx.com", "content": "content"}] mock_cls.return_value.__enter__.return_value.get.return_value = ( self._make_searxng_response(results) ) output = tools.web_search("python typing") assert "From SearXNG" in output or "[1]" in output # ------------------------------------------------------------------ # # web_search — with DDGS mocked # ------------------------------------------------------------------ # class TestWebSearch: def _make_ddgs_result(self) -> list[dict[str, str]]: return [ {"title": "Foo Docs", "href": "https://foo.com/docs", "body": "Learn about Foo."}, {"title": "Bar Guide", "href": "https://bar.com", "body": "A guide to Bar."}, ] def _mock_ddgs(self, results: list[dict[str, str]]) -> MagicMock: mock_ddgs_instance = MagicMock() mock_ddgs_instance.__enter__ = MagicMock(return_value=mock_ddgs_instance) mock_ddgs_instance.__exit__ = MagicMock(return_value=False) mock_ddgs_instance.text.return_value = iter(results) return mock_ddgs_instance @patch("gitea.tools.research_tools.time.sleep") def test_returns_numbered_results( self, _mock_sleep: MagicMock, tools: ResearchTools ) -> None: mock_cls = MagicMock(return_value=self._mock_ddgs(self._make_ddgs_result())) with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \ patch("gitea.tools.research_tools.DDGS", mock_cls): result = tools.web_search("python httpx") assert "[1]" in result assert "foo.com" in result @patch("gitea.tools.research_tools.time.sleep") def test_no_results_returns_helpful_message( self, _mock_sleep: MagicMock, tools: ResearchTools ) -> None: mock_cls = MagicMock(return_value=self._mock_ddgs([])) with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \ patch("gitea.tools.research_tools.DDGS", mock_cls): result = tools.web_search("xyzzy-not-real") assert "No results" in result def test_clamps_num_results_max(self, tools: ResearchTools) -> None: ddgs_mock = self._mock_ddgs(self._make_ddgs_result()) mock_cls = MagicMock(return_value=ddgs_mock) with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \ patch("gitea.tools.research_tools.DDGS", mock_cls): tools.web_search("q", num_results=999) # DDGS.text should be called with max_results clamped to 20 ddgs_mock.text.assert_called_once() _, kwargs = ddgs_mock.text.call_args assert kwargs.get("max_results", 0) <= 20 def test_clamps_num_results_min(self, tools: ResearchTools) -> None: ddgs_mock = self._mock_ddgs(self._make_ddgs_result()) mock_cls = MagicMock(return_value=ddgs_mock) with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \ patch("gitea.tools.research_tools.DDGS", mock_cls): tools.web_search("q", num_results=0) _, kwargs = ddgs_mock.text.call_args assert kwargs.get("max_results", 0) >= 1 @patch("gitea.tools.research_tools.time.sleep") def test_retries_on_rate_limit( self, mock_sleep: MagicMock, tools: ResearchTools ) -> None: """Should retry up to 3 times with exponential backoff on RatelimitException.""" rate_exc = Exception("rate limit") ddgs_mock = MagicMock() ddgs_mock.__enter__ = MagicMock(return_value=ddgs_mock) ddgs_mock.__exit__ = MagicMock(return_value=False) ddgs_mock.text.side_effect = rate_exc with patch("gitea.tools.research_tools.ResearchTools._search_searxng", return_value=None), \ patch("gitea.tools.research_tools.DDGS", return_value=ddgs_mock), \ patch("gitea.tools.research_tools.RatelimitException", type(rate_exc)), \ patch("gitea.tools.research_tools.DuckDuckGoSearchException", ValueError): result = tools.web_search("q") assert isinstance(result, str) # Returns error string, not raise # ------------------------------------------------------------------ # # fetch_url # ------------------------------------------------------------------ # class TestFetchUrl: def _make_response( self, text: str, content_type: str = "text/html; charset=utf-8", status_code: int = 200, ) -> MagicMock: mock_resp = MagicMock() mock_resp.text = text mock_resp.headers = {"content-type": content_type} mock_resp.status_code = status_code mock_resp.raise_for_status = MagicMock() return mock_resp def test_rejects_non_http_url(self, tools: ResearchTools) -> None: result = tools.fetch_url("ftp://example.com/file") assert "Invalid URL" in result def test_rejects_no_scheme(self, tools: ResearchTools) -> None: result = tools.fetch_url("example.com") assert "Invalid URL" in result @patch("gitea.tools.research_tools.httpx.Client") def test_pretty_prints_json_response( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: data = {"key": "value", "n": 42} mock_resp = self._make_response( json.dumps(data), content_type="application/json" ) mock_resp.json = MagicMock(return_value=data) mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://api.example.com/data") assert '"key": "value"' in result @patch("gitea.tools.research_tools.httpx.Client") def test_returns_plain_text_as_is( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_resp = self._make_response("plain text content", content_type="text/plain") mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://example.com/readme.txt") assert "plain text content" in result @patch("gitea.tools.research_tools.httpx.Client") def test_returns_markdown_file_as_is( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_resp = self._make_response("# Heading\nContent", content_type="text/plain") mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://example.com/README.md") assert "# Heading" in result @patch("gitea.tools.research_tools.httpx.Client") def test_raw_html_when_extract_false( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: html = "

Content

" mock_resp = self._make_response(html) mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://example.com", extract_text=False) assert "

" in result @patch("gitea.tools.research_tools.httpx.Client") def test_truncates_at_max_chars( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_resp = self._make_response("word " * 10000, content_type="text/plain") mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://example.com/big", max_chars=100) assert "truncated" in result @patch("gitea.tools.research_tools.httpx.Client") def test_handles_http_404( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: import httpx as _httpx mock_response = MagicMock() mock_response.status_code = 404 mock_cls.return_value.__enter__.return_value.get.side_effect = ( _httpx.HTTPStatusError("not found", request=MagicMock(), response=mock_response) ) result = tools.fetch_url("https://example.com/missing") assert "404" in result or "Failed" in result @patch("gitea.tools.research_tools.httpx.Client") def test_handles_timeout( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: import httpx as _httpx mock_cls.return_value.__enter__.return_value.get.side_effect = ( _httpx.TimeoutException("timed out") ) result = tools.fetch_url("https://slow.example.com") assert "timed out" in result.lower() or "timeout" in result.lower() @patch("gitea.tools.research_tools.httpx.Client") def test_html_extraction_called_for_html_content( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: html = "

Hello world from main content

" mock_resp = self._make_response(html) mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp with patch.object(tools, "_html_to_markdown", return_value="Hello world") as mock_extract: result = tools.fetch_url("https://example.com") mock_extract.assert_called_once_with(html) assert "Hello world" in result @patch("gitea.tools.research_tools.httpx.Client") def test_default_max_chars_is_20k( self, mock_cls: MagicMock, tools: ResearchTools ) -> None: mock_resp = self._make_response("a" * 30000, content_type="text/plain") mock_cls.return_value.__enter__.return_value.get.return_value = mock_resp result = tools.fetch_url("https://example.com/long") assert "truncated" in result # Content before truncation note should be ~20k chars content_before = result.split("[Content truncated")[0] # Allow a small buffer for the trailing \n\n appended before the truncation note assert len(content_before) <= _MAX_CONTENT_CHARS + 4