"""Tests for the lazy per-artist candidate pool in PlaylistSyncService. The pool replaces a per-track SQL storm: instead of running ~30 title-variation × artist-variation queries for every playlist track, sync now fetches each unique artist's library tracks once and feeds the matcher via the in-memory `candidate_tracks` path. The fetch is *lazy* — it only fires when a track actually misses the sync_match_cache, so warm-cache playlists pay zero pool cost. """ from __future__ import annotations from unittest.mock import MagicMock from services.sync_service import PlaylistSyncService # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_service() -> PlaylistSyncService: """Bare PlaylistSyncService — pool helper doesn't touch service state.""" return PlaylistSyncService( spotify_client=MagicMock(), download_orchestrator=MagicMock(), media_server_engine=MagicMock(), ) def _make_db_stub(indexed_returns=None, search_returns=None, raise_on_search=None) -> MagicMock: """MusicDatabase stub mirroring the contract the helper relies on: - get_artist_tracks_indexed is the fast path (indexed artist_id lookup) - search_tracks is the slow LIKE-based fallback for recall edge cases Pool-key normalization runs through `core.text.normalize` directly, not through the db, so no `_normalize_for_comparison` stub is needed. """ db = MagicMock() db.get_artist_tracks_indexed.return_value = indexed_returns if indexed_returns is not None else [] if raise_on_search is not None: db.search_tracks.side_effect = raise_on_search db.get_artist_tracks_indexed.side_effect = raise_on_search else: db.search_tracks.return_value = search_returns if search_returns is not None else [] return db # --------------------------------------------------------------------------- # Pooling disabled — legacy fallback # --------------------------------------------------------------------------- def test_returns_none_when_pool_disabled(): """candidate_pool=None signals callers to fall through to the legacy per-track SQL loop. Helper must not touch the DB.""" svc = _make_service() db = _make_db_stub() result = svc._get_or_fetch_artist_candidates(None, db, 'Drake', 'plex') assert result is None db.search_tracks.assert_not_called() db.get_artist_tracks_indexed.assert_not_called() # --------------------------------------------------------------------------- # Lazy population # --------------------------------------------------------------------------- def test_indexed_fast_path_hits_skip_the_like_fallback(): """When the indexed lookup finds tracks, the LIKE-based fallback must NOT run — that's the whole perf point of the fast path.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=['t1', 't2']) result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') assert result == ['t1', 't2'] assert pool == {'drake': ['t1', 't2']} db.get_artist_tracks_indexed.assert_called_once_with( 'Drake', server_source='plex', limit=10000, ) db.search_tracks.assert_not_called() def test_like_fallback_runs_when_indexed_returns_empty(): """Diacritics / featured-artist recall lives in the LIKE path. The helper must fall through to search_tracks when the indexed lookup finds nothing, otherwise sync regresses on those cases. Note that the pool key is accent-folded (`Beyoncé` → `beyonce`) so library spellings with/without diacritics share one entry.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=[], search_returns=['feature-track']) result = svc._get_or_fetch_artist_candidates(pool, db, 'Beyoncé', 'plex') assert result == ['feature-track'] assert pool == {'beyonce': ['feature-track']} db.get_artist_tracks_indexed.assert_called_once() db.search_tracks.assert_called_once_with( artist='Beyoncé', limit=10000, server_source='plex', ) def test_second_call_for_same_artist_reuses_cache(): """Once an artist's pool is populated, subsequent lookups must not re-fetch — that's the whole perf point of the pool.""" svc = _make_service() pool = {'drake': ['cached']} db = _make_db_stub(indexed_returns=['fresh'], search_returns=['stale']) result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') assert result == ['cached'] db.get_artist_tracks_indexed.assert_not_called() db.search_tracks.assert_not_called() def test_artist_absent_from_library_cached_as_empty_list(): """Both paths return [] → cache [] so the next call short-circuits via check_track_exists' batched path without firing SQL again.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=[], search_returns=[]) result = svc._get_or_fetch_artist_candidates(pool, db, 'Obscure', 'plex') assert result == [] assert pool == {'obscure': []} def test_none_return_normalized_to_empty_list(): """Defensive — if both paths ever return None, helper must coerce to [] so the cached value is still a valid iterable for the matcher.""" svc = _make_service() pool: dict = {} db = _make_db_stub() db.get_artist_tracks_indexed.return_value = None db.search_tracks.return_value = None result = svc._get_or_fetch_artist_candidates(pool, db, 'Anyone', 'plex') assert result == [] assert pool == {'anyone': []} # --------------------------------------------------------------------------- # Failure modes # --------------------------------------------------------------------------- def test_fetch_failure_returns_none_and_does_not_cache(): """A pool fetch exception must not poison the dict — the per-track legacy path still has a chance to run for this track, and a later track for the same artist can retry the fetch.""" svc = _make_service() pool: dict = {} db = _make_db_stub(raise_on_search=RuntimeError('DB exploded')) result = svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') assert result is None assert pool == {} # --------------------------------------------------------------------------- # Normalization # --------------------------------------------------------------------------- def test_pool_key_is_normalized_so_casing_variants_share_one_fetch(): """'Drake' and 'DRAKE' must hash to the same pool entry — otherwise a playlist that mixes casing would re-fetch the same artist twice.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=['t']) svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') db.get_artist_tracks_indexed.reset_mock() db.search_tracks.reset_mock() result = svc._get_or_fetch_artist_candidates(pool, db, 'DRAKE', 'plex') assert result == ['t'] db.get_artist_tracks_indexed.assert_not_called() db.search_tracks.assert_not_called() def test_different_artists_get_separate_pool_entries(): svc = _make_service() pool: dict = {} db = _make_db_stub() db.get_artist_tracks_indexed.side_effect = [['drake-track'], ['sza-track']] svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'plex') svc._get_or_fetch_artist_candidates(pool, db, 'SZA', 'plex') assert pool == {'drake': ['drake-track'], 'sza': ['sza-track']} assert db.get_artist_tracks_indexed.call_count == 2 # --------------------------------------------------------------------------- # Server source plumbing # --------------------------------------------------------------------------- def test_active_server_is_passed_through_to_indexed_path(): """Misrouting server_source would make the pool include tracks from the wrong server (e.g. Plex tracks in a Jellyfin sync) — verify it survives the trip on the fast path.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=['t']) svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') db.get_artist_tracks_indexed.assert_called_once_with( 'Drake', server_source='jellyfin', limit=10000, ) def test_active_server_is_passed_through_to_like_fallback(): """Same server_source check for the slow LIKE-based fallback path.""" svc = _make_service() pool: dict = {} db = _make_db_stub(indexed_returns=[], search_returns=['t']) svc._get_or_fetch_artist_candidates(pool, db, 'Drake', 'jellyfin') db.search_tracks.assert_called_once_with( artist='Drake', limit=10000, server_source='jellyfin', )