from contextlib import contextmanager from types import SimpleNamespace from core.wishlist import processing from core.wishlist.processing import WishlistAutoProcessingRuntime, process_wishlist_automatically class _FakeLogger: def __init__(self): self.info_messages = [] self.warning_messages = [] self.error_messages = [] self.debug_messages = [] def info(self, msg): self.info_messages.append(msg) def warning(self, msg): self.warning_messages.append(msg) def error(self, msg): self.error_messages.append(msg) def debug(self, msg): self.debug_messages.append(msg) class _FakeProfilesDatabase: def __init__(self, profiles): self._profiles = profiles def get_all_profiles(self): return list(self._profiles) class _FakeWishlistService: def __init__(self, tracks, count=None): self._tracks = tracks self._count = count if count is not None else len(tracks) def get_wishlist_count(self, profile_id=1): return self._count def get_wishlist_tracks_for_download(self, profile_id=1): return list(self._tracks) def mark_track_download_result(self, spotify_track_id, success, error_message=None, profile_id=1): return True class _FakeCursor: def __init__(self, db): self.db = db self.calls = [] self._last_sql = "" def execute(self, sql, params=None): self.calls.append((sql, params)) self._last_sql = sql if "INSERT OR REPLACE INTO metadata" in sql and params: self.db.cycle_value = params[0] def fetchone(self): if "SELECT value FROM metadata WHERE key = 'wishlist_cycle'" in self._last_sql: return {"value": self.db.cycle_value} return None class _FakeMusicDatabase: def __init__(self, cycle_value="albums"): self.cycle_value = cycle_value self.cursor_obj = _FakeCursor(self) self.commits = 0 self.duplicate_removals = [] self.track_checks = [] def _get_connection(self): class _Conn: def __init__(self, outer): self.outer = outer def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def cursor(self): return self.outer.cursor_obj def commit(self): self.outer.commits += 1 return _Conn(self) def remove_wishlist_duplicates(self, profile_id=1): self.duplicate_removals.append(profile_id) return 0 def check_track_exists(self, track_name, artist_name, confidence_threshold=0.7, server_source=None, album=None): self.track_checks.append((track_name, artist_name, confidence_threshold, server_source, album)) return None, 0.0 class _FakeExecutor: def __init__(self): self.submissions = [] def submit(self, fn, *args, **kwargs): self.submissions.append((fn, args, kwargs)) return SimpleNamespace() class _FakeLock: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def _build_runtime( *, tracks, cycle_value="albums", count=None, profiles=None, active_server="navidrome", progress_calls=None, guard_events=None, batch_map=None, guard_acquired=True, is_actually_processing=False, ): if progress_calls is None: progress_calls = [] if guard_events is None: guard_events = [] if batch_map is None: batch_map = {} wishlist_service = _FakeWishlistService(tracks, count=count) processing.get_wishlist_service = lambda: wishlist_service profiles_db = _FakeProfilesDatabase(profiles or [{"id": 1}]) music_db = _FakeMusicDatabase(cycle_value=cycle_value) executor = _FakeExecutor() logger = _FakeLogger() @contextmanager def guard(): guard_events.append("enter") try: yield guard_acquired finally: guard_events.append("exit") @contextmanager def app_context(): yield def progress_callback(*args, **kwargs): progress_calls.append((args, kwargs)) runtime = WishlistAutoProcessingRuntime( processing_guard=guard, app_context_factory=app_context, get_profiles_database=lambda: profiles_db, get_music_database=lambda: music_db, download_batches=batch_map, tasks_lock=_FakeLock(), update_automation_progress=progress_callback, automation_engine=None, missing_download_executor=executor, run_full_missing_tracks_process=lambda *args, **kwargs: None, get_batch_max_concurrent=lambda: 4, get_active_server=lambda: active_server, logger=logger, current_time_fn=lambda: 123.0, is_actually_processing=lambda: is_actually_processing, profile_id=1, ) return runtime, wishlist_service, profiles_db, music_db, executor, logger, progress_calls, guard_events def test_process_wishlist_automatically_toggles_cycle_when_no_tracks_match_current_cycle(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Single Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "single"}}, } ], cycle_value="albums", count=1, ) process_wishlist_automatically(runtime, automation_id="auto-1") assert executor.submissions == [] assert music_db.cycle_value == "singles" assert music_db.commits == 1 assert any("No albums tracks in wishlist" in msg for msg in logger.warning_messages) assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10, 25, 40] def test_process_wishlist_automatically_creates_batch_for_matching_tracks(): batch_map = {} runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-2") assert len(executor.submissions) == 1 submitted_fn, submitted_args, submitted_kwargs = executor.submissions[0] assert submitted_args[1] == "wishlist" assert submitted_args[2][0]["_original_index"] == 0 assert len(batch_map) == 1 batch = next(iter(batch_map.values())) assert batch["phase"] == "analysis" assert batch["playlist_name"] == "Wishlist (Auto - Albums)" assert batch["analysis_total"] == 1 assert any(kwargs.get("progress") == 50 for _args, kwargs in progress_calls) assert guard_events == ["enter", "exit"] assert any("Starting automatic wishlist batch" in msg for msg in logger.info_messages) def test_process_wishlist_automatically_returns_early_when_already_processing(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, is_actually_processing=True, ) process_wishlist_automatically(runtime, automation_id="auto-3") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert progress_calls == [] assert guard_events == [] assert any("Already processing" in msg for msg in logger.info_messages) def test_process_wishlist_automatically_returns_early_when_guard_not_acquired(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, guard_acquired=False, ) process_wishlist_automatically(runtime, automation_id="auto-4") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert progress_calls == [] assert guard_events == ["enter", "exit"] assert any("race condition check" in msg for msg in logger.info_messages) def test_process_wishlist_automatically_returns_early_when_no_tracks_are_available(): runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[], cycle_value="albums", count=0, ) process_wishlist_automatically(runtime, automation_id="auto-5") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10] assert any("No tracks in wishlist for auto-processing" in msg for msg in logger.warning_messages) def test_process_wishlist_automatically_skips_when_wishlist_batch_is_already_active(): batch_map = { "batch-1": { "playlist_id": "wishlist", "phase": "analysis", } } runtime, _service, _profiles_db, music_db, executor, logger, progress_calls, guard_events = _build_runtime( tracks=[ { "name": "Album Track", "artists": [{"name": "Artist A"}], "spotify_data": {"album": {"album_type": "album"}}, } ], cycle_value="albums", count=1, batch_map=batch_map, ) process_wishlist_automatically(runtime, automation_id="auto-6") assert executor.submissions == [] assert music_db.duplicate_removals == [] assert guard_events == ["enter", "exit"] assert [kwargs.get("progress") for _args, kwargs in progress_calls if "progress" in kwargs] == [10] assert any("already active in another batch" in msg for msg in logger.info_messages)