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
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Tests for ``core/downloads/album_bundle_dispatch.py``.
|
|
|
|
Pins the gate predicate, the resolution + run flow, and the
|
|
fail / fall-through return contract. Mocks the config, plugin
|
|
resolver, and state access so the dispatcher is testable without
|
|
standing up runtime_state or a real plugin.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from core.downloads.album_bundle_dispatch import (
|
|
BatchStateAccess,
|
|
is_eligible,
|
|
try_dispatch,
|
|
)
|
|
|
|
|
|
class _FakeState:
|
|
"""In-memory ``BatchStateAccess`` for tests — records every
|
|
update so assertions can check the sequence of fields set."""
|
|
|
|
def __init__(self) -> None:
|
|
self.fields: dict = {}
|
|
self.update_calls: list = []
|
|
self.failed_with: str = ''
|
|
|
|
def update_fields(self, batch_id: str, fields: dict) -> None:
|
|
self.update_calls.append((batch_id, dict(fields)))
|
|
self.fields.update(fields)
|
|
|
|
def mark_failed(self, batch_id: str, error: str) -> None:
|
|
self.failed_with = error
|
|
self.fields['phase'] = 'failed'
|
|
self.fields['error'] = error
|
|
self.fields['album_bundle_state'] = 'failed'
|
|
|
|
|
|
def _config(values: dict):
|
|
"""Build a config_get callable from a flat dict."""
|
|
def _get(key, default=None):
|
|
return values.get(key, default)
|
|
return _get
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# is_eligible pure predicate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_is_eligible_requires_album_flag() -> None:
|
|
assert is_eligible(mode='torrent', is_album=False,
|
|
album_name='X', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_requires_torrent_or_usenet_mode() -> None:
|
|
for mode in ('soulseek', 'youtube', 'tidal', 'qobuz', 'hifi',
|
|
'deezer_dl', 'amazon', 'lidarr', 'soundcloud', 'hybrid'):
|
|
assert is_eligible(mode=mode, is_album=True,
|
|
album_name='X', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_accepts_torrent_and_usenet() -> None:
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
assert is_eligible(mode='usenet', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
|
|
|
|
def test_is_eligible_requires_non_empty_names() -> None:
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='', artist_name='Y') is False
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name='X', artist_name='') is False
|
|
assert is_eligible(mode='torrent', is_album=True,
|
|
album_name=' ', artist_name='Y') is False
|
|
|
|
|
|
def test_is_eligible_case_insensitive_mode() -> None:
|
|
assert is_eligible(mode='TORRENT', is_album=True,
|
|
album_name='X', artist_name='Y') is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# try_dispatch — gate evaluation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_returns_false_when_not_album() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=False,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
plugin.download_album_to_staging.assert_not_called()
|
|
|
|
|
|
def test_dispatch_returns_false_for_non_torrent_modes() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'soulseek'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_plugin_missing() -> None:
|
|
"""No plugin available → fall through to per-track flow with a
|
|
warning. The state SHOULD NOT have been touched."""
|
|
state = _FakeState()
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: None, state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_plugin_lacks_method() -> None:
|
|
state = _FakeState()
|
|
# Plugin that doesn't implement download_album_to_staging.
|
|
class _LegacyPlugin:
|
|
pass
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: _LegacyPlugin(), state=state,
|
|
)
|
|
assert result is False
|
|
assert state.update_calls == []
|
|
|
|
|
|
def test_dispatch_returns_false_when_resolver_raises() -> None:
|
|
"""Plugin resolution can fail (registry not initialised); we log
|
|
and fall through rather than crashing the master worker."""
|
|
state = _FakeState()
|
|
def _boom(_name):
|
|
raise RuntimeError("registry not initialised")
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'X'}, artist_context={'name': 'Y'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=_boom, state=state,
|
|
)
|
|
assert result is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# try_dispatch — success / failure paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_dispatch_success_returns_false_so_per_track_can_run() -> None:
|
|
"""Success → master worker should CONTINUE to per-track flow so
|
|
each task can hit try_staging_match and find its file."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {
|
|
'success': True, 'files': ['/tmp/a.flac', '/tmp/b.flac'], 'error': None,
|
|
}
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({
|
|
'download_source.mode': 'torrent',
|
|
'import.staging_path': '/staging/path',
|
|
}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is False
|
|
# Plugin was called with the right args.
|
|
args = plugin.download_album_to_staging.call_args
|
|
assert args.args[0] == 'GNX'
|
|
assert args.args[1] == 'Kendrick Lamar'
|
|
assert args.args[2].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
|
|
# Phase transitioned through searching → analysis.
|
|
assert state.fields['phase'] == 'analysis'
|
|
assert state.fields['album_bundle_state'] == 'staged'
|
|
assert state.fields['album_bundle_source'] == 'torrent'
|
|
assert state.fields['album_bundle_private_staging'] is True
|
|
assert state.fields['album_bundle_staging_path'].replace('\\', '/').endswith('storage/album_bundle_staging/b1')
|
|
assert state.failed_with == ''
|
|
|
|
|
|
def test_dispatch_uses_configured_private_album_bundle_staging_root() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/tmp/a.flac']}
|
|
|
|
try_dispatch(
|
|
batch_id='batch:with/slash', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({
|
|
'download_source.mode': 'torrent',
|
|
'download_source.album_bundle_staging_path': '/private/staging',
|
|
}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
|
|
staging_arg = plugin.download_album_to_staging.call_args.args[2].replace('\\', '/')
|
|
assert staging_arg == '/private/staging/batch_with_slash'
|
|
assert state.fields['album_bundle_staging_path'].replace('\\', '/') == staging_arg
|
|
|
|
|
|
def test_dispatch_failure_returns_true_so_master_stops() -> None:
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {
|
|
'success': False, 'files': [], 'error': 'No torrent results found',
|
|
}
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is True
|
|
assert state.failed_with == 'No torrent results found'
|
|
assert state.fields['phase'] == 'failed'
|
|
|
|
|
|
def test_dispatch_plugin_exception_treated_as_failure() -> None:
|
|
"""A bug / network error in the plugin must not propagate into
|
|
the master worker — caught + treated as a normal failure so
|
|
the batch reports the error cleanly."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.side_effect = RuntimeError("network down")
|
|
result = try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
assert result is True
|
|
assert 'network down' in state.failed_with
|
|
|
|
|
|
def test_dispatch_strips_whitespace_from_names() -> None:
|
|
"""Trailing whitespace in batch context shouldn't fail the
|
|
eligibility predicate AND should be cleaned before passing to
|
|
the plugin."""
|
|
state = _FakeState()
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
|
|
try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': ' GNX '}, artist_context={'name': ' Kendrick '},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
args = plugin.download_album_to_staging.call_args
|
|
assert args.args[0] == 'GNX'
|
|
assert args.args[1] == 'Kendrick'
|
|
|
|
|
|
def test_dispatch_progress_callback_mirrors_payload_to_state() -> None:
|
|
"""The progress callback the plugin gets must mirror its
|
|
payload onto the batch state under ``album_bundle_*`` keys so
|
|
the Downloads page can render progress while the torrent
|
|
download runs."""
|
|
state = _FakeState()
|
|
captured_emit = {}
|
|
|
|
def _capture(album, artist, staging, emit):
|
|
captured_emit['fn'] = emit
|
|
emit({'state': 'searching', 'release': 'GNX [FLAC]'})
|
|
emit({'state': 'downloading', 'progress': 0.42, 'speed': 1024 * 1024})
|
|
emit({'state': 'staged', 'count': 12})
|
|
return {'success': True, 'files': []}
|
|
|
|
plugin = MagicMock()
|
|
plugin.download_album_to_staging.side_effect = _capture
|
|
try_dispatch(
|
|
batch_id='b1', is_album=True,
|
|
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
|
|
config_get=_config({'download_source.mode': 'torrent'}),
|
|
plugin_resolver=lambda _name: plugin, state=state,
|
|
)
|
|
# State should have seen each of the three lifecycle emissions.
|
|
states_seen = [fields.get('album_bundle_state')
|
|
for _, fields in state.update_calls
|
|
if 'album_bundle_state' in fields]
|
|
assert 'searching' in states_seen
|
|
assert 'downloading' in states_seen
|
|
assert 'staged' in states_seen
|
|
# Numeric progress + release name made it through.
|
|
assert state.fields['album_bundle_release'] == 'GNX [FLAC]'
|
|
assert state.fields['album_bundle_progress'] == 0.42
|
|
assert state.fields['album_bundle_count'] == 12
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Protocol conformance — runtime impl must satisfy the contract
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_runtime_state_impl_matches_protocol() -> None:
|
|
"""Sanity check that the concrete BatchStateAccess impl in
|
|
master.py implements both methods. We don't import master.py
|
|
here (would pull in heavy deps); duck-check on the _FakeState
|
|
instead since it's a sibling impl of the same Protocol."""
|
|
state: BatchStateAccess = _FakeState()
|
|
state.update_fields('b1', {'x': 1})
|
|
state.mark_failed('b1', 'oops')
|
|
assert state.fields['x'] == 1
|
|
assert state.fields['error'] == 'oops'
|