Files
musicseerr/backend/tests/services/test_discover_picks.py
T
Harvey 7fd6bb83bd Discover page improvements + playlists albums changes (#55)
* MUS-42 Discover page improvements + playlist/album changes + various discovery fixes + tanstack query stuuff + placeholder image fix

* fix formatter truncate

* fix tests

* make lint

* fix make ci errors

* address copilot
2026-04-17 23:46:52 +00:00

717 lines
30 KiB
Python

"""Tests for the Discover Picks section builder in DiscoverHomepageService."""
import random
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from api.v1.schemas.discover import DiscoverResponse
from api.v1.schemas.home import HomeSection, HomeAlbum
from api.v1.schemas.settings import (
ListenBrainzConnectionSettings,
LastFmConnectionSettings,
PrimaryMusicSourceSettings,
)
from repositories.listenbrainz_models import ListenBrainzReleaseGroup
from repositories.lastfm_models import LastFmArtist
from services.discover.homepage_service import (
DiscoverHomepageService,
DISCOVER_PICKS_CACHE_TTL,
)
from services.discover.integration_helpers import IntegrationHelpers
def _make_lb_settings(
enabled: bool = True, username: str = "lbuser",
) -> ListenBrainzConnectionSettings:
return ListenBrainzConnectionSettings(
user_token="tok", username=username, enabled=enabled,
)
def _make_lfm_settings(
enabled: bool = True, username: str = "lfmuser",
) -> LastFmConnectionSettings:
return LastFmConnectionSettings(
api_key="key", shared_secret="secret", session_key="sk",
username=username, enabled=enabled,
)
def _make_prefs(
lb_enabled: bool = True,
lfm_enabled: bool = False,
primary_source: str = "listenbrainz",
affinity_weight: float = 0.7,
picks_count: int = 12,
) -> MagicMock:
prefs = MagicMock()
prefs.get_listenbrainz_connection.return_value = _make_lb_settings(enabled=lb_enabled)
prefs.get_lastfm_connection.return_value = _make_lfm_settings(enabled=lfm_enabled)
prefs.is_lastfm_enabled.return_value = lfm_enabled
prefs.get_primary_music_source.return_value = PrimaryMusicSourceSettings(source=primary_source)
jf_settings = MagicMock()
jf_settings.enabled = False
jf_settings.jellyfin_url = ""
jf_settings.api_key = ""
prefs.get_jellyfin_connection.return_value = jf_settings
lidarr = MagicMock()
lidarr.lidarr_url = ""
lidarr.lidarr_api_key = ""
prefs.get_lidarr_connection.return_value = lidarr
yt = MagicMock()
yt.enabled = False
yt.api_key = ""
prefs.get_youtube_connection.return_value = yt
lf = MagicMock()
lf.enabled = False
lf.music_path = ""
prefs.get_local_files_connection.return_value = lf
adv = MagicMock()
adv.discover_queue_size = 10
adv.discover_queue_ttl = 3600
adv.discover_queue_seed_artists = 3
adv.discover_queue_wildcard_slots = 2
adv.discover_queue_similar_artists_limit = 15
adv.discover_queue_albums_per_similar = 3
adv.discover_queue_enrich_ttl = 3600
adv.discover_queue_lastfm_mbid_max_lookups = 10
adv.discover_picks_genre_affinity_weight = affinity_weight
adv.discover_picks_count = picks_count
prefs.get_advanced_settings.return_value = adv
return prefs
def _make_genre_index(
top_genres: list[tuple[str, int]] | None = None,
genres_for_artists: dict[str, list[str]] | None = None,
) -> AsyncMock:
genre_index = AsyncMock()
genre_index.get_top_genres = AsyncMock(return_value=top_genres or [])
genre_index.get_genres_for_artists = AsyncMock(return_value=genres_for_artists or {})
return genre_index
def _make_cache() -> AsyncMock:
cache = AsyncMock()
cache.get = AsyncMock(return_value=None)
cache.set = AsyncMock()
return cache
def _make_release_groups(
count: int,
prefix: str = "rg",
artist_genres: dict[str, list[str]] | None = None,
) -> list[ListenBrainzReleaseGroup]:
return [
ListenBrainzReleaseGroup(
release_group_name=f"Album {prefix}-{i}",
artist_name=f"Artist {prefix}-{i}",
listen_count=100 - i,
release_group_mbid=f"{prefix}-mbid-{i}",
artist_mbids=[f"artist-{prefix}-{i}"],
)
for i in range(count)
]
def _make_service(
genre_index: AsyncMock | None = None,
cache: AsyncMock | None = None,
prefs: MagicMock | None = None,
lastfm_repo: AsyncMock | None = None,
mbid_store: AsyncMock | None = None,
) -> DiscoverHomepageService:
lb_repo = AsyncMock()
jf_repo = AsyncMock()
lidarr_repo = AsyncMock()
mb_repo = AsyncMock()
if prefs is None:
prefs = _make_prefs()
integration = IntegrationHelpers(prefs)
mbid_resolution = MagicMock()
return DiscoverHomepageService(
listenbrainz_repo=lb_repo,
jellyfin_repo=jf_repo,
lidarr_repo=lidarr_repo,
musicbrainz_repo=mb_repo,
integration=integration,
mbid_resolution=mbid_resolution,
memory_cache=cache,
genre_index=genre_index,
lastfm_repo=lastfm_repo,
mbid_store=mbid_store,
)
# ---------------------------------------------------------------------------
# Test 16 - constant value
# ---------------------------------------------------------------------------
class TestConstantValue:
def test_discover_picks_cache_ttl_is_14400(self) -> None:
assert DISCOVER_PICKS_CACHE_TTL == 14400
# ---------------------------------------------------------------------------
# Test 15 - cache key format
# ---------------------------------------------------------------------------
class TestCacheKeyFormat:
def test_cache_key_format_listenbrainz(self) -> None:
service = _make_service()
assert service._discover_picks_cache_key("listenbrainz") == "discover_picks:listenbrainz"
def test_cache_key_format_lastfm(self) -> None:
service = _make_service()
assert service._discover_picks_cache_key("lastfm") == "discover_picks:lastfm"
# ---------------------------------------------------------------------------
# Test 1 - returns None when genre_index is None
# ---------------------------------------------------------------------------
class TestReturnsNoneWhenGenreIndexIsNone:
@pytest.mark.asyncio
async def test_returns_none_when_genre_index_is_none(self) -> None:
service = _make_service(genre_index=None)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is None
# ---------------------------------------------------------------------------
# Test 2 - returns None when candidate pool is empty
# ---------------------------------------------------------------------------
class TestReturnsNoneWhenCandidatePoolIsEmpty:
@pytest.mark.asyncio
async def test_returns_none_when_candidate_pool_is_empty(self) -> None:
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[])
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is None
# ---------------------------------------------------------------------------
# Test 3 - returns None when all candidates in library
# ---------------------------------------------------------------------------
class TestReturnsNoneWhenAllCandidatesInLibrary:
@pytest.mark.asyncio
async def test_returns_none_when_all_candidates_in_library(self) -> None:
candidates = _make_release_groups(5)
library_mbids = {c.release_group_mbid.lower() for c in candidates}
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user")
assert result is None
# ---------------------------------------------------------------------------
# Test 4 - returns section with correct count
# ---------------------------------------------------------------------------
class TestReturnsSectionWithCorrectCount:
@pytest.mark.asyncio
async def test_returns_section_with_correct_count(self) -> None:
candidates = _make_release_groups(50)
genre_index = _make_genre_index(
top_genres=[("rock", 50), ("electronic", 30)],
genres_for_artists={},
)
cache = _make_cache()
prefs = _make_prefs(picks_count=12)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 12
# ---------------------------------------------------------------------------
# Test 5 - returns section with fewer when pool smaller than count
# ---------------------------------------------------------------------------
class TestReturnsSectionWithFewerWhenPoolSmaller:
@pytest.mark.asyncio
async def test_returns_section_with_fewer_when_pool_smaller_than_count(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(picks_count=12)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 5
# ---------------------------------------------------------------------------
# Test 6 - section type is albums
# ---------------------------------------------------------------------------
class TestSectionTypeIsAlbums:
@pytest.mark.asyncio
async def test_section_type_is_albums(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert result.type == "albums"
# ---------------------------------------------------------------------------
# Test 7 - section title is Discover Picks
# ---------------------------------------------------------------------------
class TestSectionTitleIsDiscoverPicks:
@pytest.mark.asyncio
async def test_section_title_is_discover_picks(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert result.title == "Discover Picks"
# ---------------------------------------------------------------------------
# Test 8 - section source matches resolved_source
# ---------------------------------------------------------------------------
class TestSectionSourceMatchesResolvedSource:
@pytest.mark.asyncio
async def test_section_source_matches_resolved_source(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "my_source", True, "user")
assert result is not None
assert result.source == "my_source"
# ---------------------------------------------------------------------------
# Test 9 - library mbids excluded
# ---------------------------------------------------------------------------
class TestLibraryMbidsExcluded:
@pytest.mark.asyncio
async def test_library_mbids_excluded(self) -> None:
candidates = _make_release_groups(10)
# Put the first 3 candidates in library
library_mbids = {candidates[i].release_group_mbid.lower() for i in range(3)}
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(picks_count=20)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 7
result_mbids = {item.mbid.lower() for item in result.items if item.mbid}
assert not result_mbids & library_mbids
# ---------------------------------------------------------------------------
# Test 10 - genre affinity weight 1.0 biases by genre
# ---------------------------------------------------------------------------
class TestGenreAffinityWeight1BiasesByGenre:
@pytest.mark.asyncio
async def test_genre_affinity_weight_1_biases_by_genre(self) -> None:
# 6 candidates with matching genres, 6 without
matching = _make_release_groups(6, prefix="match")
non_matching = _make_release_groups(6, prefix="nomatch")
all_candidates = matching + non_matching
genres_for_artists = {}
for c in matching:
genres_for_artists[c.artist_mbids[0].lower()] = ["rock", "indie"]
for c in non_matching:
genres_for_artists[c.artist_mbids[0].lower()] = ["country", "folk"]
genre_index = _make_genre_index(
top_genres=[("rock", 50), ("indie", 30)],
genres_for_artists=genres_for_artists,
)
cache = _make_cache()
prefs = _make_prefs(affinity_weight=1.0, picks_count=6)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=all_candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 6
# All top-6 should be the matching-genre candidates
result_mbids = {item.mbid for item in result.items}
match_mbids = {c.release_group_mbid for c in matching}
assert result_mbids == match_mbids
# ---------------------------------------------------------------------------
# Test 11 - genre affinity weight 0.0 is fully random
# ---------------------------------------------------------------------------
class TestGenreAffinityWeight0IsFullyRandom:
@pytest.mark.asyncio
async def test_genre_affinity_weight_0_is_fully_random(self) -> None:
candidates = _make_release_groups(20)
genres_for_artists = {
c.artist_mbids[0].lower(): ["rock"] for c in candidates[:10]
}
genre_index = _make_genre_index(
top_genres=[("rock", 50)],
genres_for_artists=genres_for_artists,
)
cache = _make_cache()
prefs = _make_prefs(affinity_weight=0.0, picks_count=10)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
random.seed(42)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 10
# With weight=0.0, genre overlap doesn't matter - order is random.
# Run again with same seed to verify determinism.
cache_second = _make_cache()
service2 = _make_service(genre_index=genre_index, cache=cache_second, prefs=prefs)
service2._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
# Reset genre_index mocks since they were consumed
genre_index.get_top_genres = AsyncMock(return_value=[("rock", 50)])
genre_index.get_genres_for_artists = AsyncMock(return_value=genres_for_artists)
random.seed(42)
result2 = await service2._build_discover_picks(set(), "listenbrainz", True, "user")
assert result2 is not None
assert [item.mbid for item in result.items] == [item.mbid for item in result2.items]
# ---------------------------------------------------------------------------
# Test 12 - settings override changes count
# ---------------------------------------------------------------------------
class TestSettingsOverrideChangesCount:
@pytest.mark.asyncio
async def test_settings_override_changes_count(self) -> None:
candidates = _make_release_groups(20)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(affinity_weight=0.5, picks_count=8)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 8
# ---------------------------------------------------------------------------
# Test 13 - cached result returned on second call
# ---------------------------------------------------------------------------
class TestCachedResultReturnedOnSecondCall:
@pytest.mark.asyncio
async def test_cached_result_returned_on_second_call(self) -> None:
candidates = _make_release_groups(10)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(picks_count=5)
service = _make_service(genre_index=genre_index, cache=cache, prefs=prefs)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
# First call - should fetch from LB
result1 = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result1 is not None
assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1
# Simulate cache returning the wrapper on second call
cache.get = AsyncMock(return_value={"section": result1})
result2 = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result2 is result1
# LB repo should NOT have been called again
assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1
# ---------------------------------------------------------------------------
# Test 14 - cache TTL is 4 hours
# ---------------------------------------------------------------------------
class TestCacheTtlIs4Hours:
@pytest.mark.asyncio
async def test_cache_ttl_is_4_hours(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert cache.set.await_count >= 1
# Find the call that cached the result
for call in cache.set.call_args_list:
args = call[0]
if args[0] == "discover_picks:listenbrainz":
assert args[2] == 14400
return
pytest.fail("cache.set was not called with discover_picks key")
# ---------------------------------------------------------------------------
# Test 17 - exception returns None
# ---------------------------------------------------------------------------
class TestExceptionReturnsNone:
@pytest.mark.asyncio
async def test_exception_returns_none(self) -> None:
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(
side_effect=Exception("API failure"),
)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is None
# ---------------------------------------------------------------------------
# Test 18 - Last.fm path when LB disabled
# ---------------------------------------------------------------------------
class TestLastfmPathWhenLbDisabled:
@pytest.mark.asyncio
async def test_lastfm_path_when_lb_disabled(self) -> None:
lfm_repo = AsyncMock()
lfm_artists = [
LastFmArtist(name="Artist A", mbid="lfm-artist-1"),
LastFmArtist(name="Artist B", mbid="lfm-artist-2"),
]
lfm_repo.get_global_top_artists = AsyncMock(return_value=lfm_artists)
lb_release_groups = _make_release_groups(3, prefix="lfm-rg")
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(lb_enabled=False, lfm_enabled=True)
service = _make_service(
genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo,
)
service._lb_repo.get_artist_top_release_groups = AsyncMock(
return_value=lb_release_groups,
)
result = await service._build_discover_picks(set(), "lastfm", False, None)
assert result is not None
lfm_repo.get_global_top_artists.assert_awaited_once()
assert len(result.items) > 0
# ---------------------------------------------------------------------------
# Test 19 - _has_meaningful_content with discover_picks
# ---------------------------------------------------------------------------
class TestHasMeaningfulContentWithDiscoverPicks:
def test_has_meaningful_content_with_discover_picks(self) -> None:
service = _make_service()
response = DiscoverResponse(
discover_picks=HomeSection(
title="Discover Picks",
type="albums",
items=[HomeAlbum(name="A", mbid="m1", artist_name="Art")],
source="listenbrainz",
),
)
assert service._has_meaningful_content(response) is True
def test_has_meaningful_content_false_when_empty(self) -> None:
service = _make_service()
response = DiscoverResponse()
assert service._has_meaningful_content(response) is False
# ---------------------------------------------------------------------------
# Test 20 - wired into build_discover_data
# ---------------------------------------------------------------------------
class TestWiredIntoBuildDiscoverData:
@pytest.mark.asyncio
async def test_wired_into_build_discover_data(self) -> None:
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
# Wire up minimal mocks for build_discover_data
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(
return_value=candidates,
)
service._lb_repo.get_user_top_artists = AsyncMock(return_value=[])
service._lb_repo.get_user_fresh_releases = AsyncMock(return_value=[])
service._lb_repo.get_user_genre_activity = AsyncMock(return_value=[])
service._lb_repo.get_similar_artists = AsyncMock(return_value=[])
service._lb_repo.get_sitewide_top_artists = AsyncMock(return_value=[])
service._jf_repo.get_most_played_artists = AsyncMock(return_value=[])
service._mbid.get_library_artist_mbids = AsyncMock(return_value=set())
response = await service.build_discover_data(source="listenbrainz")
assert response.discover_picks is not None
assert response.discover_picks.title == "Discover Picks"
# ---------------------------------------------------------------------------
# Test 21 - ignored MBIDs are filtered out
# ---------------------------------------------------------------------------
class TestIgnoredMbidsAreFilteredOut:
@pytest.mark.asyncio
async def test_ignored_mbids_are_filtered_out(self) -> None:
candidates = _make_release_groups(10)
# Mark 2 candidates as ignored
ignored_mbids = {
candidates[0].release_group_mbid.lower(),
candidates[1].release_group_mbid.lower(),
}
mbid_store = AsyncMock()
mbid_store.get_ignored_release_mbids = AsyncMock(return_value=ignored_mbids)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(picks_count=20)
service = _make_service(
genre_index=genre_index, cache=cache, prefs=prefs, mbid_store=mbid_store,
)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
assert len(result.items) == 8
result_mbids = {item.mbid.lower() for item in result.items if item.mbid}
assert not result_mbids & ignored_mbids
# ---------------------------------------------------------------------------
# Test 22 - Last.fm path when source is lastfm (even if LB is enabled)
# ---------------------------------------------------------------------------
class TestLastfmPathWhenSourceIsLastfm:
@pytest.mark.asyncio
async def test_lastfm_source_uses_lfm_repo_when_lb_also_enabled(self) -> None:
lfm_repo = AsyncMock()
lfm_artists = [
LastFmArtist(name="Artist A", mbid="lfm-artist-1"),
]
lfm_repo.get_global_top_artists = AsyncMock(return_value=lfm_artists)
lb_release_groups = _make_release_groups(3, prefix="lfm-rg")
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(lb_enabled=True, lfm_enabled=True)
service = _make_service(
genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo,
)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[])
service._lb_repo.get_artist_top_release_groups = AsyncMock(
return_value=lb_release_groups,
)
result = await service._build_discover_picks(set(), "lastfm", True, None)
assert result is not None
lfm_repo.get_global_top_artists.assert_awaited_once()
# LB sitewide should NOT have been called since source is lastfm
service._lb_repo.get_sitewide_top_release_groups.assert_not_awaited()
@pytest.mark.asyncio
async def test_listenbrainz_source_uses_lb_repo(self) -> None:
lfm_repo = AsyncMock()
candidates = _make_release_groups(5)
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
prefs = _make_prefs(lb_enabled=True, lfm_enabled=True)
service = _make_service(
genre_index=genre_index, cache=cache, prefs=prefs, lastfm_repo=lfm_repo,
)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is not None
service._lb_repo.get_sitewide_top_release_groups.assert_awaited_once()
# LFM repo should NOT have been called since source is listenbrainz
lfm_repo.get_global_top_artists.assert_not_awaited()
@pytest.mark.asyncio
async def test_ignored_mbid_store_failure_logs_and_continues(self) -> None:
candidates = _make_release_groups(5)
mbid_store = AsyncMock()
mbid_store.get_ignored_release_mbids = AsyncMock(side_effect=Exception("DB down"))
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(
genre_index=genre_index, cache=cache, mbid_store=mbid_store,
)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
# Should still succeed - ignored filtering is best-effort
assert result is not None
assert len(result.items) == 5
# ---------------------------------------------------------------------------
# Test 22 - negative result is cached and reused
# ---------------------------------------------------------------------------
class TestNegativeResultIsCachedAndReused:
@pytest.mark.asyncio
async def test_negative_result_is_cached_and_reused(self) -> None:
"""When all candidates are filtered out, None is cached. Second call returns None without re-fetching."""
candidates = _make_release_groups(3)
library_mbids = {c.release_group_mbid.lower() for c in candidates}
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=candidates)
# First call - all filtered, should cache None
result1 = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user")
assert result1 is None
assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1
# Simulate cache returning the wrapper on second call
cache.get = AsyncMock(return_value={"section": None})
result2 = await service._build_discover_picks(library_mbids, "listenbrainz", True, "user")
assert result2 is None
# LB repo should NOT have been called again
assert service._lb_repo.get_sitewide_top_release_groups.await_count == 1
@pytest.mark.asyncio
async def test_empty_candidates_are_cached(self) -> None:
"""When candidate pool is empty, None is cached."""
genre_index = _make_genre_index(top_genres=[("rock", 10)])
cache = _make_cache()
service = _make_service(genre_index=genre_index, cache=cache)
service._lb_repo.get_sitewide_top_release_groups = AsyncMock(return_value=[])
result = await service._build_discover_picks(set(), "listenbrainz", True, "user")
assert result is None
# Verify cache.set was called with the wrapper
cache.set.assert_awaited()
set_calls = [
c for c in cache.set.call_args_list
if c[0][0] == "discover_picks:listenbrainz"
]
assert len(set_calls) == 1
assert set_calls[0][0][1] == {"section": None}