feat: add SearX search plugin and alert backend
Add standalone !searx command for on-demand SearXNG search (top 3 results). Add SearX as a third backend (sx) to the alert plugin for keyword monitoring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
243
tests/test_searx.py
Normal file
243
tests/test_searx.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user