Files
musicseerr/backend/tests/services/test_home_genre_decoupling.py
T
2026-04-03 15:53:00 +01:00

272 lines
10 KiB
Python

"""Tests for genre section decoupling from home page build."""
import asyncio
import json
import time
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from api.v1.schemas.home import HomeGenre, HomeResponse, HomeSection
from services.home.genre_service import GenreService, GENRE_SECTION_TTL_DEFAULT
def _make_artist(mbid: str) -> MagicMock:
a = MagicMock()
a.musicbrainz_id = mbid
return a
def _make_genre_service(
tmp_path=None,
genre_section_ttl: int = GENRE_SECTION_TTL_DEFAULT,
) -> tuple[GenreService, AsyncMock, AsyncMock]:
mb = AsyncMock()
mem_cache = AsyncMock()
mem_cache.get = AsyncMock(return_value=None)
mem_cache.set = AsyncMock()
audiodb = AsyncMock()
prefs = MagicMock()
adv = MagicMock()
adv.genre_section_ttl = genre_section_ttl
prefs.get_advanced_settings.return_value = adv
svc = GenreService(
musicbrainz_repo=mb,
memory_cache=mem_cache,
audiodb_image_service=audiodb,
cache_dir=tmp_path,
preferences_service=prefs,
)
return svc, mb, mem_cache
class TestGenreSectionCacheHit:
@pytest.mark.asyncio
async def test_cache_hit_returns_data_no_mb_calls(self, tmp_path):
"""Genre section cache hit should return data without any MB/AudioDB calls."""
svc, mb, mem_cache = _make_genre_service(tmp_path)
genre_artists = {"rock": "mbid-1", "jazz": "mbid-2"}
genre_images = {"rock": "http://img/rock.jpg", "jazz": "http://img/jazz.jpg"}
mem_cache.get = AsyncMock(return_value=(genre_artists, genre_images))
result = await svc.get_cached_genre_section("listenbrainz")
assert result is not None
assert result[0] == genre_artists
assert result[1] == genre_images
mb.search_artists_by_tag.assert_not_awaited()
@pytest.mark.asyncio
async def test_cache_miss_returns_none(self, tmp_path):
"""Genre section cache miss should return None."""
svc, _, mem_cache = _make_genre_service(tmp_path)
mem_cache.get = AsyncMock(return_value=None)
result = await svc.get_cached_genre_section("listenbrainz")
assert result is None
class TestGenreSectionDiskPersistence:
@pytest.mark.asyncio
async def test_save_and_read_from_disk(self, tmp_path):
"""Genre section should be readable from disk after save."""
svc, _, mem_cache = _make_genre_service(tmp_path)
genre_artists = {"rock": "mbid-1", "pop": "mbid-2"}
genre_images = {"rock": "http://img/rock.jpg"}
await svc.save_genre_section("listenbrainz", genre_artists, genre_images)
mem_cache.get = AsyncMock(return_value=None)
result = await svc.get_cached_genre_section("listenbrainz")
assert result is not None
assert result[0] == genre_artists
assert result[1] == genre_images
@pytest.mark.asyncio
async def test_disk_survives_memory_clear(self, tmp_path):
"""Simulates a restart: memory cache is empty, disk data should be used."""
svc, _, mem_cache = _make_genre_service(tmp_path)
genre_artists = {"metal": "mbid-3"}
genre_images = {"metal": "http://img/metal.jpg"}
await svc.save_genre_section("lastfm", genre_artists, genre_images)
mem_cache.get = AsyncMock(return_value=None)
svc2, _, mem_cache2 = _make_genre_service(tmp_path)
mem_cache2.get = AsyncMock(return_value=None)
result = await svc2.get_cached_genre_section("lastfm")
assert result is not None
assert result[0] == genre_artists
@pytest.mark.asyncio
async def test_expired_disk_returns_none(self, tmp_path):
"""Expired genre section on disk should return None."""
svc, _, mem_cache = _make_genre_service(tmp_path, genre_section_ttl=1)
genre_artists = {"rock": "mbid-1"}
genre_images = {}
await svc.save_genre_section("listenbrainz", genre_artists, genre_images)
file_path = tmp_path / "genre_sections" / "listenbrainz.json"
data = json.loads(file_path.read_text())
data["built_at"] = time.time() - 100
file_path.write_text(json.dumps(data))
mem_cache.get = AsyncMock(return_value=None)
result = await svc.get_cached_genre_section("listenbrainz")
assert result is None
class TestGenreSectionBuild:
@pytest.mark.asyncio
async def test_build_caches_result(self, tmp_path):
"""build_and_cache_genre_section should build and save to disk."""
svc, mb, mem_cache = _make_genre_service(tmp_path)
mb.search_artists_by_tag = AsyncMock(
side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
)
img_result = MagicMock()
img_result.is_negative = False
img_result.wide_thumb_url = "http://img/test.jpg"
img_result.banner_url = None
img_result.fanart_url = None
svc._audiodb_image_service.fetch_and_cache_artist_images = AsyncMock(
return_value=img_result
)
await svc.build_and_cache_genre_section("listenbrainz", ["rock", "jazz"])
mem_cache.get = AsyncMock(return_value=None)
result = await svc.get_cached_genre_section("listenbrainz")
assert result is not None
assert "rock" in result[0]
assert "jazz" in result[0]
@pytest.mark.asyncio
async def test_build_skips_when_locked(self, tmp_path):
"""Concurrent builds for same source should be skipped if lock is held."""
svc, mb, _ = _make_genre_service(tmp_path)
mb.search_artists_by_tag = AsyncMock(
side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
)
svc._genre_build_locks["listenbrainz"] = asyncio.Lock()
async with svc._genre_build_locks["listenbrainz"]:
await svc.build_and_cache_genre_section("listenbrainz", ["rock"])
mb.search_artists_by_tag.assert_not_awaited()
class TestGenreSectionTTLSetting:
@pytest.mark.asyncio
async def test_ttl_from_preferences(self, tmp_path):
"""Genre section TTL should be read from advanced settings."""
svc, _, _ = _make_genre_service(tmp_path, genre_section_ttl=7200)
assert svc._get_genre_section_ttl() == 7200
@pytest.mark.asyncio
async def test_ttl_default_on_missing(self, tmp_path):
"""Missing preference should fall back to default TTL."""
svc, _, _ = _make_genre_service(tmp_path)
svc._preferences_service = None
assert svc._get_genre_section_ttl() == GENRE_SECTION_TTL_DEFAULT
class TestAdvancedSettingsGenreTTL:
def test_genre_section_ttl_default(self):
"""AdvancedSettings should include genre_section_ttl with correct default."""
from api.v1.schemas.advanced_settings import AdvancedSettings
settings = AdvancedSettings()
assert settings.genre_section_ttl == 21600
def test_genre_section_ttl_roundtrip(self):
"""Frontend→backend roundtrip should preserve genre_section_ttl value."""
from api.v1.schemas.advanced_settings import AdvancedSettings, AdvancedSettingsFrontend
backend = AdvancedSettings(genre_section_ttl=43200)
frontend = AdvancedSettingsFrontend.from_backend(backend)
assert frontend.genre_section_ttl == 12
roundtripped = frontend.to_backend()
assert roundtripped.genre_section_ttl == 43200
def test_genre_section_ttl_validation_rejects_below_minimum(self):
"""genre_section_ttl below 3600 should be rejected."""
from api.v1.schemas.advanced_settings import AdvancedSettings
import msgspec
with pytest.raises(msgspec.ValidationError):
AdvancedSettings(genre_section_ttl=100)
class TestPerSourceLockIndependence:
"""Per-source locks allow parallel builds for different sources."""
@pytest.mark.asyncio
async def test_different_sources_build_independently(self, tmp_path):
"""Building for LB should not block building for LFM."""
svc, mb, audiodb = _make_genre_service(tmp_path)
mb.search_artists_by_tag = AsyncMock(
side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
)
audiodb.get_artist_images_batch = AsyncMock(return_value={})
results = await asyncio.gather(
svc.build_and_cache_genre_section("listenbrainz", ["rock"]),
svc.build_and_cache_genre_section("lastfm", ["jazz"]),
)
assert mb.search_artists_by_tag.await_count == 2
@pytest.mark.asyncio
async def test_per_source_locks_created_on_demand(self, tmp_path):
"""Lock for a source is created when first needed."""
svc, mb, audiodb = _make_genre_service(tmp_path)
assert "listenbrainz" not in svc._genre_build_locks
mb.search_artists_by_tag = AsyncMock(
side_effect=lambda name, limit=10: [_make_artist(f"mbid-{name}")]
)
audiodb.get_artist_images_batch = AsyncMock(return_value={})
await svc.build_and_cache_genre_section("listenbrainz", ["rock"])
assert "listenbrainz" in svc._genre_build_locks
class TestWarmerRetryOnNoop:
"""Warmer uses short retry interval when no sources were warmed."""
@pytest.mark.asyncio
async def test_warmer_retries_quickly_on_no_data(self):
"""When no cached home data exists, warmer sleeps 60s not full TTL."""
from core.tasks import warm_genre_cache_periodically
home_svc = AsyncMock()
home_svc.get_cached_home_data = AsyncMock(return_value=None)
home_svc._genre._get_genre_section_ttl = MagicMock(return_value=21600)
sleep_values: list[int | float] = []
call_count = 0
original_sleep = asyncio.sleep
async def mock_sleep(seconds):
nonlocal call_count
sleep_values.append(seconds)
call_count += 1
if call_count >= 2:
raise asyncio.CancelledError
return await original_sleep(0)
with patch("core.tasks.asyncio.sleep", side_effect=mock_sleep):
try:
await warm_genre_cache_periodically(home_svc, interval=21600)
except asyncio.CancelledError:
pass
assert len(sleep_values) >= 2
assert sleep_values[1] == 60