"""Unit tests for the Newznab API client."""
from unittest.mock import MagicMock, patch
import requests
from shelfmark.release_sources.newznab.api import NewznabClient
# ── helpers ────────────────────────────────────────────────────────────────────
NZB_XML = """
My Indexer
-
Some Book (2024) [EPUB]
https://indexer.example.com/nzb/1
https://indexer.example.com/nzb/1?apikey=secret
Sun, 01 Jan 2024 00:00:00 +0000
2097152
7000
"""
CAPS_XML = """
"""
def _make_response(text: str, status: int = 200) -> MagicMock:
r = MagicMock(spec=requests.Response)
r.status_code = status
r.text = text
r.ok = status < 400
r.raise_for_status = MagicMock()
if status >= 400:
r.raise_for_status.side_effect = requests.exceptions.HTTPError(response=r)
return r
# ── URL construction ────────────────────────────────────────────────────────────
class TestApiUrl:
def test_appends_api_path(self):
client = NewznabClient("http://nzbhydra:5076", "key")
assert client._api_url() == "http://nzbhydra:5076/api"
def test_does_not_double_append(self):
client = NewznabClient("http://nzbhydra:5076/api", "key")
assert client._api_url() == "http://nzbhydra:5076/api"
def test_strips_trailing_slash(self):
client = NewznabClient("http://nzbhydra:5076/", "key")
assert client._api_url() == "http://nzbhydra:5076/api"
# ── test_connection ─────────────────────────────────────────────────────────────
class TestTestConnection:
def test_success_returns_true_with_title(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(client, "_get", return_value=_make_response(CAPS_XML)):
ok, msg = client.test_connection()
assert ok is True
assert "NZBHydra2" in msg
def test_connection_error_returns_false(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(
client,
"_get",
side_effect=requests.exceptions.ConnectionError("refused"),
):
ok, msg = client.test_connection()
assert ok is False
assert "connect" in msg.lower()
def test_401_returns_api_key_error(self):
client = NewznabClient("http://nzbhydra:5076", "key")
fake_resp = _make_response("", status=401)
with patch.object(
client,
"_get",
side_effect=requests.exceptions.HTTPError(response=fake_resp),
):
ok, msg = client.test_connection()
assert ok is False
assert "api key" in msg.lower()
def test_generic_exception_returns_false(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(
client,
"_get",
side_effect=requests.exceptions.Timeout("oops"),
):
ok, msg = client.test_connection()
assert ok is False
assert "oops" in msg.lower()
def test_caps_without_title_still_succeeds(self):
client = NewznabClient("http://nzbhydra:5076", "key")
caps_no_title = ""
with patch.object(client, "_get", return_value=_make_response(caps_no_title)):
ok, msg = client.test_connection()
assert ok is True
assert msg # some non-empty message
# ── search ──────────────────────────────────────────────────────────────────────
class TestSearch:
def test_empty_query_returns_empty(self):
client = NewznabClient("http://nzbhydra:5076", "key")
results = client.search(query="")
assert results == []
def test_parses_nzb_xml(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(client, "_get", return_value=_make_response(NZB_XML)):
results = client.search(query="Some Book")
assert len(results) == 1
r = results[0]
assert r["title"] == "Some Book (2024) [EPUB]"
assert r["protocol"] == "usenet"
assert r["size"] == 2097152
assert r["downloadUrl"] == "https://indexer.example.com/nzb/1?apikey=secret"
def test_sends_category_param(self):
client = NewznabClient("http://nzbhydra:5076", "key")
captured: list = []
def fake_get(params, accept_xml=False):
captured.append(params.copy())
return _make_response(NZB_XML)
with patch.object(client, "_get", side_effect=fake_get):
client.search(query="book", categories=[7000, 3030])
assert len(captured) == 1
assert captured[0]["cat"] == "7000,3030"
def test_omits_category_when_none(self):
client = NewznabClient("http://nzbhydra:5076", "key")
captured: list = []
def fake_get(params, accept_xml=False):
captured.append(params.copy())
return _make_response(NZB_XML)
with patch.object(client, "_get", side_effect=fake_get):
client.search(query="book", categories=None)
assert "cat" not in captured[0]
def test_returns_empty_on_request_error(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(
client,
"_get",
side_effect=requests.exceptions.ConnectionError("down"),
):
results = client.search(query="book")
assert results == []
def test_returns_empty_on_malformed_xml(self):
client = NewznabClient("http://nzbhydra:5076", "key")
with patch.object(client, "_get", return_value=_make_response("not xml at all")):
results = client.search(query="book")
assert results == []
def test_includes_apikey_in_request(self):
"""The apikey is injected by _get() into the outgoing HTTP request."""
client = NewznabClient("http://nzbhydra:5076", "mykey")
captured_params: list = []
def fake_session_get(url, params=None, **kwargs):
captured_params.append(dict(params or {}))
r = _make_response(NZB_XML)
return r
with patch.object(client._session, "get", side_effect=fake_session_get):
client.search(query="test")
assert len(captured_params) == 1
assert captured_params[0].get("apikey") == "mykey"
def test_uses_book_search_type_when_specified(self):
client = NewznabClient("http://nzbhydra:5076", "key")
captured: list = []
def fake_get(params, accept_xml=False):
captured.append(params.copy())
return _make_response(NZB_XML)
with patch.object(client, "_get", side_effect=fake_get):
client.search(query="book", search_type="book")
assert captured[0]["t"] == "book"