"""Tests for the DownloadEngine skeleton (Phase B). Pinning the engine's state-storage contract: add/update/remove, per-source iteration, find-by-id, plugin registration, lock-held mutations vs lock-released reads. Future phases (C/D/E/F) bolt behavior on top of this surface — these tests stay green and act as the regression net while behavior moves in. """ from __future__ import annotations import threading import pytest from core.download_engine import DownloadEngine # --------------------------------------------------------------------------- # Plugin registration # --------------------------------------------------------------------------- def test_register_plugin_stores_under_source_name(): engine = DownloadEngine() plugin = object() engine.register_plugin('soulseek', plugin) assert engine.get_plugin('soulseek') is plugin assert 'soulseek' in engine.registered_sources() def test_get_plugin_returns_none_for_unknown_source(): engine = DownloadEngine() assert engine.get_plugin('made_up') is None def test_register_plugin_swallows_set_engine_failure(caplog): """Per JohnBaumb: if a plugin's ``set_engine`` callback raises, registration shouldn't take down the engine — the failure gets logged + the plugin stays registered. The plugin's own download() method is responsible for surfacing the missing-engine state to the user (see ``test_download_raises_when_engine_not_wired`` per source). Pinning this so a future refactor can't accidentally propagate the set_engine exception and crash boot. """ class _BrokenSetEngine: def set_engine(self, engine): raise RuntimeError("plugin's set_engine blew up") engine = DownloadEngine() plugin = _BrokenSetEngine() # Should not raise. engine.register_plugin('flaky', plugin) # Plugin still registered + lookupable. assert engine.get_plugin('flaky') is plugin assert 'flaky' in engine.registered_sources() def test_register_plugin_overwrites_on_duplicate(caplog): """Re-registering under the same name overwrites and warns. Not a common path but useful so test fixtures that build a fresh engine can swap a mock plugin in without setup gymnastics.""" engine = DownloadEngine() first = object() second = object() engine.register_plugin('soulseek', first) engine.register_plugin('soulseek', second) assert engine.get_plugin('soulseek') is second # --------------------------------------------------------------------------- # Active-download state — add / get / update / remove # --------------------------------------------------------------------------- def test_add_record_inserts_under_composite_key(): engine = DownloadEngine() engine.add_record('youtube', 'dl-1', {'state': 'Initializing', 'progress': 0.0}) rec = engine.get_record('youtube', 'dl-1') assert rec is not None assert rec['state'] == 'Initializing' assert rec['progress'] == 0.0 def test_get_record_returns_shallow_copy(): """Mutating the returned dict must NOT affect engine state. Engine reads should be safe to hold / iterate without locks.""" engine = DownloadEngine() engine.add_record('youtube', 'dl-1', {'state': 'Initializing'}) rec = engine.get_record('youtube', 'dl-1') rec['state'] = 'TamperedByCaller' # Engine state still has the original. fresh = engine.get_record('youtube', 'dl-1') assert fresh['state'] == 'Initializing' def test_update_record_applies_partial_patch(): engine = DownloadEngine() engine.add_record('tidal', 'dl-2', {'state': 'Initializing', 'progress': 0.0, 'file_path': None}) engine.update_record('tidal', 'dl-2', {'state': 'Completed, Succeeded', 'progress': 100.0, 'file_path': '/tmp/song.flac'}) rec = engine.get_record('tidal', 'dl-2') assert rec['state'] == 'Completed, Succeeded' assert rec['progress'] == 100.0 assert rec['file_path'] == '/tmp/song.flac' def test_update_record_is_noop_when_record_removed(): """If a record was removed (e.g. user cancelled mid-download), the worker thread's late update is silently dropped — never raises. Mirrors the per-client `if download_id in active_downloads` guard pattern that's all over the existing clients.""" engine = DownloadEngine() engine.add_record('tidal', 'dl-2', {'state': 'Initializing'}) engine.remove_record('tidal', 'dl-2') # Should not raise. engine.update_record('tidal', 'dl-2', {'state': 'Completed, Succeeded'}) assert engine.get_record('tidal', 'dl-2') is None def test_remove_record_returns_removed_record(): engine = DownloadEngine() engine.add_record('qobuz', 'dl-3', {'state': 'InProgress'}) removed = engine.remove_record('qobuz', 'dl-3') assert removed is not None assert removed['state'] == 'InProgress' assert engine.get_record('qobuz', 'dl-3') is None def test_remove_record_returns_none_when_missing(): engine = DownloadEngine() assert engine.remove_record('qobuz', 'never-existed') is None def test_per_source_locks_dont_block_each_other(): """Per JohnBaumb: each source must have its own lock so a long-held write on one source doesn't block writes on another. Pre-refactor each client owned its own download lock; the engine has to match that semantic. Hold source-A's lock from one thread, then mutate source-B from another thread. Source-B's mutation must complete promptly even while source-A is locked. """ engine = DownloadEngine() engine.add_record('youtube', 'yt-1', {'state': 'InProgress'}) engine.add_record('tidal', 'td-1', {'state': 'InProgress'}) held = threading.Event() release = threading.Event() other_done = threading.Event() def hold_youtube_lock(): with engine._source_lock('youtube'): held.set() release.wait(timeout=2.0) def update_tidal_while_youtube_held(): held.wait(timeout=1.0) engine.update_record('tidal', 'td-1', {'state': 'Completed, Succeeded'}) other_done.set() holder = threading.Thread(target=hold_youtube_lock) other = threading.Thread(target=update_tidal_while_youtube_held) holder.start() other.start() # Tidal write must complete even though YouTube's lock is held. assert other_done.wait(timeout=1.0), ( "Tidal mutation blocked by YouTube's lock — sources are not " "independently shardable" ) release.set() holder.join() other.join() assert engine.get_record('tidal', 'td-1')['state'] == 'Completed, Succeeded' def test_remove_record_drops_empty_source_bucket(): """Per JohnBaumb: nested layout makes per-source iteration O(source_records). Removing the last record for a source must also drop the empty source bucket so future iter_records_for_source calls don't see a stale source key.""" engine = DownloadEngine() engine.add_record('youtube', 'yt-1', {'title': 'A'}) engine.remove_record('youtube', 'yt-1') # Source bucket gone — internal state matches the contract. assert 'youtube' not in engine._records # Public surface still answers correctly. assert list(engine.iter_records_for_source('youtube')) == [] assert engine.get_record('youtube', 'yt-1') is None # --------------------------------------------------------------------------- # Iteration # --------------------------------------------------------------------------- def test_iter_records_for_source_filters_correctly(): engine = DownloadEngine() engine.add_record('youtube', 'yt-1', {'title': 'A'}) engine.add_record('youtube', 'yt-2', {'title': 'B'}) engine.add_record('tidal', 'td-1', {'title': 'C'}) yt_records = list(engine.iter_records_for_source('youtube')) assert len(yt_records) == 2 assert {r['title'] for r in yt_records} == {'A', 'B'} td_records = list(engine.iter_records_for_source('tidal')) assert len(td_records) == 1 assert td_records[0]['title'] == 'C' def test_iter_yields_shallow_copies(): """Iteration returns COPIES — caller can hold the list and mutate each record without affecting engine state. Important: future Phase B3's `get_all_downloads` will iterate then build DownloadStatus objects from the snapshots.""" engine = DownloadEngine() engine.add_record('youtube', 'yt-1', {'title': 'A'}) snapshot = list(engine.iter_records_for_source('youtube')) snapshot[0]['title'] = 'TAMPERED' fresh = engine.get_record('youtube', 'yt-1') assert fresh['title'] == 'A' # --------------------------------------------------------------------------- # Thread safety — basic concurrent-mutation smoke # --------------------------------------------------------------------------- def test_concurrent_adds_dont_lose_records(): """Hammer the engine with concurrent add_record from multiple threads. With proper locking, every record lands in state. Future Phase C BackgroundDownloadWorker spawns N threads doing exactly this kind of mutation.""" engine = DownloadEngine() def add_records(source, base): for i in range(50): engine.add_record(source, f'{base}-{i}', {'i': i}) threads = [ threading.Thread(target=add_records, args=(f'src-{n}', f'dl-{n}')) for n in range(4) ] for t in threads: t.start() for t in threads: t.join() total = sum( 1 for n in range(4) for _ in engine.iter_records_for_source(f'src-{n}') ) assert total == 4 * 50 # 200 records, none lost # --------------------------------------------------------------------------- # Cross-source query dispatch (Phase B2) # --------------------------------------------------------------------------- def _run_async(coro): import asyncio loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() class _FakePlugin: """Minimal plugin double for engine query tests. Exposes the methods engine.get_all_downloads / get_download_status / cancel_download / clear_all_completed_downloads call.""" def __init__(self, name, configured=True, downloads=None, cancel_result=True, clear_result=True): self.name = name self._configured = configured self._downloads = downloads or [] self._cancel_result = cancel_result self._clear_result = clear_result self.cancel_calls = [] self.clear_calls = 0 def is_configured(self): return self._configured async def get_all_downloads(self): return list(self._downloads) async def get_download_status(self, download_id): for d in self._downloads: if getattr(d, 'id', None) == download_id: return d return None async def cancel_download(self, download_id, source_hint, remove): self.cancel_calls.append((download_id, source_hint, remove)) return self._cancel_result async def clear_all_completed_downloads(self): self.clear_calls += 1 return self._clear_result class _FakeStatus: def __init__(self, id, source): self.id = id self.source = source def test_engine_get_all_downloads_aggregates_across_plugins(): """Engine concatenates every plugin's get_all_downloads output — same behavior as the legacy orchestrator.""" engine = DownloadEngine() yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')]) td_plugin = _FakePlugin('tidal', downloads=[_FakeStatus('td-1', 'tidal'), _FakeStatus('td-2', 'tidal')]) engine.register_plugin('youtube', yt_plugin) engine.register_plugin('tidal', td_plugin) result = _run_async(engine.get_all_downloads()) assert len(result) == 3 assert {r.id for r in result} == {'yt-1', 'td-1', 'td-2'} def test_engine_get_all_downloads_excludes_dont_invoke_plugin(): """Per JohnBaumb: the monitor calls engine.get_all_downloads( exclude=('soulseek',)) AFTER already pulling slskd transfers via the transfers/downloads endpoint. The whole point of the exclude is to NOT touch soulseek's plugin a second time — so the plugin's get_all_downloads must literally not be called when its name is in the exclude list. Pin that semantic explicitly (a test that just checks IDs would pass even if soulseek was called and returned []).""" engine = DownloadEngine() sl_plugin = _FakePlugin('soulseek', downloads=[_FakeStatus('sl-1', 'soulseek')]) yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')]) engine.register_plugin('soulseek', sl_plugin) engine.register_plugin('youtube', yt_plugin) # Sentinel — flips True if get_all_downloads is called on soulseek. soulseek_called = [] original_get_all = sl_plugin.get_all_downloads async def _tracking_get_all(): soulseek_called.append(True) return await original_get_all() sl_plugin.get_all_downloads = _tracking_get_all _run_async(engine.get_all_downloads(exclude=('soulseek',))) assert soulseek_called == [], ( "soulseek's get_all_downloads was invoked despite being in exclude — " "monitor would still double-fetch slskd" ) def test_engine_get_all_downloads_skips_excluded_sources(): """Per JohnBaumb: monitor pulls slskd transfers via the transfers/downloads endpoint earlier in its loop, so engine aggregation must skip soulseek to avoid double-fetching.""" engine = DownloadEngine() sl_plugin = _FakePlugin('soulseek', downloads=[_FakeStatus('sl-1', 'soulseek')]) yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')]) td_plugin = _FakePlugin('tidal', downloads=[_FakeStatus('td-1', 'tidal')]) engine.register_plugin('soulseek', sl_plugin) engine.register_plugin('youtube', yt_plugin) engine.register_plugin('tidal', td_plugin) result = _run_async(engine.get_all_downloads(exclude=('soulseek',))) ids = {r.id for r in result} assert ids == {'yt-1', 'td-1'} assert 'sl-1' not in ids def test_engine_get_all_downloads_swallows_per_plugin_exceptions(): """One plugin throwing must NOT take down the whole list — same defensive behavior as the legacy orchestrator (matched by `try ... except: pass` on every iteration).""" engine = DownloadEngine() class _BrokenPlugin: async def get_all_downloads(self): raise RuntimeError("boom") yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('yt-1', 'youtube')]) engine.register_plugin('broken', _BrokenPlugin()) engine.register_plugin('youtube', yt_plugin) result = _run_async(engine.get_all_downloads()) assert [r.id for r in result] == ['yt-1'] def test_engine_get_download_status_returns_first_match(): engine = DownloadEngine() yt_plugin = _FakePlugin('youtube', downloads=[_FakeStatus('shared', 'youtube')]) td_plugin = _FakePlugin('tidal', downloads=[]) engine.register_plugin('youtube', yt_plugin) engine.register_plugin('tidal', td_plugin) result = _run_async(engine.get_download_status('shared')) assert result is not None assert result.id == 'shared' def test_engine_cancel_routes_streaming_source_directly(): """When source_hint is a known streaming-source name (not 'soulseek'), engine routes the cancel to that specific plugin only — doesn't ask every other plugin first.""" engine = DownloadEngine() yt_plugin = _FakePlugin('youtube') td_plugin = _FakePlugin('tidal') engine.register_plugin('youtube', yt_plugin) engine.register_plugin('tidal', td_plugin) _run_async(engine.cancel_download('dl-1', 'tidal', remove=False)) assert yt_plugin.cancel_calls == [] assert td_plugin.cancel_calls == [('dl-1', 'tidal', False)] def test_engine_cancel_routes_unknown_source_hint_to_soulseek(): """A username that's NOT in the plugin registry is a real Soulseek peer name — route to the soulseek plugin.""" engine = DownloadEngine() sl_plugin = _FakePlugin('soulseek') yt_plugin = _FakePlugin('youtube') engine.register_plugin('soulseek', sl_plugin) engine.register_plugin('youtube', yt_plugin) _run_async(engine.cancel_download('dl-1', 'random_peer_username', remove=False)) assert sl_plugin.cancel_calls == [('dl-1', 'random_peer_username', False)] assert yt_plugin.cancel_calls == [] def test_engine_cancel_falls_back_to_iterating_all_plugins_without_hint(): """No source hint → ask every plugin until one accepts the cancel (returns True). Mirrors legacy orchestrator behavior.""" engine = DownloadEngine() yt_plugin = _FakePlugin('youtube', cancel_result=False) td_plugin = _FakePlugin('tidal', cancel_result=True) engine.register_plugin('youtube', yt_plugin) engine.register_plugin('tidal', td_plugin) result = _run_async(engine.cancel_download('dl-1', None, remove=False)) assert result is True # Both plugins were asked; tidal accepted. assert len(yt_plugin.cancel_calls) == 1 assert len(td_plugin.cancel_calls) == 1 def test_engine_clear_all_skips_unconfigured_plugins(): """Unconfigured plugins are silently skipped (no API call, no error) — matches legacy orchestrator's defensive handling.""" engine = DownloadEngine() configured = _FakePlugin('youtube', configured=True, clear_result=True) unconfigured = _FakePlugin('tidal', configured=False) engine.register_plugin('youtube', configured) engine.register_plugin('tidal', unconfigured) result = _run_async(engine.clear_all_completed_downloads()) assert result is True assert configured.clear_calls == 1 assert unconfigured.clear_calls == 0 def test_engine_clear_all_returns_false_when_any_configured_plugin_fails(): engine = DownloadEngine() failing = _FakePlugin('youtube', configured=True, clear_result=False) engine.register_plugin('youtube', failing) result = _run_async(engine.clear_all_completed_downloads()) assert result is False # --------------------------------------------------------------------------- # Hybrid fallback (Phase F) # --------------------------------------------------------------------------- class _FakeSearchPlugin: def __init__(self, name, configured=True, search_result=None, raises=None): self.name = name self._configured = configured self._search_result = search_result if search_result is not None else ([], []) self._raises = raises self.search_calls = 0 def is_configured(self): return self._configured async def search(self, query, timeout=None, progress_callback=None): self.search_calls += 1 if self._raises: raise self._raises return self._search_result class _FakeDownloadPlugin: def __init__(self, name, configured=True, download_result=None, raises=None): self.name = name self._configured = configured self._download_result = download_result self._raises = raises self.download_calls = [] def is_configured(self): return self._configured async def download(self, username, filename, file_size): self.download_calls.append((username, filename, file_size)) if self._raises: raise self._raises return self._download_result def test_search_with_fallback_returns_first_non_empty_result(): engine = DownloadEngine() yt = _FakeSearchPlugin('youtube', search_result=([], [])) td = _FakeSearchPlugin('tidal', search_result=(['track1'], [])) qz = _FakeSearchPlugin('qobuz', search_result=(['track2'], [])) engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) engine.register_plugin('qobuz', qz) tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal', 'qobuz'])) assert tracks == ['track1'] # Tidal short-circuits — qobuz never queried. assert yt.search_calls == 1 assert td.search_calls == 1 assert qz.search_calls == 0 def test_search_with_fallback_skips_unconfigured_plugins(): engine = DownloadEngine() yt = _FakeSearchPlugin('youtube', configured=False) td = _FakeSearchPlugin('tidal', configured=True, search_result=(['hit'], [])) engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal'])) assert tracks == ['hit'] assert yt.search_calls == 0 # skipped def test_search_with_fallback_continues_after_per_source_exception(): engine = DownloadEngine() yt = _FakeSearchPlugin('youtube', raises=RuntimeError("yt down")) td = _FakeSearchPlugin('tidal', search_result=(['fallback-hit'], [])) engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal'])) assert tracks == ['fallback-hit'] def test_search_with_fallback_returns_empty_when_chain_exhausted(): engine = DownloadEngine() yt = _FakeSearchPlugin('youtube', search_result=([], [])) td = _FakeSearchPlugin('tidal', search_result=([], [])) engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) tracks, _ = _run_async(engine.search_with_fallback('q', ['youtube', 'tidal'])) assert tracks == [] assert yt.search_calls == 1 assert td.search_calls == 1 def test_download_with_fallback_returns_first_accepted_download_id(): """Phase F bug fix: legacy hybrid download routed to one source via username hint with no retry. Engine now falls through chain.""" engine = DownloadEngine() yt = _FakeDownloadPlugin('youtube', download_result=None) # refuses td = _FakeDownloadPlugin('tidal', download_result='td-id') engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) result = _run_async(engine.download_with_fallback( 'youtube', 'v||t', 0, ['youtube', 'tidal'], )) assert result == 'td-id' assert len(yt.download_calls) == 1 # tried first assert len(td.download_calls) == 1 # took over def test_download_with_fallback_promotes_username_hint_to_head(): """A username hint that matches a source-chain entry tries that source FIRST regardless of declared chain order.""" engine = DownloadEngine() yt = _FakeDownloadPlugin('youtube', download_result='yt-id') td = _FakeDownloadPlugin('tidal', download_result='td-id') engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) # Chain says tidal-first, but username hint promotes youtube. result = _run_async(engine.download_with_fallback( 'youtube', 'v||t', 0, ['tidal', 'youtube'], )) assert result == 'yt-id' assert len(yt.download_calls) == 1 assert len(td.download_calls) == 0 # never reached def test_download_with_fallback_returns_none_when_all_refuse(): engine = DownloadEngine() yt = _FakeDownloadPlugin('youtube', download_result=None) td = _FakeDownloadPlugin('tidal', download_result=None) engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) result = _run_async(engine.download_with_fallback( 'youtube', 'v||t', 0, ['youtube', 'tidal'], )) assert result is None assert len(yt.download_calls) == 1 assert len(td.download_calls) == 1 def test_download_with_fallback_continues_past_exception(): engine = DownloadEngine() yt = _FakeDownloadPlugin('youtube', raises=RuntimeError("yt died")) td = _FakeDownloadPlugin('tidal', download_result='td-id') engine.register_plugin('youtube', yt) engine.register_plugin('tidal', td) result = _run_async(engine.download_with_fallback( 'youtube', 'v||t', 0, ['youtube', 'tidal'], )) assert result == 'td-id' # --------------------------------------------------------------------------- # Cin bug 1: alias resolution on cancel_download # --------------------------------------------------------------------------- def test_register_plugin_records_aliases(): """Aliases passed to register_plugin resolve to the canonical plugin via get_plugin. Cin caught engine.cancel_download routing 'deezer_dl' to soulseek because the alias never made it to the engine.""" engine = DownloadEngine() deezer = _FakePlugin('deezer') engine.register_plugin('deezer', deezer, aliases=('deezer_dl',)) assert engine.get_plugin('deezer') is deezer assert engine.get_plugin('deezer_dl') is deezer def test_cancel_download_resolves_alias_to_canonical_plugin(): """The legacy 'deezer_dl' source_hint must route to the deezer plugin, not fall through to soulseek. This was Cin's bug 1 — cancel of a Deezer download silently no-op'd.""" engine = DownloadEngine() soulseek = _FakePlugin('soulseek') deezer = _FakePlugin('deezer') engine.register_plugin('soulseek', soulseek) engine.register_plugin('deezer', deezer, aliases=('deezer_dl',)) _run_async(engine.cancel_download('dl-1', 'deezer_dl', remove=False)) assert deezer.cancel_calls == [('dl-1', 'deezer_dl', False)] assert soulseek.cancel_calls == [] # --------------------------------------------------------------------------- # Cin bug 3: atomic update_record_unless_state # --------------------------------------------------------------------------- def test_update_record_unless_state_applies_when_state_not_blocked(): engine = DownloadEngine() engine.add_record('youtube', 'dl-1', {'state': 'InProgress, Downloading'}) applied = engine.update_record_unless_state( 'youtube', 'dl-1', {'state': 'Completed, Succeeded', 'progress': 100.0}, skip_if_state_in=('Cancelled',), ) assert applied is True assert engine.get_record('youtube', 'dl-1')['state'] == 'Completed, Succeeded' assert engine.get_record('youtube', 'dl-1')['progress'] == 100.0 def test_update_record_unless_state_skips_when_state_blocked(): """A worker-thread terminal write must NOT clobber a Cancelled state set by the user. Returns False so caller knows the patch was skipped.""" engine = DownloadEngine() engine.add_record('youtube', 'dl-1', {'state': 'Cancelled'}) applied = engine.update_record_unless_state( 'youtube', 'dl-1', {'state': 'Completed, Succeeded'}, skip_if_state_in=('Cancelled',), ) assert applied is False assert engine.get_record('youtube', 'dl-1')['state'] == 'Cancelled' def test_update_record_unless_state_returns_false_for_missing_record(): engine = DownloadEngine() applied = engine.update_record_unless_state( 'youtube', 'never-existed', {'state': 'Completed, Succeeded'}, skip_if_state_in=('Cancelled',), ) assert applied is False