Files
musicseerr/backend/tests/services/test_discovery_precache_lock.py
T
Harvey df779c9e6d Mus 19 library sync completing with issues (#29)
* fix: Sync issues - AudioDB warmig +  automatic sync skips

* progress ui/ux + discovery and album fixes

* artist fixes

* several request level fixes and improvements

* handle request fails + artist refresh + resilience fixes

* fix format

* fix stop sync fail + last.fn mbid issues + failures/validation reworks
2026-04-08 00:29:36 +01:00

223 lines
7.6 KiB
Python

"""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)