Per Boulder's calls on the new enrichment toggle: - Naming: "Spotify Free" was misleading (it's a hybrid — pick it, connect an account, and sync still uses your official playlists). Relabel the user-facing strings to "Spotify (no auth)" — the real distinction is needs-credentials vs not. Internal value/key (spotify_free, _free_*) unchanged, so no migration. - Default ON: metadata.spotify_free_enrichment now defaults True (worker + UI load both treat unset as on). So bulk enrichment runs on the no-auth path by default and the official account is reserved for interactive search/sync; turn the toggle off to enrich through the connected account. The toggle overrides auth for the worker (authed users still enrich via no-auth) — matching the intended model. - Worker runs on the toggle alone: is_spotify_metadata_available() now honors _prefer_free (+ package installed), so the worker enriches via no-auth even with no account connected and no 'no-auth' source selected. Only fires on a client carrying the flag (the worker's own), so interactive/watchlist availability is unchanged. - UI: moved the toggle from "Metadata Source" to the Spotify section next to the auth fields, always visible, on by default. Help notes the genre trade-off. Tests: prefer_free makes metadata available without auth/source (and is inert without the package); interactive availability unaffected. 218 Spotify tests pass.
426 lines
19 KiB
Python
426 lines
19 KiB
Python
"""Tests for core/spotify_free_metadata.py — the no-creds Spotify fallback.
|
|
|
|
Pure-unit only: the live SpotipyFree calls hit the network and can't run in CI,
|
|
but the two things that actually need pinning are pure — the activation gate and
|
|
the artist normalizer (the one shape SpotipyFree returns raw). Fixtures below
|
|
are trimmed from real captured responses (2026-06).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from core.spotify_free_metadata import (
|
|
normalize_artist,
|
|
should_block_rate_limited_resume,
|
|
should_offer_spotify_metadata,
|
|
should_use_free_fallback,
|
|
spotify_free_installed,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# should_block_rate_limited_resume — the worker resume guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_resume_blocked_when_rate_limited_and_nothing_serves():
|
|
# Plain auth, no free: resuming during a ban would just sleep → block.
|
|
assert should_block_rate_limited_resume(rate_limited=True, metadata_available=False) is True
|
|
|
|
|
|
def test_resume_allowed_when_free_can_bridge():
|
|
# Rate-limited but free is available (metadata_available True during a ban
|
|
# because is_spotify_authenticated() is False while banned) → allow resume,
|
|
# the worker bridges via free.
|
|
assert should_block_rate_limited_resume(rate_limited=True, metadata_available=True) is False
|
|
|
|
|
|
def test_resume_never_blocked_when_not_rate_limited():
|
|
assert should_block_rate_limited_resume(rate_limited=False, metadata_available=False) is False
|
|
assert should_block_rate_limited_resume(rate_limited=False, metadata_available=True) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# should_use_free_fallback — the activation gate
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_gate_open_when_no_auth():
|
|
assert should_use_free_fallback(authenticated=False, rate_limited=False) is True
|
|
|
|
|
|
def test_gate_open_when_rate_limited_even_if_authed():
|
|
assert should_use_free_fallback(authenticated=True, rate_limited=True) is True
|
|
|
|
|
|
def test_gate_closed_when_authed_and_healthy():
|
|
# The critical guarantee: with working Spotify auth and no rate limit, the
|
|
# free source must NEVER activate.
|
|
assert should_use_free_fallback(authenticated=True, rate_limited=False) is False
|
|
|
|
|
|
def test_gate_open_when_no_auth_and_rate_limited():
|
|
assert should_use_free_fallback(authenticated=False, rate_limited=True) is True
|
|
|
|
|
|
def test_gate_open_when_budget_exhausted_even_if_authed_and_healthy():
|
|
# #758-follow-up: the real-API daily budget is spent, but the user has
|
|
# Spotify Free — switch to the uncapped free source instead of pausing.
|
|
assert should_use_free_fallback(authenticated=True, rate_limited=False,
|
|
budget_exhausted=True) is True
|
|
|
|
|
|
def test_gate_closed_when_authed_healthy_and_under_budget():
|
|
assert should_use_free_fallback(authenticated=True, rate_limited=False,
|
|
budget_exhausted=False) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# should_offer_spotify_metadata — the availability gate the callers use
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_offer_when_authed_even_if_no_free():
|
|
assert should_offer_spotify_metadata(authenticated=True, free_available=False) is True
|
|
|
|
|
|
def test_offer_when_free_available_even_if_no_auth():
|
|
# The whole point: no auth but free available → Spotify source stays usable.
|
|
assert should_offer_spotify_metadata(authenticated=False, free_available=True) is True
|
|
|
|
|
|
def test_not_offered_when_neither():
|
|
assert should_offer_spotify_metadata(authenticated=False, free_available=False) is False
|
|
|
|
|
|
def test_full_composition_authed_healthy_never_uses_free():
|
|
# The critical no-regression guarantee end to end: authed + healthy →
|
|
# offered (official), and the per-request gate stays CLOSED.
|
|
assert should_offer_spotify_metadata(authenticated=True, free_available=True) is True
|
|
assert should_use_free_fallback(authenticated=True, rate_limited=False) is False
|
|
|
|
|
|
def test_spotify_free_installed_returns_bool():
|
|
# Whatever the env, it's a cached bool and doesn't raise / hit network.
|
|
assert isinstance(spotify_free_installed(), bool)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# normalize_artist — raw web-player GraphQL → Spotify-compatible artist dict
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Trimmed from a real `SpotipyFree.artist()` response.
|
|
_RAW_ARTIST = {
|
|
'__typename': 'Artist',
|
|
'id': '4Z8W4fKeB5YxbusRsdQVPb',
|
|
'uri': 'spotify:artist:4Z8W4fKeB5YxbusRsdQVPb',
|
|
'profile': {'name': 'Radiohead', 'biography': {'text': '...'}},
|
|
'stats': {'followers': 16239336, 'monthlyListeners': 45139421},
|
|
'visuals': {
|
|
'avatarImage': {
|
|
'sources': [
|
|
{'height': 640, 'width': 640, 'url': 'https://i.scdn.co/image/big'},
|
|
{'height': 160, 'width': 160, 'url': 'https://i.scdn.co/image/small'},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
|
|
# Trimmed from a real `artist_search` item's `data` (no usable image — only
|
|
# color swatches under visualIdentity; SoulSync lazy-loads art separately).
|
|
_RAW_SEARCH_ARTIST = {
|
|
'__typename': 'Artist',
|
|
'uri': 'spotify:artist:1dfeR4HaWDbWqFHLkxsg1d',
|
|
'profile': {'name': 'Queen'},
|
|
'visualIdentity': {'squareCoverImage': {'extractedColorSet': {}}},
|
|
}
|
|
|
|
|
|
def test_normalize_artist_full_shape():
|
|
out = normalize_artist(_RAW_ARTIST)
|
|
assert out['id'] == '4Z8W4fKeB5YxbusRsdQVPb'
|
|
assert out['name'] == 'Radiohead'
|
|
assert out['followers'] == {'total': 16239336}
|
|
assert out['images'][0]['url'] == 'https://i.scdn.co/image/big'
|
|
assert len(out['images']) == 2
|
|
assert out['external_urls'] == {
|
|
'spotify': 'https://open.spotify.com/artist/4Z8W4fKeB5YxbusRsdQVPb'
|
|
}
|
|
assert out['genres'] == [] # web player doesn't provide genres
|
|
|
|
|
|
def test_normalize_artist_id_from_uri_when_no_id_field():
|
|
out = normalize_artist(_RAW_SEARCH_ARTIST)
|
|
assert out['id'] == '1dfeR4HaWDbWqFHLkxsg1d'
|
|
assert out['name'] == 'Queen'
|
|
assert out['images'] == [] # search items carry no usable image
|
|
assert out['external_urls']['spotify'].endswith('1dfeR4HaWDbWqFHLkxsg1d')
|
|
|
|
|
|
def test_normalize_artist_handles_empty_and_none():
|
|
for bad in (None, {}, {'profile': None, 'stats': None, 'visuals': None}):
|
|
out = normalize_artist(bad)
|
|
assert out['name'] == ''
|
|
assert out['images'] == []
|
|
assert out['followers'] == {'total': 0}
|
|
assert out['external_urls'] == {}
|
|
|
|
|
|
def test_normalize_artist_skips_imageless_sources():
|
|
raw = {'uri': 'spotify:artist:x', 'profile': {'name': 'A'},
|
|
'visuals': {'avatarImage': {'sources': [{'height': 1}, {'url': 'u'}]}}}
|
|
out = normalize_artist(raw)
|
|
assert out['images'] == [{'url': 'u', 'height': None, 'width': None}]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SpotifyClient gate model — auto-bridge vs explicit opt-in vs no-surprise
|
|
# (constructs the client via __new__ so no network/config init runs)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
from unittest.mock import patch # noqa: E402
|
|
from core.spotify_client import SpotifyClient # noqa: E402
|
|
import core.spotify_free_metadata as _sfm # noqa: E402
|
|
|
|
|
|
def _gate(authed, has_creds, selected, installed, rate_limited):
|
|
c = SpotifyClient.__new__(SpotifyClient)
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=rate_limited), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
|
|
cm.get_spotify_config.return_value = (
|
|
{'client_id': 'x', 'client_secret': 'y'} if has_creds else {})
|
|
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
|
|
return c.is_spotify_metadata_available(), c._free_active()
|
|
|
|
|
|
def test_connected_healthy_uses_official():
|
|
avail, free = _gate(authed=True, has_creds=True, selected=False, installed=True, rate_limited=False)
|
|
assert avail is True and free is False # official; free never opens
|
|
|
|
|
|
def test_plain_spotify_ratelimited_does_NOT_bridge():
|
|
# OPT-IN: a user on plain 'Spotify' (didn't pick Spotify Free) waits out a
|
|
# rate-limit ban — free does NOT auto-bridge for them. No surprise scraping.
|
|
avail, free = _gate(authed=False, has_creds=True, selected=False, installed=True, rate_limited=True)
|
|
assert avail is False and free is False
|
|
|
|
|
|
def test_spotify_free_user_ratelimited_bridges():
|
|
# A user who DID pick Spotify Free and also connected an account: official
|
|
# when healthy, free bridges during a ban.
|
|
avail, free = _gate(authed=False, has_creds=True, selected=True, installed=True, rate_limited=True)
|
|
assert avail is True and free is True
|
|
|
|
|
|
def test_spotify_free_user_healthy_uses_official():
|
|
avail, free = _gate(authed=True, has_creds=True, selected=True, installed=True, rate_limited=False)
|
|
assert avail is True and free is False # picked Spotify Free but authed -> official
|
|
|
|
|
|
def test_no_auth_picked_spotify_free_serves():
|
|
avail, free = _gate(authed=False, has_creds=False, selected=True, installed=True, rate_limited=False)
|
|
assert avail is True and free is True
|
|
|
|
|
|
def test_no_auth_not_opted_in_does_nothing():
|
|
# The key no-surprise guarantee: a user who never chose Spotify gets no free.
|
|
avail, free = _gate(authed=False, has_creds=False, selected=False, installed=True, rate_limited=False)
|
|
assert avail is False and free is False
|
|
|
|
|
|
def test_selected_but_package_missing_is_graceful():
|
|
avail, free = _gate(authed=False, has_creds=False, selected=True, installed=False, rate_limited=False)
|
|
assert avail is False and free is False
|
|
|
|
|
|
def test_connected_ratelimited_but_no_package_no_bridge():
|
|
avail, free = _gate(authed=False, has_creds=True, selected=False, installed=False, rate_limited=True)
|
|
assert avail is False and free is False
|
|
|
|
|
|
# ── #(free album search): rank + artist-discography workaround ──────────────
|
|
|
|
from core.spotify_free_metadata import ( # noqa: E402
|
|
rank_albums_by_name, SpotifyFreeMetadataClient,
|
|
)
|
|
|
|
|
|
def test_rank_albums_by_name_orders_best_first_and_limits():
|
|
albums = [{'name': 'Random Access Memories'}, {'name': 'GNX'},
|
|
{'name': 'GNX (Deluxe)'}, {'name': 'untitled unmastered.'}]
|
|
out = rank_albums_by_name(albums, 'GNX', limit=2)
|
|
assert [a['name'] for a in out] == ['GNX', 'GNX (Deluxe)']
|
|
|
|
|
|
def test_rank_albums_by_name_handles_empty():
|
|
assert rank_albums_by_name([], 'GNX') == []
|
|
assert rank_albums_by_name(None, 'GNX') == []
|
|
|
|
|
|
def test_search_albums_via_artist_resolves_through_discography():
|
|
c = SpotifyFreeMetadataClient()
|
|
c.search_artists = lambda q, limit=5: [
|
|
{'id': 'art_other', 'name': 'Some Other Band'},
|
|
{'id': 'art_k', 'name': 'Kendrick Lamar'},
|
|
]
|
|
c.get_artist_albums_list = lambda aid, limit=50: (
|
|
[{'id': 'al1', 'name': 'DAMN.'}, {'id': 'al2', 'name': 'GNX'}]
|
|
if aid == 'art_k' else [])
|
|
out = c.search_albums_via_artist('Kendrick Lamar', 'GNX', limit=3)
|
|
assert out and out[0]['name'] == 'GNX' # picked the right artist + ranked
|
|
|
|
|
|
def test_search_albums_via_artist_empty_without_artist_or_album():
|
|
c = SpotifyFreeMetadataClient()
|
|
c.search_artists = lambda q, limit=5: [{'id': 'a', 'name': 'X'}]
|
|
assert c.search_albums_via_artist('', 'GNX') == []
|
|
assert c.search_albums_via_artist('X', '') == []
|
|
|
|
|
|
def test_search_albums_via_artist_empty_when_no_artist_match():
|
|
c = SpotifyFreeMetadataClient()
|
|
c.search_artists = lambda q, limit=5: [] # nothing found
|
|
assert c.search_albums_via_artist('Nobody', 'GNX') == []
|
|
|
|
|
|
def test_client_search_albums_uses_free_via_artist_when_active():
|
|
"""SpotifyClient.search_albums bridges album matching through Spotify Free
|
|
(artist discography) when free is active, instead of dropping to iTunes/Deezer."""
|
|
c = SpotifyClient.__new__(SpotifyClient)
|
|
fake_free = SpotifyFreeMetadataClient()
|
|
fake_free.search_albums_via_artist = lambda artist, album, limit: [
|
|
{'id': 'al2', 'name': 'GNX',
|
|
'artists': [{'name': 'Kendrick Lamar', 'id': 'art_k'}]}
|
|
]
|
|
c._free_meta_client = fake_free
|
|
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
|
|
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=False), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
|
|
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=True):
|
|
cm.get_spotify_config.return_value = {}
|
|
cm.get.side_effect = lambda k, d=None: True if k == 'metadata.spotify_free' else d
|
|
results = c.search_albums('Kendrick Lamar GNX', limit=5,
|
|
artist='Kendrick Lamar', album='GNX')
|
|
|
|
assert len(results) == 1 and results[0].name == 'GNX'
|
|
assert results[0].id == 'al2'
|
|
|
|
|
|
# ── prefer-free (enrichment opt-in) + the use_spotify root-cause fix ─────────
|
|
|
|
def _free_active_with(prefer_free, installed, authed=True, rate_limited=False,
|
|
selected=False, budget=False):
|
|
c = SpotifyClient.__new__(SpotifyClient)
|
|
if prefer_free:
|
|
c._prefer_free = True
|
|
if budget:
|
|
c._budget_exhausted_use_free = True
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=rate_limited), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
|
|
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
|
|
return c._free_active()
|
|
|
|
|
|
def test_prefer_free_activates_even_when_authed_healthy_under_budget():
|
|
# The enrichment opt-in: free serves even though official is perfectly usable.
|
|
assert _free_active_with(prefer_free=True, installed=True) is True
|
|
|
|
|
|
def test_prefer_free_inert_without_package():
|
|
# Graceful: opt-in set but SpotipyFree missing -> stays on official.
|
|
assert _free_active_with(prefer_free=True, installed=False) is False
|
|
|
|
|
|
def test_no_prefer_free_authed_healthy_stays_official():
|
|
assert _free_active_with(prefer_free=False, installed=True, selected=True) is False
|
|
|
|
|
|
def _client_that_forbids_official(prefer_free=False, budget=False):
|
|
"""A SpotifyClient whose official .sp blows up if touched + a Free client that
|
|
serves albums — so a passing search proves official was skipped."""
|
|
c = SpotifyClient.__new__(SpotifyClient)
|
|
if prefer_free:
|
|
c._prefer_free = True
|
|
if budget:
|
|
c._budget_exhausted_use_free = True
|
|
|
|
class _Sp:
|
|
def search(self, *a, **k):
|
|
raise AssertionError("official Spotify API must not be called when deferring to Free")
|
|
c.sp = _Sp()
|
|
fake_free = SpotifyFreeMetadataClient()
|
|
fake_free.search_albums_via_artist = lambda artist, album, limit: [
|
|
{'id': 'al2', 'name': 'GNX', 'artists': [{'name': 'Kendrick Lamar', 'id': 'art_k'}]}]
|
|
c._free_meta_client = fake_free
|
|
return c
|
|
|
|
|
|
def test_search_albums_prefers_free_when_authed_and_prefer_free_set():
|
|
"""Root-cause regression: prefer_free makes an AUTHED, healthy client defer to
|
|
Spotify Free instead of the official API. Previously the use_spotify gate
|
|
(= is_spotify_authenticated()) ignored _free_active() and hit official."""
|
|
c = _client_that_forbids_official(prefer_free=True)
|
|
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=True), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
|
|
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=True):
|
|
cm.get_spotify_config.return_value = {'client_id': 'x', 'client_secret': 'y'}
|
|
cm.get.side_effect = lambda k, d=None: d
|
|
results = c.search_albums('Kendrick Lamar GNX', limit=5,
|
|
artist='Kendrick Lamar', album='GNX')
|
|
assert len(results) == 1 and results[0].id == 'al2' # Free served; official untouched
|
|
|
|
|
|
def test_search_albums_diverts_to_free_when_budget_exhausted_and_authed():
|
|
"""Budget→Free bridge regression: an AUTHED client that has spent the daily
|
|
budget defers to Free instead of hammering the official API (which the budget
|
|
exists to protect). This is the divert that previously never happened."""
|
|
c = _client_that_forbids_official(budget=True)
|
|
fake_cache = type('C', (), {'get_search_results': lambda *a, **k: None})()
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=True), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
|
|
patch('core.spotify_client.get_metadata_cache', return_value=fake_cache), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=True):
|
|
cm.get_spotify_config.return_value = {'client_id': 'x', 'client_secret': 'y'}
|
|
# metadata.spotify_free=True so the budget path's _free_available() holds
|
|
cm.get.side_effect = lambda k, d=None: True if k == 'metadata.spotify_free' else d
|
|
results = c.search_albums('Kendrick Lamar GNX', limit=5,
|
|
artist='Kendrick Lamar', album='GNX')
|
|
assert len(results) == 1 and results[0].id == 'al2'
|
|
|
|
|
|
# ── default-ON enrichment: prefer_free makes metadata available to the worker ──
|
|
|
|
def _metadata_available(prefer_free, installed, authed=False, selected=False):
|
|
c = SpotifyClient.__new__(SpotifyClient)
|
|
if prefer_free:
|
|
c._prefer_free = True
|
|
with patch.object(SpotifyClient, 'is_spotify_authenticated', return_value=authed), \
|
|
patch('core.spotify_client.config_manager') as cm, \
|
|
patch('core.spotify_client._is_globally_rate_limited', return_value=False), \
|
|
patch.object(_sfm, 'spotify_free_installed', return_value=installed):
|
|
cm.get.side_effect = lambda k, d=None: selected if k == 'metadata.spotify_free' else d
|
|
return c.is_spotify_metadata_available()
|
|
|
|
|
|
def test_prefer_free_makes_metadata_available_without_auth_or_source():
|
|
# Default-ON enrichment: the worker runs via the no-auth path on the toggle
|
|
# alone — no account connected, no 'no-auth Spotify' source selected.
|
|
assert _metadata_available(prefer_free=True, installed=True) is True
|
|
|
|
|
|
def test_prefer_free_metadata_unavailable_without_package():
|
|
assert _metadata_available(prefer_free=True, installed=False) is False
|
|
|
|
|
|
def test_interactive_metadata_availability_unaffected_by_prefer_free():
|
|
# A client WITHOUT _prefer_free (interactive/global): no auth + no source -> unavailable.
|
|
assert _metadata_available(prefer_free=False, installed=True) is False
|
|
# ...and authed is available as before.
|
|
assert _metadata_available(prefer_free=False, installed=True, authed=True) is True
|