Artist detail pages previously always pushed /artist-detail to the URL, so refreshing the page or sharing a link would drop users on a broken empty page with no artist loaded. URL format is now /artist-detail/:source/:id (e.g. /artist-detail/spotify/4tZwfgrHOc3mvqsCAfo4LT or /artist-detail/library/42). The source segment lets the backend synthesize a response from the right metadata client without a DB hit. Changes: Client routing (legacy shell + TanStack bridge) - buildArtistDetailPath / _getDeepLinkArtistDetail added to init.js; parse both new :source/:id and legacy bare :id formats so old bookmarks still work - navigateToPage passes artistId + artistSource through to the router bridge, which builds the dynamic href instead of hardcoding route.path - resolveShellPageFromPath / resolveLegacyShellPageFromPath use a prefix match so /artist-detail/* resolves to artist-detail page-id - globals.d.ts typed for artistId / artistSource options - activateLegacyPath and syncActivePageFromLocation (popstate) both restore artist from URL using skipRouteChange:true to avoid a re-navigation loop back to /artist-detail - loadInitialData restores artist from URL on page load (router not yet mounted at DOMContentLoaded so legacy path runs unconditionally) - Same-artist guard in navigateToArtistDetail prevents double-fetch when the router fires activateLegacyPath after the initial navigation Server - artist_source_detail.build_source_only_artist_detail now resolves artist name from the source API when none is supplied, so deep-link restores with an empty name string still render correctly Tests - test_spa_deep_linking: /artist-detail/42 and /artist-detail/spotify/ID both serve index.html - bridge.test.ts: source-aware URL building and library fallback - route-manifest.test.ts: prefix path resolution - artist_source_detail: name resolved from source when input is empty
296 lines
12 KiB
Python
296 lines
12 KiB
Python
"""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"]
|