You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/metadata/test_artist_source_detail.py

297 lines
12 KiB

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