"""Tests for ``core.artist_source_detail.build_source_only_artist_detail``. The function used to live inline inside ``web_server.py``; a prior version of these tests AST-parsed the function body to assert on response keys because ``web_server.py`` couldn't be imported at test time. Now that the logic lives in a side-effect-free core module with dependency-injected clients, the tests just call it directly with mocks. """ from __future__ import annotations from types import SimpleNamespace import pytest from core import artist_source_detail from core.artist_source_detail import build_source_only_artist_detail # --------------------------------------------------------------------------- # Fixtures — stubs for the metadata helpers the function calls # --------------------------------------------------------------------------- def _success_discography(**overrides): result = { "success": True, "albums": [{"id": "a1", "title": "Album One"}], "eps": [], "singles": [{"id": "s1", "title": "Single One"}], } result.update(overrides) return result def _empty_discography(): return { "success": False, "error": "No releases found for artist", } @pytest.fixture def _stub_metadata(monkeypatch): """Replace the metadata imports with controllable stubs. The function imports ``get_artist_image_url`` and ``get_artist_detail_discography`` from the concrete metadata modules, so we patch those modules directly. """ from core.metadata import artist_image as metadata_artist_image from core.metadata import discography as metadata_discography state = { "image_url": None, "discography": _success_discography(), "last_options": None, "last_discog_call": None, } def fake_get_artist_image_url(artist_id, source_override=None): return state["image_url"] def fake_get_artist_detail_discography(artist_id, artist_name="", options=None): state["last_options"] = options state["last_discog_call"] = (artist_id, artist_name) return state["discography"] monkeypatch.setattr(metadata_artist_image, "get_artist_image_url", fake_get_artist_image_url) monkeypatch.setattr(metadata_discography, "get_artist_detail_discography", fake_get_artist_detail_discography) return state # --------------------------------------------------------------------------- # Group A — Success-path response shape + source-specific ID stamping # --------------------------------------------------------------------------- class TestResponseShape: def test_success_returns_expected_envelope(self, _stub_metadata): payload, status = build_source_only_artist_detail( "dz-123", "Artist One", "deezer", ) assert status == 200 assert payload["success"] is True assert payload["discography"] == _stub_metadata["discography"] assert payload["enrichment_coverage"] == {} assert payload["artist"]["id"] == "dz-123" assert payload["artist"]["name"] == "Artist One" assert payload["artist"]["server_source"] is None assert "genres" in payload["artist"] def test_empty_artist_name_falls_back_to_id(self, _stub_metadata): payload, status = build_source_only_artist_detail( "dz-123", "", "deezer", ) assert status == 200 assert payload["artist"]["name"] == "dz-123" def test_failure_returns_404(self, _stub_metadata): _stub_metadata["discography"] = _empty_discography() payload, status = build_source_only_artist_detail( "dz-missing", "Unknown Artist", "deezer", ) assert status == 404 assert payload["success"] is False assert payload["source"] == "deezer" assert "error" in payload @pytest.mark.parametrize("source,expected_field", [ ("spotify", "spotify_artist_id"), ("itunes", "itunes_artist_id"), ("deezer", "deezer_id"), ("discogs", "discogs_id"), ("hydrabase", "soul_id"), ("musicbrainz", "musicbrainz_id"), ]) def test_source_specific_id_field_is_stamped(self, _stub_metadata, source, expected_field): payload, _ = build_source_only_artist_detail("the-id", "Artist", source) assert payload["artist"][expected_field] == "the-id" # --------------------------------------------------------------------------- # Group B — Discography options contract (the bug that motivated the extract) # --------------------------------------------------------------------------- class TestDiscographyOptions: def test_dedup_variants_disabled(self, _stub_metadata): """Source-only view must show every release variant, matching the retired inline Artists page behaviour.""" build_source_only_artist_detail("dz-1", "Artist", "deezer") opts = _stub_metadata["last_options"] assert opts is not None assert opts.dedup_variants is False def test_passes_source_override_and_artist_source_ids(self, _stub_metadata): build_source_only_artist_detail("sp-999", "Artist", "spotify") opts = _stub_metadata["last_options"] assert opts.source_override == "spotify" assert opts.artist_source_ids == {"spotify": "sp-999"} # --------------------------------------------------------------------------- # Group C — Per-source enrichment # --------------------------------------------------------------------------- class TestPerSourceEnrichment: def test_spotify_extracts_genres_followers_and_image_fallback(self, _stub_metadata): spotify = SimpleNamespace( get_artist=lambda aid, allow_fallback=False: { "name": "Artist", "genres": ["alt rock", "emo"], "followers": {"total": 12345}, "images": [{"url": "https://sp/img.jpg"}], } ) payload, _ = build_source_only_artist_detail( "sp-1", "Artist", "spotify", spotify_client=spotify, ) assert payload["artist"]["genres"] == ["alt rock", "emo"] assert payload["artist"]["followers"] == 12345 # image_url falls back to Spotify's image when metadata returned None assert payload["artist"]["image_url"] == "https://sp/img.jpg" def test_empty_name_uses_source_artist_name_when_available(self, _stub_metadata): spotify = SimpleNamespace( get_artist=lambda aid, allow_fallback=False: { "name": "Kendrick Lamar", "genres": [], "followers": {}, "images": [], } ) payload, _ = build_source_only_artist_detail( "2YZyLoL8N0Wb9xBt1NhZWg", "", "spotify", spotify_client=spotify, ) assert payload["artist"]["name"] == "Kendrick Lamar" assert _stub_metadata["last_discog_call"] == ( "2YZyLoL8N0Wb9xBt1NhZWg", "Kendrick Lamar", ) def test_deezer_extracts_genres_and_followers(self, _stub_metadata): deezer = SimpleNamespace( get_artist_info=lambda aid: { "genres": ["pop"], "followers": {"total": 500}, } ) payload, _ = build_source_only_artist_detail( "dz-1", "Artist", "deezer", deezer_client=deezer, ) assert payload["artist"]["genres"] == ["pop"] assert payload["artist"]["followers"] == 500 def test_itunes_extracts_genres_only(self, _stub_metadata): itunes = SimpleNamespace(get_artist=lambda aid: {"genres": ["rock"]}) payload, _ = build_source_only_artist_detail( "it-1", "Artist", "itunes", itunes_client=itunes, ) assert payload["artist"]["genres"] == ["rock"] assert "followers" not in payload["artist"] def test_discogs_extracts_genres_only(self, _stub_metadata): discogs = SimpleNamespace(get_artist=lambda aid: {"genres": ["jazz"]}) payload, _ = build_source_only_artist_detail( "dc-1", "Artist", "discogs", discogs_client=discogs, ) assert payload["artist"]["genres"] == ["jazz"] def test_client_none_is_safe(self, _stub_metadata): """Missing client for the requested source is a no-op, not a crash.""" payload, status = build_source_only_artist_detail( "dz-1", "Artist", "deezer", deezer_client=None, ) assert status == 200 assert payload["artist"]["genres"] == [] def test_client_exception_does_not_propagate(self, _stub_metadata): """A failing source client should log and move on; the response still builds.""" def _boom(_): raise RuntimeError("deezer down") deezer = SimpleNamespace(get_artist_info=_boom) payload, status = build_source_only_artist_detail( "dz-1", "Artist", "deezer", deezer_client=deezer, ) assert status == 200 assert payload["artist"]["genres"] == [] # --------------------------------------------------------------------------- # Group D — Last.fm enrichment # --------------------------------------------------------------------------- class _FakeLastFM: def __init__(self, api_key=None): self.api_key = api_key def get_artist_info(self, name): return { "bio": {"content": "Long bio text", "summary": "Short summary"}, "stats": {"listeners": "5000", "playcount": "99999"}, "url": "https://last.fm/artist", } class TestLastFmEnrichment: def _patch_lastfm(self, monkeypatch, cls=_FakeLastFM): import core.lastfm_client as lastfm_module monkeypatch.setattr(lastfm_module, "LastFMClient", cls) def test_lastfm_fields_populated_when_api_key_present(self, _stub_metadata, monkeypatch): self._patch_lastfm(monkeypatch) payload, _ = build_source_only_artist_detail( "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", ) artist = payload["artist"] assert artist["lastfm_bio"] == "Long bio text" assert artist["lastfm_listeners"] == 5000 assert artist["lastfm_playcount"] == 99999 assert artist["lastfm_url"] == "https://last.fm/artist" def test_no_lastfm_when_api_key_missing(self, _stub_metadata, monkeypatch): self._patch_lastfm(monkeypatch) payload, _ = build_source_only_artist_detail( "dz-1", "Artist", "deezer", lastfm_api_key=None, ) assert "lastfm_bio" not in payload["artist"] assert "lastfm_listeners" not in payload["artist"] def test_summary_used_when_bio_content_missing(self, _stub_metadata, monkeypatch): class _SummaryOnly(_FakeLastFM): def get_artist_info(self, name): return { "bio": {"summary": "Just a summary"}, "stats": {}, "url": "", } self._patch_lastfm(monkeypatch, _SummaryOnly) payload, _ = build_source_only_artist_detail( "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", ) assert payload["artist"]["lastfm_bio"] == "Just a summary" def test_lastfm_exception_does_not_propagate(self, _stub_metadata, monkeypatch): class _Broken(_FakeLastFM): def get_artist_info(self, name): raise RuntimeError("last.fm rate limited") self._patch_lastfm(monkeypatch, _Broken) payload, status = build_source_only_artist_detail( "dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY", ) assert status == 200 assert "lastfm_bio" not in payload["artist"]