"""Tests for discovery precache double-execution prevention and throttling.""" import asyncio from unittest.mock import AsyncMock, MagicMock, patch import pytest from services.artist_discovery_service import ArtistDiscoveryService import services.artist_discovery_service as _ads_module @pytest.fixture(autouse=True) def _reset_precache_flag(): _ads_module._discovery_precache_running = False yield _ads_module._discovery_precache_running = False def _make_service(*, lb_configured: bool = True, lastfm_enabled: bool = False): lb_repo = MagicMock() lb_repo.is_configured.return_value = lb_configured lastfm_repo = MagicMock() if lastfm_enabled else None prefs = MagicMock() prefs.is_lastfm_enabled.return_value = lastfm_enabled advanced = MagicMock() advanced.artist_discovery_precache_concurrency = 2 prefs.get_advanced_settings.return_value = advanced cache = AsyncMock() cache.get = AsyncMock(return_value=None) cache.set = AsyncMock() library_db = AsyncMock() library_db.get_all_artist_mbids = AsyncMock(return_value=set()) svc = ArtistDiscoveryService( listenbrainz_repo=lb_repo, musicbrainz_repo=MagicMock(), library_db=library_db, lidarr_repo=MagicMock(), memory_cache=cache, lastfm_repo=lastfm_repo, preferences_service=prefs, ) return svc @pytest.mark.asyncio async def test_duplicate_invocation_skipped(): """Second call returns 0 immediately when precache is already running.""" svc = _make_service() gate = asyncio.Event() async def slow_similar(*args, **kwargs): await gate.wait() return MagicMock() with ( patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=slow_similar), patch.object(svc, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), ): task1 = asyncio.create_task( svc.precache_artist_discovery(["mbid-a"], delay=0) ) await asyncio.sleep(0.01) assert _ads_module._discovery_precache_running is True result2 = await svc.precache_artist_discovery(["mbid-b"], delay=0) assert result2 == 0 gate.set() result1 = await task1 assert result1 >= 0 assert _ads_module._discovery_precache_running is False @pytest.mark.asyncio async def test_lock_released_after_exception(): """Flag is cleared even when precache raises an unexpected error.""" svc = _make_service() with patch.object( svc, "_do_precache_artist_discovery", new_callable=AsyncMock, side_effect=RuntimeError("boom"), ): with pytest.raises(RuntimeError, match="boom"): await svc.precache_artist_discovery(["mbid-a"], delay=0) assert _ads_module._discovery_precache_running is False with ( patch.object(svc, "get_similar_artists", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), ): result = await svc.precache_artist_discovery(["mbid-a"], delay=0) assert result >= 0 @pytest.mark.asyncio async def test_delay_does_not_hold_semaphore_slot(): """Delay is applied outside the semaphore, allowing other artists to start during sleep.""" svc = _make_service() timestamps: list[float] = [] loop = asyncio.get_event_loop() async def track_similar(*args, **kwargs): timestamps.append(loop.time()) return MagicMock() with ( patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=track_similar), patch.object(svc, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), ): await svc.precache_artist_discovery( ["mbid-a", "mbid-b", "mbid-c", "mbid-d"], delay=0.15, ) assert len(timestamps) == 4 # With concurrency=2 and delay=0.15s OUTSIDE semaphore, the 3rd artist # can start as soon as a semaphore slot frees (before the delay finishes). # All four API calls should start quickly — the gap between 1st and 3rd # should be small since the semaphore isn't held during sleep. sorted_ts = sorted(timestamps) gap = sorted_ts[2] - sorted_ts[0] assert gap < 0.15, f"Expected <0.15s gap since sleep is outside semaphore, got {gap:.3f}s" @pytest.mark.asyncio async def test_cached_artists_skip_api_calls(): """Artists with all cache keys populated skip API fetches entirely.""" svc = _make_service() call_count = 0 async def counting_similar(*args, **kwargs): nonlocal call_count call_count += 1 return MagicMock() svc._cache.get = AsyncMock(return_value=MagicMock()) with ( patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=counting_similar), patch.object(svc, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), ): result = await svc.precache_artist_discovery( ["mbid-a", "mbid-b"], delay=0, ) assert call_count == 0, "Expected no API calls when all cache keys are populated" assert result == 2 @pytest.mark.asyncio async def test_guard_survives_instance_recreation(): """Module-level flag prevents overlap even when a new service instance is created.""" svc1 = _make_service() svc2 = _make_service() gate = asyncio.Event() async def slow_similar(*args, **kwargs): await gate.wait() return MagicMock() with ( patch.object(svc1, "get_similar_artists", new_callable=AsyncMock, side_effect=slow_similar), patch.object(svc1, "get_top_songs", new_callable=AsyncMock, return_value=MagicMock()), patch.object(svc1, "get_top_albums", new_callable=AsyncMock, return_value=MagicMock()), ): task1 = asyncio.create_task( svc1.precache_artist_discovery(["mbid-a"], delay=0) ) await asyncio.sleep(0.01) result2 = await svc2.precache_artist_discovery(["mbid-b"], delay=0) assert result2 == 0, "Second instance should be blocked by module-level flag" gate.set() await task1 assert _ads_module._discovery_precache_running is False @pytest.mark.asyncio async def test_worker_timeout_fires_and_updates_progress(): """A worker that exceeds the per-artist timeout is killed and progress is updated.""" svc = _make_service() async def hang_forever(*args, **kwargs): await asyncio.sleep(9999) return MagicMock() # pragma: no cover status = MagicMock() status.is_cancelled = MagicMock(return_value=False) status.update_progress = AsyncMock() with ( patch.object(svc, "get_similar_artists", new_callable=AsyncMock, side_effect=hang_forever), patch.object(svc, "get_top_songs", new_callable=AsyncMock, side_effect=hang_forever), patch.object(svc, "get_top_albums", new_callable=AsyncMock, side_effect=hang_forever), patch("services.artist_discovery_service._DISCOVERY_WORKER_TIMEOUT", 0.1), ): result = await svc.precache_artist_discovery( ["mbid-a"], delay=0, status_service=status, ) assert result == 0 assert status.update_progress.await_count >= 1 last_call_args = status.update_progress.call_args assert "timed out" in str(last_call_args)