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>
244 lines
7.7 KiB
Python
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())
|