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:
Broque Thomas 2026-05-20 15:43:18 -07:00
parent 7a3ce50f71
commit e4ca56b499
4 changed files with 860 additions and 0 deletions

View 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

View 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'

View 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()

View file

@ -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.' },