Files
derp/tests/test_searx.py
user b973635445 fix: route SearXNG direct via static route, drop proxy
SearXNG instance at 192.168.122.119 is reachable via grokbox
static route -- no need to tunnel through SOCKS5. Reverts searx
and alert plugins to stdlib urlopen for SearXNG queries. YouTube
and Twitch in alert.py still use the proxy. Also removes cprofile
flag from docker-compose command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:52:43 +01:00

244 lines
7.7 KiB
Python

"""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())