soulsync/tests/test_spotify_free_metadata.py
BoulderBadgeDad 90174de4b2 Spotify: rename "Spotify Free" → "Spotify (no auth)", default enrichment to it
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.
2026-06-09 12:55:19 -07:00

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