soulsync/tests/test_artist_albums_cache_poisoning.py
BoulderBadgeDad ef751ce4e4 Artist pages: stop watchlist probes from poisoning the album-list cache
Boulder: "Taylor Swift shows only 8 albums, nothing before 2022, no singles,
no EPs" — for every artist (actually: every WATCHLIST artist). Traced live:
get_artist_albums caches its result under an UNQUALIFIED key (no limit/page
info), and the watchlist's new-release probe (limit=5, max_pages=1 — the
April "reduce watchlist API calls ~90%" optimization) stored its truncated
single page in that same slot. The artist detail page reads the cache first,
so a watchlisted artist's page showed only the newest handful of releases —
newest-first, hence "nothing before 2022" — re-poisoned on every scan, with a
30-day TTL. When the source-priority fetch comes back tiny, the page's
fallback path quietly serves it, so the symptom looked like a discography
filter bug. Not related to the #808 matching change (that is a pure max(),
provably additive).

Three pieces:
- get_artist_albums tracks whether the fetch stopped while more pages
  existed (truncated) and only caches COMPLETE discographies. Individual
  albums keep their opportunistic caching — they're complete entities
  regardless of pagination. A small real discography that fits one page
  stays cacheable even under max_pages=1.
- MetadataCache.purge_artist_album_lists(): delete the already-poisoned
  album-list entries (TTL would have kept them for weeks); lists rebuild
  lazily on the next artist-page visit.
- one-time startup purge in web_server, config-guarded
  (maintenance.album_cache_purge_v1), mirroring the startup-repair pattern.

Tests: truncated probe never stores the list (but still returns its page),
complete multi-page fetch caches, and a genuinely-small one-page discography
under max_pages=1 still caches. 1087 spotify/cache/watchlist/artist tests
pass.
2026-06-07 09:49:30 -07:00

84 lines
3.2 KiB
Python

"""Artist album-list cache poisoning (Boulder: 'Taylor Swift has 8 albums,
nothing before 2022').
get_artist_albums caches its result under an UNQUALIFIED key (no limit/page
info). The watchlist's new-release probe (limit=5, max_pages=1) stored its
truncated page in that slot, so the artist detail page — which reads the
cache — showed only the newest handful of releases for every watchlist
artist. The writer must never cache a fetch that stopped while more pages
existed; complete fetches (even small real discographies) stay cacheable.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import core.spotify_client as sc
from core.spotify_client import SpotifyClient
def _album(i):
return {
'id': f'al{i}', 'name': f'Album {i}', 'album_type': 'album',
'artists': [{'id': 'ar1', 'name': 'Taylor Swift'}],
'release_date': '2024-01-01', 'total_tracks': 12, 'images': [],
'external_urls': {},
}
def _client(monkeypatch, pages):
"""Fake sp.artist_albums + sp.next over a list of page dicts."""
client = SpotifyClient.__new__(SpotifyClient)
fake = MagicMock()
fake.artist_albums.return_value = pages[0]
fake.next.side_effect = pages[1:]
client.sp = fake
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True)
monkeypatch.setattr(sc, '_last_api_call_time', 0)
store_calls = []
cache = MagicMock()
cache.get_entity.return_value = None
cache.store_entity.side_effect = lambda *a, **k: store_calls.append(a)
monkeypatch.setattr(sc, 'get_metadata_cache', lambda: cache)
return client, store_calls
def test_truncated_fetch_is_not_cached(monkeypatch):
# Two pages exist; max_pages=1 stops with a 'next' pending -> truncated.
pages = [
{'items': [_album(1), _album(2)], 'next': 'page2-url'},
{'items': [_album(3)], 'next': None},
]
client, stores = _client(monkeypatch, pages)
albums = client.get_artist_albums('ar1', limit=5, skip_cache=True, max_pages=1)
assert len(albums) == 2 # the probe still works
album_list_stores = [s for s in stores if s[1] == 'artist']
assert album_list_stores == [] # but never poisons the slot
def test_complete_fetch_is_cached(monkeypatch):
pages = [
{'items': [_album(1), _album(2)], 'next': 'page2-url'},
{'items': [_album(3)], 'next': None},
]
client, stores = _client(monkeypatch, pages)
albums = client.get_artist_albums('ar1', limit=50, skip_cache=True, max_pages=0)
assert len(albums) == 3
album_list_stores = [s for s in stores if s[1] == 'artist']
assert len(album_list_stores) == 1 # full discography cached
def test_small_real_discography_with_page_cap_still_cached(monkeypatch):
# Artist genuinely has one page; max_pages=1 didn't truncate anything.
pages = [{'items': [_album(1)], 'next': None}]
client, stores = _client(monkeypatch, pages)
albums = client.get_artist_albums('ar1', limit=5, skip_cache=True, max_pages=1)
assert len(albums) == 1
album_list_stores = [s for s in stores if s[1] == 'artist']
assert len(album_list_stores) == 1 # complete -> cacheable