"""Tests for the SearXNG search plugin.""" import asyncio import importlib.util import json import sys from pathlib import Path from unittest.mock import patch from derp.irc import Message # plugins/ is not a Python package -- load the module from file path _spec = importlib.util.spec_from_file_location( "plugins.searx", Path(__file__).resolve().parent.parent / "plugins" / "searx.py", ) _mod = importlib.util.module_from_spec(_spec) sys.modules[_spec.name] = _mod _spec.loader.exec_module(_mod) from plugins.searx import ( # noqa: E402 _MAX_QUERY_LEN, _MAX_RESULTS, _search, _truncate, cmd_searx, ) # -- Fixtures ---------------------------------------------------------------- SEARX_RESPONSE = { "results": [ {"title": "Result One", "url": "https://example.com/1", "content": "First snippet"}, {"title": "Result Two", "url": "https://example.com/2", "content": "Second snippet"}, {"title": "Result Three", "url": "https://example.com/3", "content": "Third snippet"}, {"title": "Result Four", "url": "https://example.com/4", "content": "Fourth snippet"}, {"title": "Result Five", "url": "https://example.com/5", "content": "Fifth snippet"}, ], } SEARX_EMPTY = {"results": []} # -- Helpers ----------------------------------------------------------------- class _FakeBot: """Minimal bot stand-in that captures sent/replied messages.""" def __init__(self): self.sent: list[tuple[str, str]] = [] self.replied: list[str] = [] async def send(self, target: str, text: str) -> None: self.sent.append((target, text)) async def reply(self, message, text: str) -> None: self.replied.append(text) def _msg(text: str, nick: str = "alice", target: str = "#test") -> Message: """Create a channel PRIVMSG.""" return Message( raw="", prefix=f"{nick}!~{nick}@host", nick=nick, command="PRIVMSG", params=[target, text], tags={}, ) def _pm(text: str, nick: str = "alice") -> Message: """Create a private PRIVMSG.""" return Message( raw="", prefix=f"{nick}!~{nick}@host", nick=nick, command="PRIVMSG", params=["botname", text], tags={}, ) class _FakeResp: """Fake HTTP response returning preset JSON.""" def __init__(self, data): self._data = data def read(self): return json.dumps(self._data).encode() def close(self): pass # --------------------------------------------------------------------------- # TestTruncate # --------------------------------------------------------------------------- class TestTruncate: def test_short_text_unchanged(self): assert _truncate("hello", 80) == "hello" def test_exact_length_unchanged(self): text = "a" * 80 assert _truncate(text, 80) == text def test_long_text_truncated(self): text = "a" * 100 result = _truncate(text, 80) assert len(result) == 80 assert result.endswith("...") def test_default_max_length(self): text = "a" * 100 result = _truncate(text) assert len(result) == 80 def test_trailing_space_stripped(self): text = "word " * 20 result = _truncate(text, 20) assert not result.endswith(" ...") # --------------------------------------------------------------------------- # TestSearch # --------------------------------------------------------------------------- class TestSearch: def test_success(self): with patch("urllib.request.urlopen", return_value=_FakeResp(SEARX_RESPONSE)): results = _search("test query") assert len(results) == 5 assert results[0]["title"] == "Result One" assert results[0]["url"] == "https://example.com/1" assert results[0]["snippet"] == "First snippet" def test_empty_results(self): with patch("urllib.request.urlopen", return_value=_FakeResp(SEARX_EMPTY)): results = _search("nothing") assert results == [] def test_http_error_propagates(self): import pytest with patch("urllib.request.urlopen", side_effect=ConnectionError("down")): with pytest.raises(ConnectionError): _search("test") def test_snippet_fallback(self): """Falls back to 'snippet' key when 'content' is absent.""" data = {"results": [ {"title": "T", "url": "http://x.com", "snippet": "fallback"}, ]} with patch("urllib.request.urlopen", return_value=_FakeResp(data)): results = _search("test") assert results[0]["snippet"] == "fallback" # --------------------------------------------------------------------------- # TestCmdSearx # --------------------------------------------------------------------------- class TestCmdSearx: def test_results_displayed(self): bot = _FakeBot() async def inner(): with patch("urllib.request.urlopen", return_value=_FakeResp(SEARX_RESPONSE)): await cmd_searx(bot, _msg("!searx test query")) assert len(bot.replied) == _MAX_RESULTS assert "Result One" in bot.replied[0] assert "https://example.com/1" in bot.replied[0] assert " -- " in bot.replied[0] asyncio.run(inner()) def test_no_results(self): bot = _FakeBot() async def inner(): with patch("urllib.request.urlopen", return_value=_FakeResp(SEARX_EMPTY)): await cmd_searx(bot, _msg("!searx nothing")) assert len(bot.replied) == 1 assert "No results for:" in bot.replied[0] asyncio.run(inner()) def test_error_handling(self): bot = _FakeBot() async def inner(): with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")): await cmd_searx(bot, _msg("!searx broken")) assert len(bot.replied) == 1 assert "Search failed:" in bot.replied[0] asyncio.run(inner()) def test_pm_rejected(self): bot = _FakeBot() asyncio.run(cmd_searx(bot, _pm("!searx test"))) assert "Use this command in a channel" in bot.replied[0] def test_no_query(self): bot = _FakeBot() asyncio.run(cmd_searx(bot, _msg("!searx"))) assert "Usage:" in bot.replied[0] def test_empty_query(self): bot = _FakeBot() asyncio.run(cmd_searx(bot, _msg("!searx "))) assert "Usage:" in bot.replied[0] def test_query_too_long(self): bot = _FakeBot() long_query = "x" * (_MAX_QUERY_LEN + 1) asyncio.run(cmd_searx(bot, _msg(f"!searx {long_query}"))) assert "too long" in bot.replied[0] def test_title_truncation(self): """Long titles are truncated to _MAX_TITLE_LEN.""" long_title = "A" * 100 data = {"results": [ {"title": long_title, "url": "http://x.com", "content": "s"}, ]} bot = _FakeBot() async def inner(): with patch("urllib.request.urlopen", return_value=_FakeResp(data)): await cmd_searx(bot, _msg("!searx test")) assert len(bot.replied) == 1 title_part = bot.replied[0].split(" -- ")[0] assert len(title_part) <= 80 assert title_part.endswith("...") asyncio.run(inner()) def test_no_title_fallback(self): """Empty title shows '(no title)'.""" data = {"results": [ {"title": "", "url": "http://x.com", "content": "s"}, ]} bot = _FakeBot() async def inner(): with patch("urllib.request.urlopen", return_value=_FakeResp(data)): await cmd_searx(bot, _msg("!searx test")) assert "(no title)" in bot.replied[0] asyncio.run(inner())