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_album_bundle_dispatch.py

366 lines
14 KiB

"""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_album_bundle_mode() -> None:
for mode in ('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_usenet_and_soulseek() -> 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
assert is_eligible(mode='soulseek', 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_album_bundle_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': 'youtube'}),
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_fallback_failure_returns_false_for_per_track_flow() -> None:
state = _FakeState()
plugin = MagicMock()
plugin.download_album_to_staging.return_value = {
'success': False,
'files': [],
'error': 'No complete Soulseek album folders found',
'fallback': True,
}
result = try_dispatch(
batch_id='b1', is_album=True,
album_context={'name': 'Album'}, artist_context={'name': 'Artist'},
config_get=_config({'download_source.mode': 'soulseek'}),
plugin_resolver=lambda _name: plugin, state=state,
)
assert result is False
assert state.failed_with == ''
assert state.fields['phase'] == 'analysis'
assert state.fields['album_bundle_state'] == 'fallback'
assert state.fields['album_bundle_error'] == 'No complete Soulseek album folders found'
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_source_override_uses_first_hybrid_source() -> None:
state = _FakeState()
plugin = MagicMock()
plugin.download_album_to_staging.return_value = {'success': True, 'files': ['/x']}
seen = []
try_dispatch(
batch_id='b1', is_album=True,
album_context={'name': 'GNX'}, artist_context={'name': 'Kendrick Lamar'},
config_get=_config({'download_source.mode': 'hybrid'}),
plugin_resolver=lambda name: seen.append(name) or plugin,
state=state,
source_override='soulseek',
)
assert seen == ['soulseek']
assert state.fields['album_bundle_source'] == 'soulseek'
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'