test: cover Prowlarr + torrent + usenet adapters
54 mocked unit tests pinning the parse + dispatch behavior of the new indexer and downloader plumbing. No live services required — HTTP is mocked at the requests-library boundary, RPC is mocked at the _rpc_sync helper. Coverage: - core/prowlarr_client.py: parse_indexer / parse_result with category-shape variants, search query encodes repeated ``categories=`` and ``indexerIds=`` keys, check_connection hits the right endpoint with the right header. - core/torrent_clients/qbittorrent.py: login sends the Referer CSRF header, login failure surfaces, parse_status normalises field names, eta <= 0 becomes None. - core/torrent_clients/transmission.py: bare host URL is rewritten to /transmission/rpc, 409 + X-Transmission-Session-Id is renegotiated and the retry carries the new id, torrent-add surfaces torrent-duplicate hashes, eta -1 becomes None. - core/torrent_clients/deluge.py: requires password to be configured, magnet vs HTTP URL hit different RPC methods, progress is normalised from 0-100 to 0-1. - core/usenet_clients/sabnzbd.py: parse_timeleft handles HH:MM:SS and the MM:SS fallback, queue + history merge into a single get_all, addurl vs addfile are dispatched on the input type. - core/usenet_clients/nzbget.py: requires URL + username + password, mb_value prefers the 64-bit size split over the legacy MB field, add_nzb base64-encodes raw bytes, GroupFinalDelete vs GroupDelete is picked by the delete_files flag, non-numeric job IDs fail fast. - state mapping tables for all five adapters get explicit assertions so future refactors can't silently lose a native state value. WHATS_NEW entry covers the test addition; no VERSION_MODAL_SECTIONS entry — internal infrastructure, not user-facing.
This commit is contained in:
parent
7a3ce50f71
commit
e4ca56b499
4 changed files with 860 additions and 0 deletions
223
tests/test_prowlarr_client.py
Normal file
223
tests/test_prowlarr_client.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""Tests for ``core/prowlarr_client.py``.
|
||||
|
||||
Pins the parse + dispatch behavior so a future Prowlarr API tweak
|
||||
that drops a field doesn't silently lose data, and the search
|
||||
endpoint keeps building the repeated-key query Prowlarr expects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.prowlarr_client import (
|
||||
DEFAULT_MUSIC_CATEGORIES,
|
||||
ProwlarrClient,
|
||||
ProwlarrIndexer,
|
||||
ProwlarrSearchResult,
|
||||
)
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _client_with_config(url="http://prowlarr:9696", api_key="secret"):
|
||||
"""Build a client whose ``_load_config`` already ran with the
|
||||
given URL + key, sidestepping the real config_manager."""
|
||||
client = ProwlarrClient.__new__(ProwlarrClient)
|
||||
client._url = url.rstrip('/')
|
||||
client._api_key = api_key
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure parsers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_indexer_extracts_core_fields() -> None:
|
||||
client = _client_with_config()
|
||||
entry = {
|
||||
'id': 7,
|
||||
'name': 'Public Tracker',
|
||||
'protocol': 'torrent',
|
||||
'enable': True,
|
||||
'privacy': 'public',
|
||||
'capabilities': {
|
||||
'categories': [
|
||||
{'id': 3000, 'name': 'Audio'},
|
||||
{'id': 3040, 'name': 'Audio/Lossless'},
|
||||
],
|
||||
},
|
||||
}
|
||||
indexer = client._parse_indexer(entry)
|
||||
assert indexer == ProwlarrIndexer(
|
||||
id=7,
|
||||
name='Public Tracker',
|
||||
protocol='torrent',
|
||||
enable=True,
|
||||
privacy='public',
|
||||
categories=[3000, 3040],
|
||||
capabilities=entry['capabilities'],
|
||||
)
|
||||
|
||||
|
||||
def test_parse_indexer_tolerates_missing_capabilities() -> None:
|
||||
"""Some indexers (the ones in error state) come back with no
|
||||
``capabilities`` block — must not crash."""
|
||||
client = _client_with_config()
|
||||
indexer = client._parse_indexer({'id': 1, 'name': 'X', 'protocol': 'usenet'})
|
||||
assert indexer.id == 1
|
||||
assert indexer.protocol == 'usenet'
|
||||
assert indexer.categories == []
|
||||
|
||||
|
||||
def test_parse_result_extracts_torrent_fields() -> None:
|
||||
client = _client_with_config()
|
||||
entry = {
|
||||
'guid': 'guid-1',
|
||||
'title': 'Some Album FLAC',
|
||||
'indexerId': 3,
|
||||
'indexer': 'Tracker',
|
||||
'protocol': 'torrent',
|
||||
'downloadUrl': 'https://example.com/x.torrent',
|
||||
'magnetUrl': 'magnet:?xt=urn:btih:abc',
|
||||
'infoUrl': 'https://example.com/details/1',
|
||||
'size': 524288000,
|
||||
'seeders': 12,
|
||||
'leechers': 3,
|
||||
'grabs': 100,
|
||||
'publishDate': '2026-05-10T00:00:00Z',
|
||||
'categories': [{'id': 3040, 'name': 'Audio/Lossless'}],
|
||||
}
|
||||
result = client._parse_result(entry)
|
||||
assert result.title == 'Some Album FLAC'
|
||||
assert result.indexer_id == 3
|
||||
assert result.download_url == 'https://example.com/x.torrent'
|
||||
assert result.magnet_uri == 'magnet:?xt=urn:btih:abc'
|
||||
assert result.size == 524288000
|
||||
assert result.seeders == 12
|
||||
assert result.categories == [3040]
|
||||
|
||||
|
||||
def test_parse_result_accepts_int_categories() -> None:
|
||||
"""Some indexers return categories as bare ints instead of
|
||||
``{id, name}`` dicts. Both forms must work."""
|
||||
client = _client_with_config()
|
||||
result = client._parse_result({'title': 'X', 'categories': [3000, 3010]})
|
||||
assert result.categories == [3000, 3010]
|
||||
|
||||
|
||||
def test_parse_result_skips_garbage_category_entries() -> None:
|
||||
client = _client_with_config()
|
||||
result = client._parse_result({'title': 'X', 'categories': [{'name': 'no-id'}, 'string', None]})
|
||||
assert result.categories == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configured-state predicates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_configured_requires_both_fields() -> None:
|
||||
assert _client_with_config('http://x', '').is_configured() is False
|
||||
assert _client_with_config('', 'key').is_configured() is False
|
||||
assert _client_with_config('http://x', 'key').is_configured() is True
|
||||
|
||||
|
||||
def test_check_connection_returns_false_when_not_configured() -> None:
|
||||
client = _client_with_config('', '')
|
||||
assert _run(client.check_connection()) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(status_code: int, json_body):
|
||||
resp = MagicMock()
|
||||
resp.ok = 200 <= status_code < 400
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_body
|
||||
return resp
|
||||
|
||||
|
||||
def test_search_passes_repeated_categories_and_indexer_ids() -> None:
|
||||
"""Prowlarr's search endpoint expects repeated query keys —
|
||||
``categories=3000&categories=3010&indexerIds=1``. ``requests``
|
||||
serializes a list of tuples into that exact form, so we assert
|
||||
the params are passed as a list-of-tuples (not a dict)."""
|
||||
client = _client_with_config()
|
||||
captured_params = {}
|
||||
|
||||
def fake_get(url, headers=None, params=None, timeout=None):
|
||||
captured_params['url'] = url
|
||||
captured_params['params'] = params
|
||||
return _mock_response(200, [])
|
||||
|
||||
with patch('core.prowlarr_client.http_requests.get', side_effect=fake_get):
|
||||
_run(client.search('the query', categories=[3000, 3010], indexer_ids=[1, 5]))
|
||||
|
||||
assert captured_params['url'] == 'http://prowlarr:9696/api/v1/search'
|
||||
params = captured_params['params']
|
||||
# Convert to a frozenset of pairs for order-independent comparison
|
||||
pair_set = set(params)
|
||||
assert ('query', 'the query') in pair_set
|
||||
assert ('type', 'search') in pair_set
|
||||
assert ('categories', 3000) in pair_set
|
||||
assert ('categories', 3010) in pair_set
|
||||
assert ('indexerIds', 1) in pair_set
|
||||
assert ('indexerIds', 5) in pair_set
|
||||
|
||||
|
||||
def test_search_returns_empty_on_blank_query() -> None:
|
||||
client = _client_with_config()
|
||||
# No HTTP mock — call must short-circuit without touching the network.
|
||||
results = _run(client.search(''))
|
||||
assert results == []
|
||||
results = _run(client.search(' '))
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_parses_response_list() -> None:
|
||||
client = _client_with_config()
|
||||
with patch('core.prowlarr_client.http_requests.get',
|
||||
return_value=_mock_response(200, [
|
||||
{'guid': 'a', 'title': 'Album A', 'protocol': 'torrent'},
|
||||
{'guid': 'b', 'title': 'Album B', 'protocol': 'usenet'},
|
||||
])):
|
||||
results = _run(client.search('q'))
|
||||
assert [r.title for r in results] == ['Album A', 'Album B']
|
||||
assert [r.protocol for r in results] == ['torrent', 'usenet']
|
||||
|
||||
|
||||
def test_check_connection_hits_system_status() -> None:
|
||||
client = _client_with_config()
|
||||
with patch('core.prowlarr_client.http_requests.get',
|
||||
return_value=_mock_response(200, {'version': '1.13.0'})) as mock_get:
|
||||
ok = _run(client.check_connection())
|
||||
assert ok is True
|
||||
called_url = mock_get.call_args.args[0]
|
||||
assert called_url == 'http://prowlarr:9696/api/v1/system/status'
|
||||
assert mock_get.call_args.kwargs['headers']['X-Api-Key'] == 'secret'
|
||||
|
||||
|
||||
def test_check_connection_returns_false_on_http_error() -> None:
|
||||
client = _client_with_config()
|
||||
with patch('core.prowlarr_client.http_requests.get',
|
||||
return_value=_mock_response(401, {'error': 'unauthorized'})):
|
||||
ok = _run(client.check_connection())
|
||||
assert ok is False
|
||||
|
||||
|
||||
def test_default_music_categories_match_newznab_tree() -> None:
|
||||
"""The Newznab Music category IDs are a stable convention across
|
||||
Prowlarr / Jackett / every indexer. Pin the defaults so a typo
|
||||
here doesn't silently broaden / narrow what SoulSync queries."""
|
||||
assert 3000 in DEFAULT_MUSIC_CATEGORIES # Audio (parent)
|
||||
assert 3010 in DEFAULT_MUSIC_CATEGORIES # MP3
|
||||
assert 3040 in DEFAULT_MUSIC_CATEGORIES # Lossless
|
||||
339
tests/test_torrent_client_adapters.py
Normal file
339
tests/test_torrent_client_adapters.py
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"""Tests for the three torrent client adapters.
|
||||
|
||||
Pins state-mapping behavior (each client has a different native state
|
||||
vocabulary that must collapse onto the adapter-uniform set) and basic
|
||||
HTTP / RPC plumbing so a future protocol-spec drift fails CI instead
|
||||
of silently breaking downloads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.torrent_clients import adapter_for_type, get_active_adapter
|
||||
from core.torrent_clients.base import TorrentClientAdapter, TorrentStatus
|
||||
from core.torrent_clients.deluge import DelugeAdapter, _map_state as deluge_map
|
||||
from core.torrent_clients.qbittorrent import QBittorrentAdapter, _map_state as qbit_map
|
||||
from core.torrent_clients.transmission import TransmissionAdapter, _map_state as trans_map
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _mock_response(status_code: int, json_body=None, text=None, headers=None):
|
||||
resp = MagicMock()
|
||||
resp.ok = 200 <= status_code < 400
|
||||
resp.status_code = status_code
|
||||
resp.headers = headers or {}
|
||||
if json_body is not None:
|
||||
resp.json.return_value = json_body
|
||||
resp.text = text or ''
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_for_type_returns_concrete_classes() -> None:
|
||||
assert isinstance(adapter_for_type('qbittorrent'), QBittorrentAdapter)
|
||||
assert isinstance(adapter_for_type('transmission'), TransmissionAdapter)
|
||||
assert isinstance(adapter_for_type('deluge'), DelugeAdapter)
|
||||
|
||||
|
||||
def test_adapter_for_type_returns_none_for_unknown() -> None:
|
||||
assert adapter_for_type('utorrent') is None
|
||||
assert adapter_for_type('') is None
|
||||
|
||||
|
||||
def test_adapters_conform_to_protocol() -> None:
|
||||
"""``isinstance`` checks the runtime_checkable Protocol — catches
|
||||
adapters that lose a required method during refactors."""
|
||||
for adapter in (QBittorrentAdapter(), TransmissionAdapter(), DelugeAdapter()):
|
||||
assert isinstance(adapter, TorrentClientAdapter)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_qbittorrent_state_mapping() -> None:
|
||||
assert qbit_map('downloading') == 'downloading'
|
||||
assert qbit_map('forcedDL') == 'downloading'
|
||||
assert qbit_map('stalledDL') == 'stalled'
|
||||
assert qbit_map('uploading') == 'seeding'
|
||||
assert qbit_map('pausedUP') == 'completed'
|
||||
assert qbit_map('pausedDL') == 'paused'
|
||||
assert qbit_map('error') == 'error'
|
||||
assert qbit_map('missingFiles') == 'error'
|
||||
# Unknown native value → error rather than swallowing silently.
|
||||
assert qbit_map('not-a-real-state') == 'error'
|
||||
|
||||
|
||||
def test_transmission_state_mapping() -> None:
|
||||
assert trans_map(4, 0.5) == 'downloading'
|
||||
assert trans_map(6, 1.0) == 'seeding'
|
||||
# Status 0 is the ambiguous one: paused vs completed-but-not-seeding.
|
||||
assert trans_map(0, 0.3) == 'paused'
|
||||
assert trans_map(0, 1.0) == 'completed'
|
||||
assert trans_map(2, 0.0) == 'queued' # checking files
|
||||
# Unknown numeric code → error.
|
||||
assert trans_map(99, 0.0) == 'error'
|
||||
|
||||
|
||||
def test_deluge_state_mapping() -> None:
|
||||
assert deluge_map('Downloading', 0.5) == 'downloading'
|
||||
assert deluge_map('Seeding', 1.0) == 'seeding'
|
||||
assert deluge_map('Paused', 0.4) == 'paused'
|
||||
# Deluge reports 'Paused' for completed-not-seeding too.
|
||||
assert deluge_map('Paused', 1.0) == 'completed'
|
||||
assert deluge_map('Error', 0.0) == 'error'
|
||||
assert deluge_map('', 0.0) == 'error'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qBittorrent adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _qbit_with_config(url='http://qbit:8080', username='admin', password='x'):
|
||||
adapter = QBittorrentAdapter.__new__(QBittorrentAdapter)
|
||||
import threading
|
||||
adapter._session = None
|
||||
adapter._session_lock = threading.Lock()
|
||||
adapter._url = url.rstrip('/')
|
||||
adapter._username = username
|
||||
adapter._password = password
|
||||
adapter._category = 'soulsync'
|
||||
adapter._save_path = ''
|
||||
return adapter
|
||||
|
||||
|
||||
def test_qbit_is_configured_requires_only_url() -> None:
|
||||
# qBittorrent allows no-auth LAN setups — URL is enough.
|
||||
assert _qbit_with_config('http://x', '', '').is_configured() is True
|
||||
assert _qbit_with_config('', 'u', 'p').is_configured() is False
|
||||
|
||||
|
||||
def test_qbit_login_sends_referer_for_csrf() -> None:
|
||||
"""qBittorrent rejects login attempts without a Referer matching
|
||||
its host — pin the header to catch regressions."""
|
||||
adapter = _qbit_with_config()
|
||||
fake_session = MagicMock()
|
||||
fake_session.post.return_value = _mock_response(200, text='Ok.')
|
||||
fake_session.post.return_value.text = 'Ok.'
|
||||
with patch('core.torrent_clients.qbittorrent.http_requests.Session',
|
||||
return_value=fake_session):
|
||||
sess = adapter._ensure_session_sync()
|
||||
assert sess is not None
|
||||
args, kwargs = fake_session.post.call_args
|
||||
assert args[0].endswith('/api/v2/auth/login')
|
||||
assert kwargs['headers']['Referer'] == 'http://qbit:8080'
|
||||
assert kwargs['data'] == {'username': 'admin', 'password': 'x'}
|
||||
|
||||
|
||||
def test_qbit_login_failure_returns_none() -> None:
|
||||
adapter = _qbit_with_config()
|
||||
fake_session = MagicMock()
|
||||
bad_resp = _mock_response(200, text='Fails.')
|
||||
bad_resp.text = 'Fails.'
|
||||
fake_session.post.return_value = bad_resp
|
||||
with patch('core.torrent_clients.qbittorrent.http_requests.Session',
|
||||
return_value=fake_session):
|
||||
sess = adapter._ensure_session_sync()
|
||||
assert sess is None
|
||||
|
||||
|
||||
def test_qbit_parse_status_normalises_native_fields() -> None:
|
||||
adapter = _qbit_with_config()
|
||||
status = adapter._parse_status({
|
||||
'hash': 'abc123', 'name': 'Album',
|
||||
'state': 'downloading', 'progress': 0.5,
|
||||
'size': 1024, 'downloaded': 512,
|
||||
'dlspeed': 200, 'upspeed': 50,
|
||||
'num_seeds': 4, 'num_leechs': 1,
|
||||
'eta': 60, 'save_path': '/data/torrents',
|
||||
})
|
||||
assert status == TorrentStatus(
|
||||
id='abc123', name='Album', state='downloading',
|
||||
progress=0.5, size=1024, downloaded=512,
|
||||
download_speed=200, upload_speed=50, seeders=4, peers=1,
|
||||
eta=60, save_path='/data/torrents',
|
||||
)
|
||||
|
||||
|
||||
def test_qbit_parse_status_zeros_eta_when_unknown() -> None:
|
||||
adapter = _qbit_with_config()
|
||||
# qBittorrent uses 8640000 for "unknown" but the adapter just
|
||||
# treats anything <= 0 as unknown; pin that 0 maps to None.
|
||||
status = adapter._parse_status({
|
||||
'hash': 'x', 'name': 'X', 'state': 'stalledDL',
|
||||
'progress': 0.0, 'size': 100, 'downloaded': 0,
|
||||
'dlspeed': 0, 'upspeed': 0, 'eta': 0,
|
||||
})
|
||||
assert status.eta is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transmission adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _trans_with_config(url='http://trans:9091/transmission/rpc'):
|
||||
adapter = TransmissionAdapter.__new__(TransmissionAdapter)
|
||||
import threading
|
||||
adapter._session_id = None
|
||||
adapter._session_id_lock = threading.Lock()
|
||||
adapter._url = url
|
||||
adapter._username = ''
|
||||
adapter._password = ''
|
||||
adapter._category = 'soulsync'
|
||||
adapter._save_path = ''
|
||||
return adapter
|
||||
|
||||
|
||||
def test_transmission_normalises_bare_host_to_rpc_path() -> None:
|
||||
"""Users sometimes paste ``http://host:9091``; the adapter must
|
||||
append ``/transmission/rpc`` so the request hits the right
|
||||
endpoint."""
|
||||
adapter = TransmissionAdapter.__new__(TransmissionAdapter)
|
||||
with patch('core.torrent_clients.transmission.config_manager') as cm:
|
||||
cm.get.side_effect = lambda key, default='': {
|
||||
'torrent_client.url': 'http://host:9091',
|
||||
'torrent_client.username': '',
|
||||
'torrent_client.password': '',
|
||||
'torrent_client.category': 'soulsync',
|
||||
'torrent_client.save_path': '',
|
||||
}.get(key, default)
|
||||
import threading
|
||||
adapter._session_id = None
|
||||
adapter._session_id_lock = threading.Lock()
|
||||
adapter._load_config()
|
||||
assert adapter._url == 'http://host:9091/transmission/rpc'
|
||||
|
||||
|
||||
def test_transmission_session_id_renegotiation() -> None:
|
||||
"""Transmission rejects the first call with 409 and a fresh
|
||||
``X-Transmission-Session-Id`` header; the adapter must store it
|
||||
and retry the same call exactly once."""
|
||||
adapter = _trans_with_config()
|
||||
first = _mock_response(409, headers={'X-Transmission-Session-Id': 'sid-2'})
|
||||
second = _mock_response(200, json_body={'result': 'success', 'arguments': {'session-id': 1}})
|
||||
with patch('core.torrent_clients.transmission.http_requests.post',
|
||||
side_effect=[first, second]) as mock_post:
|
||||
result = adapter._rpc('session-get', {})
|
||||
assert result == {'session-id': 1}
|
||||
assert mock_post.call_count == 2
|
||||
# Second call carried the new session id.
|
||||
second_call_kwargs = mock_post.call_args_list[1].kwargs
|
||||
assert second_call_kwargs['headers']['X-Transmission-Session-Id'] == 'sid-2'
|
||||
|
||||
|
||||
def test_transmission_rpc_returns_none_on_failure_result() -> None:
|
||||
adapter = _trans_with_config()
|
||||
with patch('core.torrent_clients.transmission.http_requests.post',
|
||||
return_value=_mock_response(200, json_body={'result': 'unknown method'})):
|
||||
assert adapter._rpc('bogus', {}) is None
|
||||
|
||||
|
||||
def test_transmission_add_torrent_handles_duplicate() -> None:
|
||||
"""torrent-add returns either ``torrent-added`` (new) or
|
||||
``torrent-duplicate`` (already-there) — both must surface the hash."""
|
||||
adapter = _trans_with_config()
|
||||
with patch.object(adapter, '_rpc', return_value={'torrent-duplicate': {'hashString': 'dup'}}):
|
||||
hash_id = adapter._add_torrent_sync('magnet:?xt=urn:btih:abc', 'cat', None)
|
||||
assert hash_id == 'dup'
|
||||
|
||||
|
||||
def test_transmission_parse_status() -> None:
|
||||
adapter = _trans_with_config()
|
||||
status = adapter._parse_status({
|
||||
'hashString': 'h', 'name': 'X', 'status': 4, 'percentDone': 0.42,
|
||||
'totalSize': 100, 'downloadedEver': 42,
|
||||
'rateDownload': 10, 'rateUpload': 5,
|
||||
'peersSendingToUs': 2, 'peersGettingFromUs': 0,
|
||||
'eta': 300, 'downloadDir': '/dl', 'errorString': '',
|
||||
})
|
||||
assert status.id == 'h'
|
||||
assert status.state == 'downloading'
|
||||
assert status.progress == 0.42
|
||||
assert status.eta == 300
|
||||
|
||||
|
||||
def test_transmission_parse_status_negative_eta_is_none() -> None:
|
||||
"""Transmission reports -1 / -2 for 'unknown' ETA — must normalise to None."""
|
||||
adapter = _trans_with_config()
|
||||
status = adapter._parse_status({
|
||||
'hashString': 'h', 'name': 'X', 'status': 4, 'percentDone': 0.0,
|
||||
'totalSize': 100, 'downloadedEver': 0,
|
||||
'rateDownload': 0, 'rateUpload': 0,
|
||||
'peersSendingToUs': 0, 'peersGettingFromUs': 0,
|
||||
'eta': -1, 'downloadDir': '/dl',
|
||||
})
|
||||
assert status.eta is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deluge adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _deluge_with_config(url='http://deluge:8112', password='delugepass'):
|
||||
adapter = DelugeAdapter.__new__(DelugeAdapter)
|
||||
import threading
|
||||
from itertools import count
|
||||
adapter._session = None
|
||||
adapter._session_lock = threading.Lock()
|
||||
adapter._id_counter = count(1)
|
||||
adapter._url = url.rstrip('/')
|
||||
adapter._password = password
|
||||
adapter._category = 'soulsync'
|
||||
adapter._save_path = ''
|
||||
return adapter
|
||||
|
||||
|
||||
def test_deluge_is_configured_requires_password() -> None:
|
||||
assert _deluge_with_config('http://x', '').is_configured() is False
|
||||
assert _deluge_with_config('http://x', 'pw').is_configured() is True
|
||||
|
||||
|
||||
def test_deluge_add_torrent_uses_magnet_method() -> None:
|
||||
adapter = _deluge_with_config()
|
||||
with patch.object(adapter, '_ensure_session_sync', return_value=MagicMock()), \
|
||||
patch.object(adapter, '_rpc_sync', return_value='hash123') as mock_rpc:
|
||||
hash_id = adapter._add_torrent_sync('magnet:?xt=urn:btih:abc', 'cat', None)
|
||||
assert hash_id == 'hash123'
|
||||
# First call was core.add_torrent_magnet, not the URL variant.
|
||||
first_method = mock_rpc.call_args_list[0].args[0]
|
||||
assert first_method == 'core.add_torrent_magnet'
|
||||
|
||||
|
||||
def test_deluge_add_torrent_uses_url_method_for_http() -> None:
|
||||
adapter = _deluge_with_config()
|
||||
with patch.object(adapter, '_ensure_session_sync', return_value=MagicMock()), \
|
||||
patch.object(adapter, '_rpc_sync', return_value='hash456') as mock_rpc:
|
||||
hash_id = adapter._add_torrent_sync('https://example.com/x.torrent', 'cat', None)
|
||||
assert hash_id == 'hash456'
|
||||
first_method = mock_rpc.call_args_list[0].args[0]
|
||||
assert first_method == 'core.add_torrent_url'
|
||||
|
||||
|
||||
def test_deluge_parse_status_normalises_percent_progress() -> None:
|
||||
"""Deluge reports progress as 0-100 (not 0-1) — adapter must
|
||||
normalise."""
|
||||
adapter = _deluge_with_config()
|
||||
status = adapter._parse_status({
|
||||
'hash': 'abc', 'name': 'X', 'state': 'Downloading',
|
||||
'progress': 42.0,
|
||||
'total_size': 1000, 'total_done': 420,
|
||||
'download_payload_rate': 100, 'upload_payload_rate': 0,
|
||||
'num_seeds': 1, 'num_peers': 0, 'eta': 0,
|
||||
})
|
||||
assert status.progress == pytest.approx(0.42)
|
||||
assert status.state == 'downloading'
|
||||
297
tests/test_usenet_client_adapters.py
Normal file
297
tests/test_usenet_client_adapters.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""Tests for the SABnzbd and NZBGet usenet adapters.
|
||||
|
||||
Pins state-mapping behavior and the queue-vs-history merge logic so
|
||||
get_all returns both active and completed jobs without losing
|
||||
either bucket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.usenet_clients import adapter_for_type
|
||||
from core.usenet_clients.base import UsenetClientAdapter, UsenetStatus
|
||||
from core.usenet_clients.nzbget import NZBGetAdapter, _map_state as nzbget_map
|
||||
from core.usenet_clients.sabnzbd import SABnzbdAdapter, _map_state as sab_map
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _mock_response(status_code: int, json_body=None):
|
||||
resp = MagicMock()
|
||||
resp.ok = 200 <= status_code < 400
|
||||
resp.status_code = status_code
|
||||
if json_body is not None:
|
||||
resp.json.return_value = json_body
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factory + protocol conformance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_for_type_returns_concrete_classes() -> None:
|
||||
assert isinstance(adapter_for_type('sabnzbd'), SABnzbdAdapter)
|
||||
assert isinstance(adapter_for_type('nzbget'), NZBGetAdapter)
|
||||
|
||||
|
||||
def test_adapter_for_type_returns_none_for_unknown() -> None:
|
||||
assert adapter_for_type('unknown') is None
|
||||
|
||||
|
||||
def test_adapters_conform_to_protocol() -> None:
|
||||
for adapter in (SABnzbdAdapter(), NZBGetAdapter()):
|
||||
assert isinstance(adapter, UsenetClientAdapter)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SABnzbd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sab_with_config(url='http://sab:8080', api_key='k'):
|
||||
adapter = SABnzbdAdapter.__new__(SABnzbdAdapter)
|
||||
adapter._url = url.rstrip('/')
|
||||
adapter._api_key = api_key
|
||||
adapter._category = 'soulsync'
|
||||
return adapter
|
||||
|
||||
|
||||
def test_sab_is_configured_requires_url_and_key() -> None:
|
||||
assert _sab_with_config('http://x', '').is_configured() is False
|
||||
assert _sab_with_config('', 'k').is_configured() is False
|
||||
assert _sab_with_config('http://x', 'k').is_configured() is True
|
||||
|
||||
|
||||
def test_sab_state_mapping_covers_queue_states() -> None:
|
||||
assert sab_map('Downloading') == 'downloading'
|
||||
assert sab_map('Verifying') == 'verifying'
|
||||
assert sab_map('Repairing') == 'repairing'
|
||||
assert sab_map('Extracting') == 'extracting'
|
||||
assert sab_map('Paused') == 'paused'
|
||||
assert sab_map('Failed') == 'failed'
|
||||
# Case-insensitive — SAB sometimes returns lowercase.
|
||||
assert sab_map('downloading') == 'downloading'
|
||||
assert sab_map('') == 'error'
|
||||
|
||||
|
||||
def test_sab_parse_timeleft_handles_hhmmss() -> None:
|
||||
# SABnzbd's timeleft is always HH:MM:SS (or H:MM:SS).
|
||||
assert SABnzbdAdapter._parse_timeleft('01:30:00') == 5400
|
||||
assert SABnzbdAdapter._parse_timeleft('00:05:30') == 330
|
||||
assert SABnzbdAdapter._parse_timeleft('00:30:00') == 1800
|
||||
# 2-part fallback covers MM:SS for robustness.
|
||||
assert SABnzbdAdapter._parse_timeleft('05:30') == 330
|
||||
assert SABnzbdAdapter._parse_timeleft('garbage') is None
|
||||
assert SABnzbdAdapter._parse_timeleft('') is None
|
||||
assert SABnzbdAdapter._parse_timeleft(None) is None
|
||||
|
||||
|
||||
def test_sab_parse_queue_slot_converts_mb_to_bytes() -> None:
|
||||
adapter = _sab_with_config()
|
||||
status = adapter._parse_queue_slot({
|
||||
'nzo_id': 'SABnzbd_nzo_1',
|
||||
'filename': 'Album.nzb',
|
||||
'status': 'Downloading',
|
||||
'percentage': '42',
|
||||
'mb': '100',
|
||||
'mbleft': '58',
|
||||
'timeleft': '0:01:00',
|
||||
'cat': 'soulsync',
|
||||
})
|
||||
assert status.id == 'SABnzbd_nzo_1'
|
||||
assert status.state == 'downloading'
|
||||
assert status.progress == pytest.approx(0.42)
|
||||
assert status.size == 100 * 1024 * 1024
|
||||
assert status.downloaded == 42 * 1024 * 1024
|
||||
assert status.eta == 60
|
||||
|
||||
|
||||
def test_sab_parse_history_slot_marks_failures() -> None:
|
||||
adapter = _sab_with_config()
|
||||
failed = adapter._parse_history_slot({
|
||||
'nzo_id': 'x', 'name': 'X', 'status': 'Failed',
|
||||
'bytes': 1024, 'fail_message': 'Damaged',
|
||||
})
|
||||
assert failed.state == 'failed'
|
||||
assert failed.progress == 0.0
|
||||
assert failed.error == 'Damaged'
|
||||
|
||||
success = adapter._parse_history_slot({
|
||||
'nzo_id': 'y', 'name': 'Y', 'status': 'Completed',
|
||||
'bytes': 1024, 'storage': '/done',
|
||||
})
|
||||
assert success.state == 'completed'
|
||||
assert success.progress == 1.0
|
||||
assert success.save_path == '/done'
|
||||
|
||||
|
||||
def test_sab_add_nzb_via_url_returns_first_nzo_id() -> None:
|
||||
adapter = _sab_with_config()
|
||||
with patch('core.usenet_clients.sabnzbd.http_requests.get',
|
||||
return_value=_mock_response(200, {'status': True, 'nzo_ids': ['SABnzbd_1']})) as mock_get:
|
||||
job_id = _run(adapter.add_nzb('https://example.com/x.nzb', category='cat'))
|
||||
assert job_id == 'SABnzbd_1'
|
||||
params = mock_get.call_args.kwargs['params']
|
||||
assert params['mode'] == 'addurl'
|
||||
assert params['apikey'] == 'k'
|
||||
assert params['name'] == 'https://example.com/x.nzb'
|
||||
assert params['cat'] == 'cat'
|
||||
|
||||
|
||||
def test_sab_add_nzb_via_bytes_uses_addfile_multipart() -> None:
|
||||
adapter = _sab_with_config()
|
||||
with patch('core.usenet_clients.sabnzbd.http_requests.post',
|
||||
return_value=_mock_response(200, {'status': True, 'nzo_ids': ['SABnzbd_2']})) as mock_post:
|
||||
job_id = _run(adapter.add_nzb(b'<nzb/>', category='cat'))
|
||||
assert job_id == 'SABnzbd_2'
|
||||
assert mock_post.call_args.kwargs['params']['mode'] == 'addfile'
|
||||
files = mock_post.call_args.kwargs['files']
|
||||
assert 'name' in files
|
||||
assert files['name'][1] == b'<nzb/>'
|
||||
|
||||
|
||||
def test_sab_get_all_merges_queue_and_history() -> None:
|
||||
"""SAB's queue and history are separate endpoints; the adapter
|
||||
must hit both so completed jobs surface in the global list."""
|
||||
adapter = _sab_with_config()
|
||||
queue_resp = _mock_response(200, {'queue': {'slots': [
|
||||
{'nzo_id': 'q1', 'filename': 'A.nzb', 'status': 'Downloading',
|
||||
'percentage': '10', 'mb': '100', 'mbleft': '90', 'timeleft': '0:01:00'},
|
||||
]}})
|
||||
history_resp = _mock_response(200, {'history': {'slots': [
|
||||
{'nzo_id': 'h1', 'name': 'B.nzb', 'status': 'Completed', 'bytes': 1024},
|
||||
]}})
|
||||
with patch('core.usenet_clients.sabnzbd.http_requests.get',
|
||||
side_effect=[queue_resp, history_resp]):
|
||||
statuses = adapter._get_all_sync()
|
||||
assert [s.id for s in statuses] == ['q1', 'h1']
|
||||
assert [s.state for s in statuses] == ['downloading', 'completed']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NZBGet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _nzbget_with_config(url='http://nzbget:6789', username='u', password='p'):
|
||||
adapter = NZBGetAdapter.__new__(NZBGetAdapter)
|
||||
from itertools import count
|
||||
adapter._id_counter = count(1)
|
||||
adapter._url = url.rstrip('/')
|
||||
adapter._username = username
|
||||
adapter._password = password
|
||||
adapter._category = 'soulsync'
|
||||
return adapter
|
||||
|
||||
|
||||
def test_nzbget_is_configured_requires_all_three() -> None:
|
||||
assert _nzbget_with_config('', 'u', 'p').is_configured() is False
|
||||
assert _nzbget_with_config('http://x', '', 'p').is_configured() is False
|
||||
assert _nzbget_with_config('http://x', 'u', '').is_configured() is False
|
||||
assert _nzbget_with_config('http://x', 'u', 'p').is_configured() is True
|
||||
|
||||
|
||||
def test_nzbget_state_mapping_covers_post_process_phases() -> None:
|
||||
assert nzbget_map('DOWNLOADING') == 'downloading'
|
||||
assert nzbget_map('PAUSED') == 'paused'
|
||||
assert nzbget_map('LOADING_PARS') == 'verifying'
|
||||
assert nzbget_map('REPAIRING') == 'repairing'
|
||||
assert nzbget_map('UNPACKING') == 'extracting'
|
||||
assert nzbget_map('PP_FINISHED') == 'completed'
|
||||
assert nzbget_map('') == 'error'
|
||||
|
||||
|
||||
def test_nzbget_mb_value_prefers_64bit_split() -> None:
|
||||
"""NZBGet ships size as FileSizeHi << 32 | FileSizeLo for clients
|
||||
that need precision past 2 GB. Prefer that over the legacy MB
|
||||
field when both are present."""
|
||||
val = NZBGetAdapter._mb_value({'FileSizeLo': 1024 * 1024, 'FileSizeHi': 0, 'FileSizeMB': 999}, 'FileSize')
|
||||
assert val == 1.0
|
||||
|
||||
|
||||
def test_nzbget_mb_value_falls_back_to_mb() -> None:
|
||||
val = NZBGetAdapter._mb_value({'FileSizeMB': 500}, 'FileSize')
|
||||
assert val == 500.0
|
||||
|
||||
|
||||
def test_nzbget_add_nzb_url_passes_through_unchanged() -> None:
|
||||
adapter = _nzbget_with_config()
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, auth=None, headers=None, timeout=None):
|
||||
captured['payload'] = json
|
||||
return _mock_response(200, {'result': 42})
|
||||
|
||||
with patch('core.usenet_clients.nzbget.http_requests.post', side_effect=fake_post):
|
||||
job_id = _run(adapter.add_nzb('https://x/x.nzb', category='cat'))
|
||||
|
||||
assert job_id == '42'
|
||||
params = captured['payload']['params']
|
||||
assert params[0] == '' # NZBFilename empty when content is a URL
|
||||
assert params[1] == 'https://x/x.nzb'
|
||||
assert params[2] == 'cat'
|
||||
|
||||
|
||||
def test_nzbget_add_nzb_bytes_base64_encodes() -> None:
|
||||
adapter = _nzbget_with_config()
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, auth=None, headers=None, timeout=None):
|
||||
captured['payload'] = json
|
||||
return _mock_response(200, {'result': 7})
|
||||
|
||||
with patch('core.usenet_clients.nzbget.http_requests.post', side_effect=fake_post):
|
||||
_run(adapter.add_nzb(b'<nzb/>', category='cat'))
|
||||
|
||||
params = captured['payload']['params']
|
||||
assert params[0] == 'soulsync.nzb'
|
||||
assert params[1] == base64.b64encode(b'<nzb/>').decode('ascii')
|
||||
|
||||
|
||||
def test_nzbget_remove_uses_groupfinal_when_deleting_files() -> None:
|
||||
"""``GroupFinalDelete`` deletes downloaded data on disk;
|
||||
``GroupDelete`` just removes the queue entry. The adapter must
|
||||
pick the right one based on the ``delete_files`` flag."""
|
||||
adapter = _nzbget_with_config()
|
||||
with patch.object(adapter, '_rpc_sync', return_value=True) as mock_rpc:
|
||||
adapter._remove_sync('42', delete_files=True)
|
||||
adapter._remove_sync('42', delete_files=False)
|
||||
cmds = [c.args[1][0] for c in mock_rpc.call_args_list]
|
||||
assert cmds == ['GroupFinalDelete', 'GroupDelete']
|
||||
|
||||
|
||||
def test_nzbget_parse_group_computes_progress() -> None:
|
||||
adapter = _nzbget_with_config()
|
||||
status = adapter._parse_group({
|
||||
'NZBID': 99,
|
||||
'NZBName': 'Album.nzb',
|
||||
'Status': 'DOWNLOADING',
|
||||
'FileSizeLo': 200 * 1024 * 1024, 'FileSizeHi': 0,
|
||||
'RemainingSizeLo': 100 * 1024 * 1024, 'RemainingSizeHi': 0,
|
||||
'DownloadRate': 500_000,
|
||||
'DestDir': '/incomplete',
|
||||
'Category': 'soulsync',
|
||||
})
|
||||
assert status.id == '99'
|
||||
assert status.state == 'downloading'
|
||||
assert status.size == 200 * 1024 * 1024
|
||||
assert status.downloaded == 100 * 1024 * 1024
|
||||
assert status.progress == pytest.approx(0.5)
|
||||
assert status.download_speed == 500_000
|
||||
|
||||
|
||||
def test_nzbget_remove_rejects_non_numeric_id() -> None:
|
||||
"""NZBGet IDs are ints; passing a string id like 'abc' must
|
||||
fail fast instead of corrupting the editqueue call."""
|
||||
adapter = _nzbget_with_config()
|
||||
with patch.object(adapter, '_rpc_sync') as mock_rpc:
|
||||
assert adapter._remove_sync('not-a-number', delete_files=False) is False
|
||||
mock_rpc.assert_not_called()
|
||||
|
|
@ -3415,6 +3415,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.6.0': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Regression tests for the new indexer + downloader plumbing', desc: 'mocked unit tests for Prowlarr + all five downloader adapters (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet). 54 tests pin the state-mapping tables, the parse logic, and the protocol quirks each client needs handled (qBit Referer header, Transmission session-id renegotiation, Deluge magnet-vs-URL method split, SAB queue+history merge, NZBGet 64-bit size fields). next time anyone touches one of these adapters, CI catches breakage before it hits a real downloader.' },
|
||||
{ title: 'Usenet client adapters (SABnzbd, NZBGet)', desc: 'third commit in the torrent + usenet rollout. SoulSync now talks to SABnzbd and NZBGet through a sibling adapter contract that mirrors the torrent adapter set — pick one downloader in Settings → Indexers & Downloaders, fill in its API key (SABnzbd) or username + password (NZBGet), and Test Connection confirms the link. all three layers are now stood up: Prowlarr finds releases, the torrent adapter and the usenet adapter each know how to ship work to the underlying client. next commit wires Prowlarr search → adapter dispatch → archive extraction so the new sources actually download. job state mapping covers SABnzbd queue + history and NZBGet groups + history, including the verify/repair/unpack phases that are unique to usenet.' },
|
||||
{ title: 'Torrent client adapters (qBittorrent, Transmission, Deluge)', desc: 'second commit in the torrent + usenet rollout. SoulSync can now talk to any of the three big torrent clients through a single adapter contract — pick which one you use in Settings → Indexers & Downloaders, paste your WebUI URL and credentials, and Test Connection confirms the link. each adapter handles its own auth quirk (qBit cookies, Transmission session-id, Deluge JSON-RPC) and maps native state strings onto a uniform set so the rest of the app stays client-agnostic. no download wiring yet — that gets layered on once the usenet client adapters land in the next commit.' },
|
||||
{ title: 'Prowlarr integration', desc: 'new Indexers & Downloaders tab in Settings. point SoulSync at your Prowlarr instance with a URL and API key, and you can browse the indexers Prowlarr exposes from inside the app. this is the search half of the upcoming torrent and usenet download sources — wires up the indexer list now so later commits can plug the download flow on top. Lidarr already pulls from its own indexers; Prowlarr unlocks the same search surface to the rest of the download pipeline.' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue