diff --git a/tests/test_prowlarr_client.py b/tests/test_prowlarr_client.py new file mode 100644 index 00000000..eb3fe6c0 --- /dev/null +++ b/tests/test_prowlarr_client.py @@ -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 diff --git a/tests/test_torrent_client_adapters.py b/tests/test_torrent_client_adapters.py new file mode 100644 index 00000000..0f813b69 --- /dev/null +++ b/tests/test_torrent_client_adapters.py @@ -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' diff --git a/tests/test_usenet_client_adapters.py b/tests/test_usenet_client_adapters.py new file mode 100644 index 00000000..8dfb28c6 --- /dev/null +++ b/tests/test_usenet_client_adapters.py @@ -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'', 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'' + + +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'', category='cat')) + + params = captured['payload']['params'] + assert params[0] == 'soulsync.nzb' + assert params[1] == base64.b64encode(b'').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() diff --git a/webui/static/helper.js b/webui/static/helper.js index b8cd1ce7..17bc8026 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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.' },