"""Tests for core/watchlist/auto_scan.py — auto-scan orchestrator.""" from __future__ import annotations import threading from contextlib import contextmanager from dataclasses import dataclass import pytest from core.watchlist import auto_scan as autosc # --------------------------------------------------------------------------- # Fakes # --------------------------------------------------------------------------- class _FakeApp: @contextmanager def app_context(self): yield class _FakeSpotify: def __init__(self, authenticated=True): self._authenticated = authenticated def is_authenticated(self): return self._authenticated class _FakeAutomationEngine: def __init__(self): self.events = [] def emit(self, event_type, data): self.events.append((event_type, data)) @dataclass class _ScanResult: success: bool = True new_tracks_found: int = 0 tracks_added_to_wishlist: int = 0 class _FakeScanner: def __init__(self, results=None): self._results = results or [] self.scan_calls = [] self.discovery_calls = [] self.lastfm_called = False self.cache_calls = [] self.cutoff_cleared = False def backfill_watchlist_artist_images(self, profile_id): return 0 def scan_watchlist_artists(self, artists, *, scan_state, progress_callback, cancel_check): self.scan_calls.append((artists, scan_state)) return self._results def populate_discovery_pool(self, profile_id, progress_callback=None): self.discovery_calls.append(profile_id) def _generate_lastfm_radio_playlists(self): self.lastfm_called = True def sync_spotify_library_cache(self, profile_id): self.cache_calls.append(profile_id) def _clear_rescan_cutoff(self): self.cutoff_cleared = True class _FakeDB: def __init__(self, profiles=None, watchlist_count=0, watchlist_artists=None, lb_profiles=None): self._profiles = profiles or [{'id': 1}] self._watchlist_count = watchlist_count self._watchlist_artists = watchlist_artists or [] self._lb_profiles = lb_profiles or [] self.database_path = '/tmp/test.db' self.scan_runs_saved = [] def save_watchlist_scan_run(self, **kwargs): self.scan_runs_saved.append(kwargs) return True def get_all_profiles(self): return self._profiles def get_watchlist_count(self, profile_id=1): return self._watchlist_count def get_watchlist_artists(self, profile_id=1): return self._watchlist_artists def get_profiles_with_listenbrainz(self): return self._lb_profiles def _build_deps( *, actually_scanning=False, flag_set=False, spotify_auth=True, progress_log=None, activity_log=None, ): progress_log = progress_log if progress_log is not None else [] activity_log = activity_log if activity_log is not None else [] auto_flag = [flag_set] auto_ts = [0.0] state_ref = [{}] deps = autosc.WatchlistAutoScanDeps( app=_FakeApp(), spotify_client=_FakeSpotify(authenticated=spotify_auth), automation_engine=_FakeAutomationEngine(), watchlist_timer_lock=threading.Lock(), is_watchlist_actually_scanning=lambda: actually_scanning, pause_enrichment_workers=lambda label: {'paused': True}, resume_enrichment_workers=lambda state, label: None, update_automation_progress=lambda *a, **kw: progress_log.append((a, kw)), add_activity_item=lambda *a, **kw: activity_log.append((a, kw)), _get_auto_scanning=lambda: auto_flag[0], _set_auto_scanning=lambda v: auto_flag.__setitem__(0, v), _get_auto_scanning_timestamp=lambda: auto_ts[0], _set_auto_scanning_timestamp=lambda v: auto_ts.__setitem__(0, v), _get_watchlist_scan_state=lambda: state_ref[0], _set_watchlist_scan_state=lambda v: state_ref.__setitem__(0, v), ) deps._auto_flag = auto_flag deps._state_ref = state_ref deps._progress_log = progress_log deps._activity_log = activity_log return deps @pytest.fixture def patched_modules(monkeypatch): """Stub out core.watchlist_scanner.get_watchlist_scanner + database access.""" scanner = _FakeScanner(results=[_ScanResult(success=True, new_tracks_found=2, tracks_added_to_wishlist=1)]) db = _FakeDB(watchlist_count=3, watchlist_artists=[{'id': 1, 'name': 'A1'}, {'id': 2, 'name': 'A2'}]) import core.watchlist_scanner as ws_mod monkeypatch.setattr(ws_mod, 'get_watchlist_scanner', lambda spotify: scanner) monkeypatch.setattr('database.music_database.get_database', lambda: db) # Stub seasonal + listenbrainz so post-scan steps don't crash trying to import real impl import core.seasonal_discovery as seasonal_mod seasonal_service = type('S', (), { 'get_current_season': lambda self: None, 'should_populate_seasonal_content': lambda self, s, days_threshold: False, 'populate_seasonal_content': lambda self, s: None, 'curate_seasonal_playlist': lambda self, s: None, })() monkeypatch.setattr(seasonal_mod, 'get_seasonal_discovery_service', lambda spotify, db: seasonal_service) return scanner, db # --------------------------------------------------------------------------- # Stuck-detection guard # --------------------------------------------------------------------------- def test_already_scanning_returns_immediately(patched_modules): """is_watchlist_actually_scanning() True → bail before doing anything.""" deps = _build_deps(actually_scanning=True) autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) # No state initialized, no scanner called, no flag set assert deps._state_ref[0] == {} assert deps._auto_flag[0] is False def test_race_check_inside_lock(patched_modules): """If get_auto_scanning_flag returns True after the smart-detect, bail.""" deps = _build_deps(actually_scanning=False, flag_set=True) autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) # Should have bailed at the lock-internal check; scan didn't run. scanner, _ = patched_modules assert scanner.scan_calls == [] # --------------------------------------------------------------------------- # No watchlist artists # --------------------------------------------------------------------------- def test_zero_watchlist_count_clears_flag_and_returns(patched_modules, monkeypatch): """When watchlist count is 0, function clears flag and returns.""" scanner, _ = patched_modules monkeypatch.setattr('database.music_database.get_database', lambda: _FakeDB(watchlist_count=0)) deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) # Flag was set then cleared assert deps._auto_flag[0] is False assert scanner.scan_calls == [] # --------------------------------------------------------------------------- # Spotify auth gate # --------------------------------------------------------------------------- def test_unauthenticated_spotify_clears_flag(patched_modules): """Spotify not authenticated → clear flag, return without scanning.""" scanner, _ = patched_modules deps = _build_deps(spotify_auth=False) autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert deps._auto_flag[0] is False assert scanner.scan_calls == [] # --------------------------------------------------------------------------- # Successful scan # --------------------------------------------------------------------------- def test_successful_scan_runs_post_steps(patched_modules): """Scan completes → discovery pool + lastfm + library sync all run.""" scanner, db = patched_modules deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) # Scanner was called with the watchlist assert len(scanner.scan_calls) == 1 # Post-scan steps fired assert scanner.discovery_calls == [1] assert scanner.lastfm_called is True assert scanner.cache_calls == [1] # State has summary assert deps._state_ref[0]['status'] == 'completed' assert deps._state_ref[0]['summary']['new_tracks_found'] == 2 def test_successful_scan_records_history_run(patched_modules): """#933: the automatic scan must persist a History row (it used to skip this, so only manual scans appeared in History).""" scanner, db = patched_modules deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert len(db.scan_runs_saved) == 1 # exactly one row, no double-record saved = db.scan_runs_saved[0] assert saved['status'] == 'completed' assert saved['artists_scanned'] == 1 # one successful _ScanResult assert saved['run_id'] # a run id was stamped def test_cancelled_scan_still_records_history_run(patched_modules, monkeypatch): """A cancelled scan is recorded too, with status='cancelled'.""" scanner, db = patched_modules deps = _build_deps() # Make the scan observe a cancel request mid-run. orig = scanner.scan_watchlist_artists def _cancel_during(artists, *, scan_state, progress_callback, cancel_check): scan_state['cancel_requested'] = True return orig(artists, scan_state=scan_state, progress_callback=progress_callback, cancel_check=cancel_check) monkeypatch.setattr(scanner, 'scan_watchlist_artists', _cancel_during) autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert len(db.scan_runs_saved) == 1 assert db.scan_runs_saved[0]['status'] == 'cancelled' def test_completion_emits_automation_event(patched_modules): """Successful scan emits 'watchlist_scan_completed' on automation_engine.""" scanner, _ = patched_modules deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert any(name == 'watchlist_scan_completed' for name, _ in deps.automation_engine.events) def test_activity_feed_logged_when_tracks_added(patched_modules): """Successful scan adding > 0 tracks logs an activity feed entry.""" scanner, _ = patched_modules deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert deps._activity_log # at least one activity fired # --------------------------------------------------------------------------- # Cancellation mid-scan # --------------------------------------------------------------------------- def test_cancelled_scan_skips_post_steps(patched_modules, monkeypatch): """If scanner sets cancel_requested mid-flight, post-scan steps skipped.""" scanner, _ = patched_modules def cancel_during_scan(artists, *, scan_state, progress_callback, cancel_check): scan_state['cancel_requested'] = True return [] scanner.scan_watchlist_artists = cancel_during_scan deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) # No post-scan steps ran assert scanner.discovery_calls == [] assert scanner.lastfm_called is False # --------------------------------------------------------------------------- # Profile-scoped trigger # --------------------------------------------------------------------------- def test_profile_scoped_trigger_only_scans_that_profile(patched_modules, monkeypatch): """When profile_id is provided, only that profile's watchlist is scanned.""" db = _FakeDB(profiles=[{'id': 1}, {'id': 2}], watchlist_count=3, watchlist_artists=[{'id': 99, 'name': 'X'}]) monkeypatch.setattr('database.music_database.get_database', lambda: db) deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', profile_id=2, deps=deps) scanner, _ = patched_modules assert len(scanner.scan_calls) == 1 # --------------------------------------------------------------------------- # Cleanup runs in finally # --------------------------------------------------------------------------- def test_finally_resets_auto_scanning_flag(patched_modules): """Even after a successful scan, the auto_scanning flag is reset.""" deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert deps._auto_flag[0] is False def test_finally_clears_rescan_cutoff(patched_modules): """scanner._clear_rescan_cutoff() called via finally.""" scanner, _ = patched_modules deps = _build_deps() autosc.process_watchlist_scan_automatically(automation_id='a1', deps=deps) assert scanner.cutoff_cleared is True