mirror of https://github.com/Nezreka/SoulSync.git
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.
297 lines
12 KiB
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"]
|