Files
shelfmark/tests/newznab/test_handler.py
T
Atirna 265da07d7f feat(download): add Blackhole torrent handoff (#1312)
## Why

Blackhole users need Shelfmark to hand a torrent file to their existing
downloader instead of importing the downloaded book itself.

## Change

- add Blackhole as a torrent client with a configurable watched
directory
- prefer a fetched `.torrent` file for Blackhole while preserving magnet
preference for other clients
- complete the queue task after the handoff without invoking book
post-processing

## Verification

- `uv run pytest -q tests/prowlarr/test_blackhole_client.py
tests/prowlarr/test_handler.py tests/newznab/test_handler.py
tests/download/test_orchestrator_lifecycle.py`
- `uv run basedpyright shelfmark/download/clients/blackhole.py
shelfmark/download/clients/__init__.py
shelfmark/download/clients/base_handler.py
shelfmark/download/clients/settings.py
shelfmark/download/orchestrator.py shelfmark/release_sources/__init__.py
shelfmark/release_sources/prowlarr/utils.py
shelfmark/release_sources/prowlarr/handler.py
shelfmark/release_sources/newznab/handler.py
tests/prowlarr/test_blackhole_client.py tests/prowlarr/test_handler.py
tests/newznab/test_handler.py
tests/download/test_orchestrator_lifecycle.py`

Fixes #1229
2026-09-11 00:46:47 -04:00

208 lines
8.2 KiB
Python

"""Unit tests for the Newznab download handler."""
from threading import Event
from unittest.mock import patch
from shelfmark.core.models import DownloadTask
from shelfmark.release_sources.newznab.handler import (
NewznabHandler,
_get_download_url,
_get_protocol,
)
# ── helpers ────────────────────────────────────────────────────────────────────
class ProgressRecorder:
def __init__(self):
self.progress_values: list[float] = []
self.status_updates: list[tuple[str, str | None]] = []
def progress_callback(self, v: float):
self.progress_values.append(v)
def status_callback(self, status: str, message: str | None):
self.status_updates.append((status, message))
@property
def last_status(self) -> str | None:
return self.status_updates[-1][0] if self.status_updates else None
@property
def last_message(self) -> str | None:
return self.status_updates[-1][1] if self.status_updates else None
@property
def statuses(self) -> list[str]:
return [s[0] for s in self.status_updates]
# ── _get_protocol ────────────────────────────────────────────────────────────────
class TestGetProtocol:
def test_explicit_usenet(self):
assert _get_protocol({"protocol": "usenet"}) == "usenet"
def test_explicit_torrent(self):
assert _get_protocol({"protocol": "torrent"}) == "torrent"
def test_magnet_url_infers_torrent(self):
assert _get_protocol({"magnetUrl": "magnet:?xt=urn:btih:abc"}) == "torrent"
def test_torrent_extension_infers_torrent(self):
assert _get_protocol({"downloadUrl": "https://example.com/file.torrent"}) == "torrent"
def test_nzb_extension_infers_usenet(self):
assert _get_protocol({"downloadUrl": "https://example.com/file.nzb"}) == "usenet"
def test_defaults_to_usenet_when_ambiguous(self):
# Newznab is usenet-native; ambiguous URLs default to usenet.
assert _get_protocol({"downloadUrl": "https://example.com/download/123"}) == "usenet"
def test_empty_dict_defaults_to_usenet(self):
assert _get_protocol({}) == "usenet"
# ── _get_download_url ──────────────────────────────────────────────────────────
class TestGetDownloadUrl:
def test_usenet_prefers_download_url(self):
result = {
"protocol": "usenet",
"downloadUrl": "https://example.com/nzb",
"magnetUrl": "magnet:?xt=urn:btih:abc",
}
assert _get_download_url(result) == "https://example.com/nzb"
def test_torrent_prefers_magnet(self):
result = {
"protocol": "torrent",
"downloadUrl": "https://example.com/file.torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc",
}
assert _get_download_url(result) == "magnet:?xt=urn:btih:abc"
def test_blackhole_prefers_torrent_file(self):
result = {
"protocol": "torrent",
"downloadUrl": "https://example.com/file.torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc",
}
assert (
_get_download_url(result, prefer_torrent_file=True)
== "https://example.com/file.torrent"
)
def test_falls_back_to_download_url_when_no_magnet(self):
result = {
"protocol": "torrent",
"downloadUrl": "https://example.com/file.torrent",
}
assert _get_download_url(result) == "https://example.com/file.torrent"
# ── error paths ────────────────────────────────────────────────────────────────
class TestHandlerErrors:
def test_cache_miss_uses_persisted_retry_fields(self):
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
handler = NewznabHandler()
task = DownloadTask(
task_id="retryable",
source="newznab",
title="Book",
retry_download_url="https://indexer.example.com/nzb/42?apikey=secret",
retry_download_protocol="usenet",
retry_release_name="Book Release",
retry_expected_hash="abc123",
)
recorder = ProgressRecorder()
result = handler._resolve_download(task, recorder.status_callback)
assert result is not None
assert result.url == "https://indexer.example.com/nzb/42?apikey=secret"
assert result.protocol == "usenet"
assert result.release_name == "Book Release"
assert result.expected_hash == "abc123"
assert recorder.status_updates == []
def test_cache_miss_returns_error(self):
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
handler = NewznabHandler()
task = DownloadTask(task_id="missing", source="newznab", title="Book")
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "cache" in (recorder.last_message or "").lower()
def test_no_download_url_returns_error(self):
with patch(
"shelfmark.release_sources.newznab.handler.get_release",
return_value={"protocol": "usenet", "title": "Book"},
):
handler = NewznabHandler()
task = DownloadTask(task_id="no-url", source="newznab", title="Book")
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "url" in (recorder.last_message or "").lower()
def test_no_client_configured_returns_error(self):
with (
patch(
"shelfmark.release_sources.newznab.handler.get_release",
return_value={
"protocol": "usenet",
"downloadUrl": "https://example.com/nzb/1",
},
),
patch("shelfmark.release_sources.newznab.handler.get_client", return_value=None),
patch(
"shelfmark.release_sources.newznab.handler.list_configured_clients",
return_value=[],
),
):
handler = NewznabHandler()
task = DownloadTask(task_id="no-client", source="newznab", title="Book")
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=Event(),
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "client" in (recorder.last_message or "").lower()
# ── cancel ─────────────────────────────────────────────────────────────────────
class TestHandlerCancel:
def test_cancel_removes_from_cache(self):
with patch("shelfmark.release_sources.newznab.handler.remove_release") as mock_remove:
result = NewznabHandler().cancel("task-123")
assert result is True
mock_remove.assert_called_once_with("task-123")
def test_cancel_handles_absent_task(self):
with patch("shelfmark.release_sources.newznab.handler.remove_release"):
result = NewznabHandler().cancel("no-such-task")
assert result is True