hermes-agent/tests/plugins/test_backsearch_plugin.py

368 lines
13 KiB
Python

"""Tests for the bundled BackSearch plugin (point-in-time web search/fetch).
Real imports from the plugin module — no mocking of the handlers
themselves. HTTP is stubbed at the httpx layer; no live network calls.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from plugins.backsearch.tools import (
BACKFETCH_SCHEMA,
BACKSEARCH_SCHEMA,
DEFAULT_BASE_URL,
_validate_as_of,
check_backsearch_available,
handle_backfetch,
handle_backsearch,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mock_response(status_code: int = 200, payload: dict | None = None):
resp = MagicMock()
resp.status_code = status_code
resp.json.return_value = payload or {}
if status_code >= 400:
import httpx
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
f"HTTP {status_code}", request=MagicMock(), response=resp
)
else:
resp.raise_for_status.return_value = None
return resp
SEARCH_HIT = {
"url": "https://www.example.com/article",
"title": "UAE Central Bank cuts interest rates",
"snippet": "The UAE Central Bank on Wednesday lowered...",
"crawl_date": "2025-12-10T20:24:28Z",
"publish_date": "2025-12-10T00:00:00Z",
"host": "www.example.com",
}
@pytest.fixture(autouse=True)
def _key_set(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("OPENREWARD_API_KEY", "or_test_key")
monkeypatch.delenv("OPENREWARD_SEARCH_URL", raising=False)
# ---------------------------------------------------------------------------
# Availability gating
# ---------------------------------------------------------------------------
class TestAvailability:
def test_available_with_key(self):
assert check_backsearch_available() is True
def test_unavailable_without_key(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.delenv("OPENREWARD_API_KEY", raising=False)
# get_env_value may read ~/.hermes/.env; force the plain-os path
with patch(
"plugins.backsearch.tools._get_env", return_value=""
):
assert check_backsearch_available() is False
def test_search_without_key_returns_actionable_error(
self, monkeypatch: pytest.MonkeyPatch
):
with patch("plugins.backsearch.tools._get_env", return_value=""):
out = json.loads(
handle_backsearch({"query": "rates", "as_of": "2026-01-15"})
)
assert "OPENREWARD_API_KEY" in out["error"]
# ---------------------------------------------------------------------------
# as_of validation
# ---------------------------------------------------------------------------
class TestAsOfValidation:
def test_valid(self):
assert _validate_as_of("2026-01-15") == "2026-01-15"
@pytest.mark.parametrize("bad", ["", None, "01/15/2026", "2026-1-5", "jan 15"])
def test_invalid(self, bad):
with pytest.raises(ValueError):
_validate_as_of(bad)
def test_handler_surfaces_as_of_error(self):
out = json.loads(handle_backsearch({"query": "x", "as_of": "not-a-date"}))
assert "as_of" in out["error"]
def test_missing_query(self):
out = json.loads(handle_backsearch({"as_of": "2026-01-15"}))
assert "query" in out["error"]
# ---------------------------------------------------------------------------
# Search behaviour
# ---------------------------------------------------------------------------
class TestSearch:
def test_search_success_shape(self):
with patch(
"httpx.post",
return_value=_mock_response(200, {"mode": "hybrid", "hits": [SEARCH_HIT]}),
) as post:
out = json.loads(
handle_backsearch({"query": "central bank", "as_of": "2026-01-15"})
)
assert out["success"] is True
assert out["as_of"] == "2026-01-15"
hit = out["hits"][0]
assert hit["url"] == SEARCH_HIT["url"]
assert hit["crawl_date"] == SEARCH_HIT["crawl_date"]
assert hit["publish_date"] == SEARCH_HIT["publish_date"]
# request went to the right endpoint with the auth header
url = post.call_args.args[0] if post.call_args.args else post.call_args.kwargs["url"]
assert url == f"{DEFAULT_BASE_URL}/search"
assert post.call_args.kwargs["headers"]["x-api-key"] == "or_test_key"
body = post.call_args.kwargs["json"]
assert body["as_of"] == "2026-01-15"
assert body["query"] == "central bank"
def test_empty_hits_appends_archive_window_hint(self):
with patch("httpx.post", return_value=_mock_response(200, {"hits": []})):
out = json.loads(
handle_backsearch({"query": "anything", "as_of": "2026-01-15"})
)
assert out["success"] is True
assert out["hits"] == []
assert "preview archive" in out["note"]
def test_allowed_and_blocked_domains_mutually_exclusive(self):
out = json.loads(
handle_backsearch(
{
"query": "x",
"as_of": "2026-01-15",
"allowed_domains": ["a.com"],
"blocked_domains": ["b.com"],
}
)
)
assert "never both" in out["error"]
def test_k_is_clamped(self):
with patch(
"httpx.post", return_value=_mock_response(200, {"hits": []})
) as post:
handle_backsearch({"query": "x", "as_of": "2026-01-15", "k": 500})
assert post.call_args.kwargs["json"]["k"] == 20
def test_domain_list_accepts_comma_string(self):
with patch(
"httpx.post", return_value=_mock_response(200, {"hits": []})
) as post:
handle_backsearch(
{
"query": "x",
"as_of": "2026-01-15",
"allowed_domains": "a.com, b.com",
}
)
assert post.call_args.kwargs["json"]["allowed_domains"] == ["a.com", "b.com"]
def test_402_returns_balance_error(self):
with patch("httpx.post", return_value=_mock_response(402)):
out = json.loads(
handle_backsearch({"query": "x", "as_of": "2026-01-15"})
)
assert "balance" in out["error"].lower()
def test_401_returns_key_error(self):
with patch("httpx.post", return_value=_mock_response(401)):
out = json.loads(
handle_backsearch({"query": "x", "as_of": "2026-01-15"})
)
assert "OPENREWARD_API_KEY" in out["error"]
def test_network_error_is_soft(self):
with patch("httpx.post", side_effect=OSError("boom")):
out = json.loads(
handle_backsearch({"query": "x", "as_of": "2026-01-15"})
)
assert "failed" in out["error"].lower()
# ---------------------------------------------------------------------------
# Fetch behaviour
# ---------------------------------------------------------------------------
class TestFetch:
def test_fetch_success(self):
with patch(
"httpx.post",
return_value=_mock_response(
200,
{
"text": "The article body.",
"title": "Headline",
"crawl_date": "2025-12-10T20:24:28Z",
},
),
) as post:
out = json.loads(
handle_backfetch(
{"url": "https://example.com/a", "as_of": "2026-01-15"}
)
)
assert out["success"] is True
assert out["text"] == "The article body."
assert out["title"] == "Headline"
url = post.call_args.args[0] if post.call_args.args else post.call_args.kwargs["url"]
assert url == f"{DEFAULT_BASE_URL}/fetch"
def test_fetch_404_no_capture_is_soft_error(self):
with patch("httpx.post", return_value=_mock_response(404)):
out = json.loads(
handle_backfetch(
{"url": "https://example.com/a", "as_of": "2026-01-15"}
)
)
assert "capture" in out["error"].lower()
def test_fetch_missing_url(self):
out = json.loads(handle_backfetch({"as_of": "2026-01-15"}))
assert "url" in out["error"]
def test_fetch_prompt_sets_summarize(self):
with patch(
"httpx.post", return_value=_mock_response(200, {"text": "summary"})
) as post:
handle_backfetch(
{
"url": "https://example.com/a",
"as_of": "2026-01-15",
"prompt": "what rate?",
}
)
body = post.call_args.kwargs["json"]
assert body["prompt"] == "what rate?"
assert body["summarize"] is True
def test_fetch_no_prompt_omits_summarize(self):
with patch(
"httpx.post", return_value=_mock_response(200, {"text": "t"})
) as post:
handle_backfetch(
{"url": "https://example.com/a", "as_of": "2026-01-15"}
)
body = post.call_args.kwargs["json"]
assert "summarize" not in body
assert "prompt" not in body
def test_fetch_long_text_truncated_with_note(self):
from plugins.backsearch.tools import _FETCH_TEXT_CAP
with patch(
"httpx.post",
return_value=_mock_response(200, {"text": "x" * (_FETCH_TEXT_CAP + 100)}),
):
out = json.loads(
handle_backfetch(
{"url": "https://example.com/a", "as_of": "2026-01-15"}
)
)
assert out["truncated"] is True
assert len(out["text"]) == _FETCH_TEXT_CAP
def test_base_url_override(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("OPENREWARD_SEARCH_URL", "http://localhost:9999/")
with patch(
"httpx.post", return_value=_mock_response(200, {"text": "t"})
) as post:
handle_backfetch(
{"url": "https://example.com/a", "as_of": "2026-01-15"}
)
url = post.call_args.args[0] if post.call_args.args else post.call_args.kwargs["url"]
assert url == "http://localhost:9999/fetch"
# ---------------------------------------------------------------------------
# Registration + toolset wiring (behavioural contracts, not snapshots)
# ---------------------------------------------------------------------------
class TestRegistration:
def test_plugin_registers_both_tools(self):
import plugins.backsearch as plugin_mod
registered = []
class _Ctx:
def register_tool(self, **kwargs):
registered.append(kwargs)
plugin_mod.register(_Ctx())
names = {r["name"] for r in registered}
assert names == {"backsearch", "backfetch"}
for r in registered:
assert r["toolset"] == "backsearch"
assert r["check_fn"] is check_backsearch_available
assert r["requires_env"] == ["OPENREWARD_API_KEY"]
assert callable(r["handler"])
def test_toolset_defined_with_plugin_tools(self):
from toolsets import TOOLSETS
ts = TOOLSETS["backsearch"]
assert set(ts["tools"]) == {"backsearch", "backfetch"}
def test_schemas_declare_required_params(self):
assert set(BACKSEARCH_SCHEMA["parameters"]["required"]) == {"query", "as_of"}
assert set(BACKFETCH_SCHEMA["parameters"]["required"]) == {"url", "as_of"}
# schema names match the registered tool names
assert BACKSEARCH_SCHEMA["name"] == "backsearch"
assert BACKFETCH_SCHEMA["name"] == "backfetch"
def test_configurable_in_hermes_tools_and_default_off(self):
from hermes_cli.tools_config import (
_DEFAULT_OFF_TOOLSETS,
CONFIGURABLE_TOOLSETS,
TOOL_CATEGORIES,
)
keys = {ts for ts, _, _ in CONFIGURABLE_TOOLSETS}
assert "backsearch" in keys
assert "backsearch" in _DEFAULT_OFF_TOOLSETS
env_keys = [
ev["key"]
for prov in TOOL_CATEGORIES["backsearch"]["providers"]
for ev in prov["env_vars"]
]
assert "OPENREWARD_API_KEY" in env_keys
def test_env_var_documented_in_optional_env_vars(self):
from hermes_cli.config import OPTIONAL_ENV_VARS
meta = OPTIONAL_ENV_VARS["OPENREWARD_API_KEY"]
assert meta["password"] is True
assert meta["category"] == "tool"
def test_auto_enable_helper_reflects_key_presence(
self, monkeypatch: pytest.MonkeyPatch
):
from hermes_cli.tools_config import _backsearch_credentials_present
assert _backsearch_credentials_present() is True
monkeypatch.delenv("OPENREWARD_API_KEY", raising=False)
with patch("plugins.backsearch.tools._get_env", return_value=""):
assert _backsearch_credentials_present() is False