You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/test_prowlarr_client.py

224 lines
7.8 KiB

"""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