import json import core.wishlist.routes as routes_module from core.wishlist.routes import ( WishlistRouteRuntime, add_album_track_to_wishlist, clear_wishlist, get_wishlist_count, get_wishlist_cycle, get_wishlist_stats, get_wishlist_tracks, process_wishlist_api, remove_album_from_wishlist, remove_batch_from_wishlist, remove_track_from_wishlist, set_wishlist_cycle, ) class _FakeLogger: def __init__(self): self.info_messages = [] self.warning_messages = [] self.error_messages = [] self.debug_messages = [] def info(self, msg, *args): self.info_messages.append(msg % args if args else msg) def warning(self, msg, *args): self.warning_messages.append(msg % args if args else msg) def error(self, msg, *args): self.error_messages.append(msg % args if args else msg) def debug(self, msg, *args): self.debug_messages.append(msg % args if args else msg) class _FakeThread: def __init__(self, target=None, daemon=False): self.target = target self.daemon = daemon self.started = False def start(self): self.started = True if self.target: self.target() class _FakeThreadFactory: def __init__(self): self.created = [] def __call__(self, *args, **kwargs): thread = _FakeThread(*args, **kwargs) self.created.append(thread) return thread class _FakeLock: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False class _FakeCursor: def __init__(self, db): self.db = db self.last_sql = "" def execute(self, sql, params=None): 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 _FakeConnection: def __init__(self, db): self.db = db def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False def cursor(self): return self.db.cursor_obj def commit(self): self.db.commits += 1 class _FakeMusicDatabase: def __init__(self, cycle_value="albums", duplicate_removals=0): self.cycle_value = cycle_value self.duplicate_removals = duplicate_removals self.commits = 0 self.cursor_obj = _FakeCursor(self) self.duplicate_cleanup_profiles = [] def _get_connection(self): return _FakeConnection(self) def remove_wishlist_duplicates(self, profile_id=1): self.duplicate_cleanup_profiles.append(profile_id) return self.duplicate_removals class _FakeWishlistService: def __init__(self, tracks=None, count=None, clear_result=True): self.tracks = list(tracks or []) self.count = len(self.tracks) if count is None else count self.clear_result = clear_result self.removed = [] self.add_calls = [] 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 clear_wishlist(self, profile_id=1): return self.clear_result def remove_track_from_wishlist(self, spotify_track_id, profile_id=1): self.removed.append((spotify_track_id, profile_id)) return True def add_track_to_wishlist(self, **kwargs): self.add_calls.append(kwargs) return True def add_spotify_track_to_wishlist(self, **kwargs): self.add_calls.append(kwargs) return True def _build_runtime( *, tracks=None, count=None, cycle_value="albums", duplicate_removals=0, clear_result=True, actually_processing=False, next_run_seconds=0, download_batches=None, download_tasks=None, thread_factory=None, reset_callback=None, ): service = _FakeWishlistService(tracks=tracks, count=count, clear_result=clear_result) routes_module.get_wishlist_service = lambda: service db = _FakeMusicDatabase(cycle_value=cycle_value, duplicate_removals=duplicate_removals) logger = _FakeLogger() activity_calls = [] runtime = WishlistRouteRuntime( get_music_database=lambda: db, profile_id=1, download_batches=download_batches if download_batches is not None else {}, download_tasks=download_tasks if download_tasks is not None else {}, tasks_lock=_FakeLock(), is_wishlist_actually_processing=lambda: actually_processing, reset_wishlist_processing_state=reset_callback or (lambda: None), add_activity_item=lambda *args: activity_calls.append(args), logger=logger, active_server="navidrome", get_next_run_seconds=(lambda _name: next_run_seconds), thread_factory=thread_factory or _FakeThreadFactory(), ) return runtime, service, db, logger, activity_calls def test_process_wishlist_api_starts_background_thread_when_idle(): thread_factory = _FakeThreadFactory() runtime, _service, _db, logger, _activity_calls = _build_runtime( thread_factory=thread_factory, ) start_calls = [] payload, status = process_wishlist_api(runtime, start_processing=lambda: start_calls.append("ran")) assert status == 200 assert payload == {"success": True, "message": "Wishlist processing started"} assert start_calls == ["ran"] assert len(thread_factory.created) == 1 assert thread_factory.created[0].daemon is True assert thread_factory.created[0].started is True assert logger.error_messages == [] def test_process_wishlist_api_rejects_when_flag_is_set(): thread_factory = _FakeThreadFactory() runtime, _service, _db, logger, _activity_calls = _build_runtime( actually_processing=True, thread_factory=thread_factory, ) payload, status = process_wishlist_api(runtime, start_processing=lambda: None) assert status == 409 assert payload == {"success": False, "error": "Wishlist processing already in progress"} assert thread_factory.created == [] assert logger.error_messages == [] def test_get_wishlist_count_returns_profile_count(): runtime, _service, _db, _logger, _activity_calls = _build_runtime(count=7) payload, status = get_wishlist_count(runtime) assert status == 200 assert payload == {"count": 7} def test_get_wishlist_stats_uses_cycle_and_next_run(): tracks = [ { "id": "track-1", "name": "Single Song", "artists": [{"name": "Artist One"}], "spotify_data": {"album": {"album_type": "single"}}, }, { "id": "track-2", "name": "Album Song", "artists": [{"name": "Artist Two"}], "spotify_data": {"album": {"total_tracks": 8}}, }, ] runtime, _service, _db, _logger, _activity_calls = _build_runtime( tracks=tracks, count=2, cycle_value="albums", actually_processing=True, next_run_seconds=123, ) payload, status = get_wishlist_stats(runtime) assert status == 200 assert payload == { "singles": 1, "albums": 1, "total": 2, "next_run_in_seconds": 123, "is_auto_processing": True, "current_cycle": "albums", } def test_get_wishlist_tracks_filters_category_and_cleans_duplicates(): tracks = [ { "id": "track-1", "name": "Single One", "artists": [{"name": "Artist One"}], "spotify_data": {"album": {"album_type": "single"}}, }, { "id": "track-2", "name": "Single Two", "artists": [{"name": "Artist Two"}], "spotify_data": {"album": {"album_type": "single"}}, }, { "id": "track-3", "name": "Album Song", "artists": [{"name": "Artist Three"}], "spotify_data": {"album": {"total_tracks": 8}}, }, ] runtime, service, db, logger, _activity_calls = _build_runtime( tracks=tracks, duplicate_removals=2, ) payload, status = get_wishlist_tracks(runtime, category="singles", limit=1) assert status == 200 assert payload["category"] == "singles" assert payload["total"] == 2 assert len(payload["tracks"]) == 1 assert payload["tracks"][0]["id"] == "track-1" assert db.duplicate_cleanup_profiles == [1] assert any("duplicate tracks from wishlist" in msg for msg in logger.warning_messages) assert service.get_wishlist_tracks_for_download(profile_id=1)[0]["id"] == "track-1" def test_clear_wishlist_cancels_active_batches_and_resets_state(): download_batches = { "batch-1": { "playlist_id": "wishlist", "phase": "analysis", "queue": ["task-1", "task-2", "task-3"], }, "batch-2": { "playlist_id": "other", "phase": "analysis", "queue": ["task-4"], }, } download_tasks = { "task-1": {"status": "queued"}, "task-2": {"status": "in_progress"}, "task-3": {"status": "completed"}, "task-4": {"status": "queued"}, } reset_calls = [] runtime, service, _db, logger, activity_calls = _build_runtime( download_batches=download_batches, download_tasks=download_tasks, reset_callback=lambda: reset_calls.append("reset"), ) payload, status = clear_wishlist(runtime) assert status == 200 assert payload == { "success": True, "message": "Wishlist cleared successfully", "cancelled_downloads": 2, } assert service.clear_result is True assert download_batches["batch-1"]["phase"] == "cancelled" assert download_batches["batch-2"]["phase"] == "analysis" assert download_tasks["task-1"]["status"] == "cancelled" assert download_tasks["task-2"]["status"] == "cancelled" assert download_tasks["task-3"]["status"] == "completed" assert download_tasks["task-4"]["status"] == "queued" assert reset_calls == ["reset"] assert activity_calls == [ ("", "Wishlist Cleared", "Wishlist cleared and 2 downloads cancelled", "Now") ] assert any("Cancelled 2 active wishlist downloads" in msg for msg in logger.warning_messages) def test_remove_track_from_wishlist_requires_track_id(): runtime, _service, _db, _logger, _activity_calls = _build_runtime() payload, status = remove_track_from_wishlist(runtime, None) assert status == 400 assert payload == {"success": False, "error": "No spotify_track_id provided"} def test_remove_track_from_wishlist_removes_single_track(): runtime, service, _db, _logger, _activity_calls = _build_runtime() payload, status = remove_track_from_wishlist(runtime, "track-1") assert status == 200 assert payload == {"success": True, "message": "Track removed from wishlist"} assert service.removed == [("track-1", 1)] def test_remove_album_from_wishlist_matches_album_name(): tracks = [ { "wishlist_id": 1, "spotify_track_id": "track-1", "id": "track-1", "spotify_data": json.dumps( { "album": {"name": "Complete Album"}, "artists": [{"name": "Artist One"}], } ), }, { "wishlist_id": 2, "spotify_track_id": "track-2", "id": "track-2", "spotify_data": { "album": {"name": "Other Album"}, "artists": [{"name": "Artist Two"}], }, }, ] runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks) payload, status = remove_album_from_wishlist(runtime, album_name_filter="complete album") assert status == 200 assert payload == { "success": True, "message": "Removed 1 track(s) from wishlist", "removed_count": 1, } assert service.removed == [("track-1", 1)] def test_remove_batch_from_wishlist_returns_removed_count(): runtime, service, _db, _logger, _activity_calls = _build_runtime() payload, status = remove_batch_from_wishlist(runtime, ["track-1", "track-2"]) assert status == 200 assert payload == { "success": True, "removed": 2, "message": "Removed 2 tracks from wishlist", } assert service.removed == [("track-1", 1), ("track-2", 1)] def test_set_wishlist_cycle_updates_metadata(): runtime, _service, db, _logger, _activity_calls = _build_runtime(cycle_value="albums") payload, status = set_wishlist_cycle(runtime, "singles") assert status == 200 assert payload == {"success": True, "cycle": "singles"} assert db.cycle_value == "singles" assert db.commits == 1 def test_get_wishlist_cycle_returns_stored_value(): runtime, _service, _db, _logger, _activity_calls = _build_runtime(cycle_value="singles") payload, status = get_wishlist_cycle(runtime) assert status == 200 assert payload == {"cycle": "singles"} def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context(): runtime, service, _db, _logger, _activity_calls = _build_runtime() track = { "id": "track-1", "name": "Song One", "artists": [{"name": "Artist One"}], "duration_ms": 1234, "track_number": 2, "disc_number": 1, "explicit": True, "popularity": 77, "preview_url": "https://example.test/preview", "external_urls": {"spotify": "https://open.spotify.com/track/track-1"}, } artist = {"id": "artist-1", "name": "Artist One"} album = { "id": "album-1", "name": "Album One", "artists": [{"name": "Artist One"}], "image_url": "https://example.test/cover.jpg", "release_date": "2024-01-01", "total_tracks": 10, } payload, status = add_album_track_to_wishlist( runtime, track=track, artist=artist, album=album, source_type="album", source_context={"playlist_id": "pl-1"}, ) assert status == 200 assert payload == {"success": True, "message": "Added 'Song One' to wishlist"} assert len(service.add_calls) == 1 add_call = service.add_calls[0] assert add_call["failure_reason"] == "Added from library (incomplete album)" assert add_call["source_type"] == "album" assert add_call["profile_id"] == 1 assert add_call["source_context"] == { "playlist_id": "pl-1", "artist_id": "artist-1", "artist_name": "Artist One", "album_id": "album-1", "album_name": "Album One", "added_via": "library_wishlist_modal", } assert add_call["track_data"]["album"]["images"] == [ {"url": "https://example.test/cover.jpg", "height": 640, "width": 640} ] assert add_call["track_data"]["duration_ms"] == 1234 assert add_call["track_data"]["explicit"] is True def test_set_wishlist_cycle_rejects_invalid_cycle(): runtime, _service, _db, _logger, _activity_calls = _build_runtime() payload, status = set_wishlist_cycle(runtime, "mixes") assert status == 400 assert payload == {"error": "Invalid cycle. Must be 'albums' or 'singles'"} def test_remove_album_from_wishlist_matches_album_id(): tracks = [ { "wishlist_id": 1, "spotify_track_id": "track-1", "id": "track-1", "spotify_data": { "album": {"id": "album-1", "name": "Complete Album"}, "artists": [{"name": "Artist One"}], }, }, { "wishlist_id": 2, "spotify_track_id": "track-2", "id": "track-2", "spotify_data": { "album": {"id": "album-2", "name": "Other Album"}, "artists": [{"name": "Artist Two"}], }, }, ] runtime, service, _db, _logger, _activity_calls = _build_runtime(tracks=tracks) payload, status = remove_album_from_wishlist(runtime, album_id="album-1") assert status == 200 assert payload == { "success": True, "message": "Removed 1 track(s) from wishlist", "removed_count": 1, } assert service.removed == [("track-1", 1)] def test_remove_album_from_wishlist_requires_lookup_fields(): runtime, _service, _db, _logger, _activity_calls = _build_runtime() payload, status = remove_album_from_wishlist(runtime) assert status == 400 assert payload == {"success": False, "error": "No album_id or album_name provided"} def test_remove_batch_from_wishlist_rejects_invalid_payload(): runtime, _service, _db, _logger, _activity_calls = _build_runtime() payload, status = remove_batch_from_wishlist(runtime, "track-1") assert status == 400 assert payload == {"success": False, "error": "Missing or invalid spotify_track_ids"} def test_add_album_track_to_wishlist_requires_required_fields(): runtime, _service, _db, _logger, _activity_calls = _build_runtime() payload, status = add_album_track_to_wishlist(runtime, track=None, artist=None, album=None) assert status == 400 assert payload == { "success": False, "error": "Missing required fields: track, artist, album", } # ── #825: don't add already-owned tracks to the wishlist (respects the toggle) ── def _own_track_args(): return dict(track={"id": "t", "name": "Song", "artists": [{"name": "A"}]}, artist={"id": "a1", "name": "A"}, album={"id": "al1", "name": "Album"}, source_type="album") def test_add_album_track_skips_owned_when_duplicates_off(monkeypatch): from config.settings import config_manager monkeypatch.setattr(config_manager, 'get', lambda key, default=None: False if key == 'wishlist.allow_duplicate_tracks' else default) runtime, service, db, _logger, _ = _build_runtime() db.check_track_exists = lambda *a, **k: (object(), 0.95) # already owned payload, status = add_album_track_to_wishlist(runtime, **_own_track_args()) assert status == 200 assert payload.get("skipped") is True assert service.add_calls == [] # nothing added def test_add_album_track_adds_missing_when_duplicates_off(monkeypatch): from config.settings import config_manager monkeypatch.setattr(config_manager, 'get', lambda key, default=None: False if key == 'wishlist.allow_duplicate_tracks' else default) runtime, service, db, _logger, _ = _build_runtime() db.check_track_exists = lambda *a, **k: (None, 0.0) # not in library payload, status = add_album_track_to_wishlist(runtime, **_own_track_args()) assert status == 200 assert not payload.get("skipped") assert len(service.add_calls) == 1 # added def test_add_album_track_adds_owned_when_duplicates_on(monkeypatch): from config.settings import config_manager monkeypatch.setattr(config_manager, 'get', lambda key, default=None: True if key == 'wishlist.allow_duplicate_tracks' else default) runtime, service, db, _logger, _ = _build_runtime() called = [] db.check_track_exists = lambda *a, **k: called.append(1) or (object(), 0.99) payload, status = add_album_track_to_wishlist(runtime, **_own_track_args()) assert status == 200 assert len(service.add_calls) == 1 # added anyway (user wants dupes) assert called == [] # ownership check skipped entirely