Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports. Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading. Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches. Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
479 lines
21 KiB
Python
479 lines
21 KiB
Python
"""Tests for ``core/download_plugins/torrent.py`` and ``usenet.py``.
|
|
|
|
Both plugins compose a Prowlarr client + an adapter + the archive
|
|
pipeline. The tests mock the Prowlarr client and the active adapter
|
|
factory so we can pin the projection logic, filename encoding /
|
|
decoding, finalize path, and the cancel / clear lifecycle without
|
|
touching the network or filesystem (beyond ``tmp_path``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from core.download_plugins.torrent import (
|
|
TorrentDownloadPlugin,
|
|
_adapter_state_to_display,
|
|
_decode_filename,
|
|
_FILENAME_SEP,
|
|
_guess_quality_from_title,
|
|
_parse_release_title,
|
|
)
|
|
from core.download_plugins.usenet import UsenetDownloadPlugin
|
|
from core.prowlarr_client import ProwlarrSearchResult
|
|
from core.torrent_clients.base import TorrentStatus
|
|
from core.usenet_clients.base import UsenetStatus
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pure helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_decode_filename_splits_on_separator() -> None:
|
|
url, display = _decode_filename(f"https://x/y.torrent{_FILENAME_SEP}Album Name")
|
|
assert url == 'https://x/y.torrent'
|
|
assert display == 'Album Name'
|
|
|
|
|
|
def test_decode_filename_without_separator_returns_none_url() -> None:
|
|
url, display = _decode_filename('just a name')
|
|
assert url is None
|
|
assert display == 'just a name'
|
|
|
|
|
|
def test_decode_filename_handles_magnet_with_embedded_separators() -> None:
|
|
"""Magnet URIs contain ``=`` and ``&`` but no ``||`` — so a
|
|
magnet must round-trip cleanly through the encoder."""
|
|
magnet = 'magnet:?xt=urn:btih:abc123&dn=Album+Name'
|
|
encoded = f"{magnet}{_FILENAME_SEP}Display"
|
|
url, display = _decode_filename(encoded)
|
|
assert url == magnet
|
|
assert display == 'Display'
|
|
|
|
|
|
def test_guess_quality_from_title() -> None:
|
|
assert _guess_quality_from_title('Album [FLAC]') == 'flac'
|
|
assert _guess_quality_from_title('Album 24-bit Hi-Res') == 'flac'
|
|
assert _guess_quality_from_title('Album [MP3 320]') == 'mp3'
|
|
assert _guess_quality_from_title('Album [AAC 256]') == 'aac'
|
|
assert _guess_quality_from_title('Album [OGG]') == 'ogg'
|
|
# Default fallback so quality_score doesn't crash on bare titles.
|
|
assert _guess_quality_from_title('Just A Title') == 'mp3'
|
|
assert _guess_quality_from_title('') == 'mp3'
|
|
|
|
|
|
def test_parse_release_title_splits_artist_dash_title() -> None:
|
|
"""Most release titles follow 'Artist - Title' / 'Artist - Album'."""
|
|
assert _parse_release_title('Danny Brown - Atrocity Exhibition') == ('Danny Brown', 'Atrocity Exhibition')
|
|
assert _parse_release_title('Kendrick Lamar - DAMN.') == ('Kendrick Lamar', 'DAMN.')
|
|
|
|
|
|
def test_parse_release_title_strips_trailing_tags() -> None:
|
|
"""Quality / year tags at the end shouldn't pollute the title."""
|
|
artist, title = _parse_release_title('Danny Brown - Atrocity Exhibition [FLAC]')
|
|
assert artist == 'Danny Brown'
|
|
assert title == 'Atrocity Exhibition'
|
|
artist, title = _parse_release_title('Danny Brown - Atrocity Exhibition (2016)')
|
|
assert artist == 'Danny Brown'
|
|
assert title == 'Atrocity Exhibition'
|
|
|
|
|
|
def test_parse_release_title_handles_no_dash() -> None:
|
|
"""Some indexers post bare titles. Caller should fall back to
|
|
the indexer name as the 'artist' field."""
|
|
artist, title = _parse_release_title('JustATitle')
|
|
assert artist == ''
|
|
assert title == 'JustATitle'
|
|
|
|
|
|
def test_parse_release_title_handles_dashes_in_title() -> None:
|
|
"""Track titles can themselves contain dashes — only split on
|
|
the FIRST one so subtitles survive."""
|
|
artist, title = _parse_release_title('Artist - Title - Live Version')
|
|
assert artist == 'Artist'
|
|
assert title == 'Title - Live Version'
|
|
|
|
|
|
def test_parse_release_title_rejects_url_prefix() -> None:
|
|
"""Defensive: if a URL somehow lands in the title field, refuse
|
|
to call it an artist."""
|
|
artist, title = _parse_release_title('https://example.com/x - Album')
|
|
assert artist == ''
|
|
|
|
|
|
def test_adapter_state_mapping_covers_complete_states() -> None:
|
|
assert _adapter_state_to_display('downloading') == 'InProgress, Downloading'
|
|
assert _adapter_state_to_display('seeding') == 'Completed, Succeeded'
|
|
assert _adapter_state_to_display('completed') == 'Completed, Succeeded'
|
|
assert _adapter_state_to_display('error') == 'Completed, Errored'
|
|
assert _adapter_state_to_display('stalled') == 'InProgress, Stalled'
|
|
# Unknown state falls through with title-casing rather than crashing.
|
|
assert _adapter_state_to_display('weird') == 'Weird'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Torrent plugin — search projection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_torrent_result(**overrides) -> ProwlarrSearchResult:
|
|
base = dict(
|
|
guid='guid-1', title='Danny Brown - Atrocity Exhibition [FLAC]', indexer_id=3,
|
|
indexer_name='Indexer', protocol='torrent',
|
|
download_url='https://x/y.torrent', magnet_uri=None,
|
|
info_url=None, size=500_000_000, seeders=12, leechers=3,
|
|
grabs=100, publish_date='2026-01-01', categories=[3040],
|
|
raw={},
|
|
)
|
|
base.update(overrides)
|
|
return ProwlarrSearchResult(**base)
|
|
|
|
|
|
def test_torrent_project_results_drops_non_torrent_protocol() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
results = [
|
|
_make_torrent_result(),
|
|
_make_torrent_result(protocol='usenet', title='Usenet Album'),
|
|
]
|
|
tracks, albums = plugin._project_results(results)
|
|
assert len(tracks) == 1
|
|
assert tracks[0].title == 'Atrocity Exhibition'
|
|
assert tracks[0].artist == 'Danny Brown'
|
|
assert len(albums) == 1
|
|
|
|
|
|
def test_torrent_project_results_drops_releases_without_download_url() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
results = [_make_torrent_result(download_url=None, magnet_uri=None)]
|
|
tracks, albums = plugin._project_results(results)
|
|
assert tracks == []
|
|
assert albums == []
|
|
|
|
|
|
def test_torrent_project_results_prefers_magnet_when_available() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
magnet = 'magnet:?xt=urn:btih:abc'
|
|
results = [_make_torrent_result(magnet_uri=magnet, download_url='https://x/y.torrent')]
|
|
tracks, _ = plugin._project_results(results)
|
|
url, _ = _decode_filename(tracks[0].filename)
|
|
assert url == magnet
|
|
|
|
|
|
def test_torrent_project_results_encodes_url_and_title_in_filename() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
tracks, _ = plugin._project_results([_make_torrent_result()])
|
|
url, display = _decode_filename(tracks[0].filename)
|
|
assert url == 'https://x/y.torrent'
|
|
assert display == 'Danny Brown - Atrocity Exhibition [FLAC]'
|
|
|
|
|
|
def test_torrent_project_falls_back_to_indexer_name_when_title_lacks_dash() -> None:
|
|
"""When the title has no 'Artist -' prefix we'd auto-parse the
|
|
filename (which starts with the indexer download URL) and end
|
|
up showing the URL in the UI's 'by' field. Pre-filling artist
|
|
with the indexer name avoids that."""
|
|
plugin = TorrentDownloadPlugin()
|
|
tracks, _ = plugin._project_results([_make_torrent_result(title='JustATitle')])
|
|
assert tracks[0].artist == 'Indexer'
|
|
# And the URL is definitely not the artist.
|
|
assert 'http' not in tracks[0].artist
|
|
assert '||' not in tracks[0].artist
|
|
|
|
|
|
def test_torrent_project_results_neutralizes_soulseek_specific_fields() -> None:
|
|
"""TrackResult.quality_score punishes results with no upload
|
|
slots; torrent results don't have that concept so the
|
|
projection has to fill in non-punishing neutral values."""
|
|
plugin = TorrentDownloadPlugin()
|
|
tracks, _ = plugin._project_results([_make_torrent_result(seeders=0)])
|
|
# seeders=0 means we should still hand the picker something
|
|
# usable. free_upload_slots floors at 1 to avoid the 0-slot
|
|
# penalty applied to dead Soulseek peers.
|
|
assert tracks[0].free_upload_slots >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Torrent plugin — is_configured / check_connection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_torrent_is_configured_requires_both_sides() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=False), \
|
|
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=None):
|
|
assert plugin.is_configured() is False
|
|
fake_adapter = MagicMock()
|
|
fake_adapter.is_configured.return_value = False
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=True), \
|
|
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
|
|
assert plugin.is_configured() is False
|
|
fake_adapter.is_configured.return_value = True
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=True), \
|
|
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
|
|
assert plugin.is_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Torrent plugin — finalize / cancel / clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_torrent_finalize_picks_first_audio_file(tmp_path: Path) -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
# Seed an in-flight download row
|
|
plugin.active_downloads['dl-1'] = {
|
|
'id': 'dl-1', 'filename': 'x', 'username': 'torrent',
|
|
'display_name': 'X', 'state': 'InProgress, Downloading',
|
|
'progress': 50.0, 'size': 0, 'transferred': 0, 'speed': 0,
|
|
'file_path': None, 'torrent_hash': 'h1', 'error': None,
|
|
}
|
|
# Drop two audio files in the save dir
|
|
(tmp_path / 'b.flac').write_bytes(b'fLaC')
|
|
(tmp_path / 'a.mp3').write_bytes(b'ID3')
|
|
plugin._finalize_download('dl-1', str(tmp_path))
|
|
row = plugin.active_downloads['dl-1']
|
|
assert row['state'] == 'Completed, Succeeded'
|
|
assert row['progress'] == 100.0
|
|
# Walker sorts → 'a.mp3' wins as first.
|
|
assert row['file_path'].endswith('a.mp3')
|
|
|
|
|
|
def test_torrent_finalize_marks_error_when_no_audio(tmp_path: Path) -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
plugin.active_downloads['dl-1'] = {
|
|
'id': 'dl-1', 'filename': 'x', 'username': 'torrent',
|
|
'display_name': 'X', 'state': 'InProgress, Downloading',
|
|
'progress': 50.0, 'size': 0, 'transferred': 0, 'speed': 0,
|
|
'file_path': None, 'torrent_hash': 'h1', 'error': None,
|
|
}
|
|
# tmp_path has no audio files
|
|
plugin._finalize_download('dl-1', str(tmp_path))
|
|
assert plugin.active_downloads['dl-1']['state'] == 'Completed, Errored'
|
|
assert 'No audio files' in plugin.active_downloads['dl-1']['error']
|
|
|
|
|
|
def test_torrent_finalize_marks_error_when_save_path_missing() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
plugin.active_downloads['dl-1'] = {
|
|
'id': 'dl-1', 'filename': 'x', 'username': 'torrent',
|
|
'display_name': 'X', 'state': 'InProgress, Downloading',
|
|
'progress': 50.0, 'size': 0, 'transferred': 0, 'speed': 0,
|
|
'file_path': None, 'torrent_hash': 'h1', 'error': None,
|
|
}
|
|
plugin._finalize_download('dl-1', None)
|
|
assert plugin.active_downloads['dl-1']['state'] == 'Completed, Errored'
|
|
assert 'no save_path' in plugin.active_downloads['dl-1']['error'].lower()
|
|
|
|
|
|
def test_torrent_clear_completed_drops_only_done_rows() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
plugin.active_downloads['a'] = {'id': 'a', 'state': 'InProgress, Downloading'}
|
|
plugin.active_downloads['b'] = {'id': 'b', 'state': 'Completed, Succeeded'}
|
|
plugin.active_downloads['c'] = {'id': 'c', 'state': 'Completed, Errored'}
|
|
plugin.active_downloads['d'] = {'id': 'd', 'state': 'Cancelled'}
|
|
_run(plugin.clear_all_completed_downloads())
|
|
assert list(plugin.active_downloads.keys()) == ['a']
|
|
|
|
|
|
def test_torrent_get_all_returns_status_objects() -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
plugin.active_downloads['a'] = {
|
|
'id': 'a', 'filename': 'f', 'username': 'torrent',
|
|
'state': 'InProgress, Downloading', 'progress': 50.0,
|
|
'size': 100, 'transferred': 50, 'speed': 1000,
|
|
'file_path': None,
|
|
}
|
|
statuses = _run(plugin.get_all_downloads())
|
|
assert len(statuses) == 1
|
|
assert statuses[0].id == 'a'
|
|
assert statuses[0].progress == 50.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Usenet plugin — projection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_usenet_result(**overrides) -> ProwlarrSearchResult:
|
|
base = dict(
|
|
guid='guid-u', title='Some Artist - Some Album', indexer_id=5,
|
|
indexer_name='UsenetIndexer', protocol='usenet',
|
|
download_url='https://x/y.nzb', magnet_uri=None,
|
|
info_url=None, size=400_000_000, seeders=None, leechers=None,
|
|
grabs=42, publish_date='2026-01-01', categories=[3010],
|
|
raw={},
|
|
)
|
|
base.update(overrides)
|
|
return ProwlarrSearchResult(**base)
|
|
|
|
|
|
def test_usenet_project_drops_torrent_protocol() -> None:
|
|
plugin = UsenetDownloadPlugin()
|
|
results = [_make_usenet_result(), _make_usenet_result(protocol='torrent', title='T')]
|
|
tracks, albums = plugin._project_results(results)
|
|
assert len(tracks) == 1
|
|
assert tracks[0].username == 'usenet'
|
|
|
|
|
|
def test_usenet_project_drops_results_without_download_url() -> None:
|
|
"""Usenet plugins reject magnet-only results entirely — NZBs
|
|
don't have a magnet equivalent."""
|
|
plugin = UsenetDownloadPlugin()
|
|
results = [_make_usenet_result(download_url=None)]
|
|
tracks, _ = plugin._project_results(results)
|
|
assert tracks == []
|
|
|
|
|
|
def test_usenet_project_encodes_url_in_filename() -> None:
|
|
plugin = UsenetDownloadPlugin()
|
|
tracks, _ = plugin._project_results([_make_usenet_result()])
|
|
url, display = _decode_filename(tracks[0].filename)
|
|
assert url == 'https://x/y.nzb'
|
|
assert display == 'Some Artist - Some Album'
|
|
# Artist + title should be parsed out, not auto-extracted from filename.
|
|
assert tracks[0].artist == 'Some Artist'
|
|
assert tracks[0].title == 'Some Album'
|
|
|
|
|
|
def test_usenet_finalize_picks_first_audio_file(tmp_path: Path) -> None:
|
|
"""Same finalize contract as torrent — sanity check the shared
|
|
helper path works for usenet too."""
|
|
plugin = UsenetDownloadPlugin()
|
|
plugin.active_downloads['u-1'] = {
|
|
'id': 'u-1', 'filename': 'x', 'username': 'usenet',
|
|
'display_name': 'X', 'state': 'InProgress, Downloading',
|
|
'progress': 50.0, 'size': 0, 'transferred': 0, 'speed': 0,
|
|
'file_path': None, 'job_id': 'j1', 'error': None,
|
|
}
|
|
(tmp_path / 'track1.flac').write_bytes(b'fLaC')
|
|
plugin._finalize_download('u-1', str(tmp_path))
|
|
assert plugin.active_downloads['u-1']['state'] == 'Completed, Succeeded'
|
|
assert plugin.active_downloads['u-1']['file_path'].endswith('track1.flac')
|
|
|
|
|
|
def test_usenet_is_configured_requires_both_sides() -> None:
|
|
plugin = UsenetDownloadPlugin()
|
|
fake_adapter = MagicMock()
|
|
fake_adapter.is_configured.return_value = True
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=False), \
|
|
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=fake_adapter):
|
|
assert plugin.is_configured() is False
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=True), \
|
|
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=None):
|
|
assert plugin.is_configured() is False
|
|
with patch.object(plugin._prowlarr, 'is_configured', return_value=True), \
|
|
patch('core.download_plugins.usenet.get_active_usenet_adapter', return_value=fake_adapter):
|
|
assert plugin.is_configured() is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin conformance — both must satisfy the DownloadSourcePlugin Protocol
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_plugins_conform_to_protocol() -> None:
|
|
from core.download_plugins.base import DownloadSourcePlugin
|
|
assert isinstance(TorrentDownloadPlugin(), DownloadSourcePlugin)
|
|
assert isinstance(UsenetDownloadPlugin(), DownloadSourcePlugin)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry — both should register cleanly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_torrent_album_pick_prefers_seeded_flac(tmp_path: Path) -> None:
|
|
"""Album bundle picker prefers high-seeded FLAC over low-seeded MP3
|
|
of comparable size — protects against picking a dead torrent."""
|
|
from core.download_plugins.album_bundle import pick_best_album_release
|
|
from core.download_plugins.torrent import _guess_quality_from_title
|
|
flac = _make_torrent_result(title='Kendrick Lamar - GNX [FLAC]', size=400_000_000, seeders=120)
|
|
mp3 = _make_torrent_result(title='Kendrick Lamar - GNX [MP3 320]', size=120_000_000, seeders=5, guid='guid-2')
|
|
picked = pick_best_album_release([flac, mp3], _guess_quality_from_title)
|
|
assert picked is flac
|
|
|
|
|
|
def test_torrent_album_pick_drops_too_small() -> None:
|
|
"""Single-track torrents (~10 MB) shouldn't be picked when the user
|
|
is downloading a whole album — the size floor (40 MB) catches them."""
|
|
from core.download_plugins.album_bundle import pick_best_album_release
|
|
from core.download_plugins.torrent import _guess_quality_from_title
|
|
single = _make_torrent_result(title='Kendrick Lamar - HUMBLE', size=10_000_000, seeders=500)
|
|
album = _make_torrent_result(title='Kendrick Lamar - DAMN [MP3]', size=120_000_000, seeders=50, guid='guid-2')
|
|
picked = pick_best_album_release([single, album], _guess_quality_from_title)
|
|
assert picked is album
|
|
|
|
|
|
def test_torrent_album_pick_falls_back_when_all_outside_size_range() -> None:
|
|
"""If every candidate is below the floor (e.g. all results are
|
|
singles), pick the most-seeded one rather than returning None —
|
|
user still wants a download even if it's a track torrent."""
|
|
from core.download_plugins.album_bundle import pick_best_album_release
|
|
from core.download_plugins.torrent import _guess_quality_from_title
|
|
small_a = _make_torrent_result(title='X [MP3]', size=8_000_000, seeders=5)
|
|
small_b = _make_torrent_result(title='Y [MP3]', size=9_000_000, seeders=80, guid='guid-2')
|
|
picked = pick_best_album_release([small_a, small_b], _guess_quality_from_title)
|
|
assert picked is small_b
|
|
|
|
|
|
def test_unique_staging_path_handles_collision(tmp_path: Path) -> None:
|
|
from core.download_plugins.album_bundle import unique_staging_path
|
|
src = tmp_path / 'src' / 'track.flac'
|
|
src.parent.mkdir()
|
|
src.write_bytes(b'fLaC')
|
|
dest_dir = tmp_path / 'staging'
|
|
dest_dir.mkdir()
|
|
# First call returns the natural name.
|
|
first = unique_staging_path(dest_dir, src)
|
|
assert first == dest_dir / 'track.flac'
|
|
first.write_bytes(b'fLaC')
|
|
# Second call picks a non-colliding suffix.
|
|
second = unique_staging_path(dest_dir, src)
|
|
assert second == dest_dir / 'track_1.flac'
|
|
|
|
|
|
def test_torrent_album_to_staging_short_circuits_when_not_configured() -> None:
|
|
"""The gate must refuse to operate when Prowlarr isn't set up —
|
|
every later call would hit the network with empty creds."""
|
|
plugin = TorrentDownloadPlugin()
|
|
with patch.object(plugin, 'is_configured', return_value=False):
|
|
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', '/tmp/staging')
|
|
assert outcome['success'] is False
|
|
assert 'not configured' in outcome['error'].lower()
|
|
|
|
|
|
def test_torrent_album_to_staging_ignores_candidates_without_download_url(tmp_path: Path) -> None:
|
|
plugin = TorrentDownloadPlugin()
|
|
fake_adapter = MagicMock()
|
|
fake_adapter.is_configured.return_value = True
|
|
with patch.object(plugin, 'is_configured', return_value=True), \
|
|
patch.object(plugin._prowlarr, 'search', new=AsyncMock(return_value=[
|
|
_make_torrent_result(download_url=None, magnet_uri=None),
|
|
])), \
|
|
patch('core.download_plugins.torrent.get_active_torrent_adapter', return_value=fake_adapter):
|
|
outcome = plugin.download_album_to_staging('GNX', 'Kendrick Lamar', str(tmp_path))
|
|
|
|
assert outcome['success'] is False
|
|
assert 'No torrent results' in outcome['error']
|
|
fake_adapter.add_torrent.assert_not_called()
|
|
|
|
|
|
def test_registry_includes_torrent_and_usenet() -> None:
|
|
"""The registry decides what shows up in the orchestrator's
|
|
iteration helpers. If we forget to register a new plugin the
|
|
download source dropdown will silently no-op."""
|
|
from core.download_plugins.registry import build_default_registry
|
|
registry = build_default_registry()
|
|
names = registry.names()
|
|
assert 'torrent' in names
|
|
assert 'usenet' in names
|