feat: make SearXNG URL env-configurable and support basic auth

Squash merge of SearXNG URL env configuration and basic auth support.
This commit was merged in pull request #7.
This commit is contained in:
2026-06-30 22:48:22 +02:00
parent fd85758e3c
commit 359a8425d5
3 changed files with 59 additions and 55 deletions
+27 -34
View File
@@ -191,6 +191,21 @@ class TestSearchSearxng:
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
@@ -228,7 +243,8 @@ class TestWebSearch:
self, _mock_sleep: MagicMock, tools: ResearchTools
) -> None:
mock_cls = MagicMock(return_value=self._mock_ddgs(self._make_ddgs_result()))
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
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
@@ -238,14 +254,16 @@ class TestWebSearch:
self, _mock_sleep: MagicMock, tools: ResearchTools
) -> None:
mock_cls = MagicMock(return_value=self._mock_ddgs([]))
with patch.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
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.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
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()
@@ -255,7 +273,8 @@ class TestWebSearch:
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.dict("sys.modules", {"duckduckgo_search": MagicMock(DDGS=mock_cls)}):
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
@@ -271,40 +290,14 @@ class TestWebSearch:
ddgs_mock.__exit__ = MagicMock(return_value=False)
ddgs_mock.text.side_effect = rate_exc
mock_module = MagicMock()
mock_module.DDGS = MagicMock(return_value=ddgs_mock)
# Make RatelimitException match our rate_exc type
mock_module.exceptions.RatelimitException = type(rate_exc)
mock_module.exceptions.DuckDuckGoSearchException = ValueError
with patch.dict("sys.modules", {
"duckduckgo_search": mock_module,
"duckduckgo_search.exceptions": mock_module.exceptions,
}):
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
def test_fallback_when_ddgs_not_installed(self, tools: ResearchTools) -> None:
"""When duckduckgo-search is not installed, uses httpx HTML fallback."""
import builtins
real_import = builtins.__import__
def mock_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if name == "duckduckgo_search":
raise ImportError("mocked missing")
return real_import(name, *args, **kwargs)
mock_response = MagicMock()
mock_response.text = ""
mock_response.raise_for_status = MagicMock()
with patch("builtins.__import__", side_effect=mock_import):
with patch("gitea.tools.research_tools.httpx.Client") as mock_cls:
mock_cls.return_value.__enter__.return_value.get.return_value = mock_response
result = tools.web_search("test")
assert isinstance(result, str)
# ------------------------------------------------------------------ #