PR #853 added artist album-list caching to Deezer, but unlike the Spotify path it had no equivalent of the truncated-fetch guard: Deezer paginates, and a transient/ malformed response on page 2+ (artist with >100 albums) broke the loop and cached the PARTIAL list as the full discography — serving an incomplete album list from cache until TTL. Fix: track whether pagination finished cleanly. A malformed/empty-of-data response mid-walk now clears a `complete` flag and the artist→album-LIST is cached only when complete. Individual album entities still cache regardless (each is complete; we just have fewer). A clean end (short page / empty page / reached limit) still caches as before. Tests: a page-1-ok / page-2-errors walk no longer caches (second call refetches instead of serving a permanently-incomplete list); a clean two-page walk still caches (happy path intact). 181 deezer/metadata tests green.
165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import core.deezer_client as deezer_mod
|
|
import core.discogs_client as discogs_mod
|
|
import core.itunes_client as itunes_mod
|
|
from core.metadata.artist_album_cache import (
|
|
get_cached_artist_album_items,
|
|
get_cached_artist_album_payload,
|
|
make_artist_album_cache_key,
|
|
store_artist_album_items,
|
|
)
|
|
|
|
|
|
class MemoryCache:
|
|
def __init__(self):
|
|
self.entities = {}
|
|
|
|
def get_entity(self, source, entity_type, entity_id):
|
|
return self.entities.get((source, entity_type, entity_id))
|
|
|
|
def store_entity(self, source, entity_type, entity_id, raw_data):
|
|
self.entities[(source, entity_type, entity_id)] = raw_data
|
|
|
|
def store_entities_bulk(self, *args, **kwargs):
|
|
return None
|
|
|
|
|
|
def test_artist_album_cache_helper_round_trips_items_and_payload():
|
|
cache = MemoryCache()
|
|
|
|
store_artist_album_items(
|
|
cache,
|
|
'deezer',
|
|
'artist-1',
|
|
[{'id': 'album-1'}],
|
|
album_type='album,single',
|
|
limit=200,
|
|
extra_fields={'artist_name': 'Artist'},
|
|
)
|
|
|
|
assert make_artist_album_cache_key('artist-1', 'album,single', 200) == 'artist-1_albums_album_single_200'
|
|
assert make_artist_album_cache_key('artist-1', 'album,single', 200, include_limit=False) == 'artist-1_albums_album_single'
|
|
assert get_cached_artist_album_items(cache, 'deezer', 'artist-1', limit=200) == [{'id': 'album-1'}]
|
|
assert get_cached_artist_album_payload(cache, 'deezer', 'artist-1', limit=200)['artist_name'] == 'Artist'
|
|
|
|
|
|
def test_deezer_artist_albums_reuses_list_cache(monkeypatch):
|
|
cache = MemoryCache()
|
|
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
|
|
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
|
|
client._api_get = MagicMock(return_value={
|
|
'data': [{
|
|
'id': 123,
|
|
'title': 'Cached Deezer Album',
|
|
'record_type': 'album',
|
|
'release_date': '2024-01-01',
|
|
'nb_tracks': 10,
|
|
'artist': {'id': 7, 'name': 'Artist'},
|
|
}]
|
|
})
|
|
|
|
first = client.get_artist_albums('7', limit=200)
|
|
second = client.get_artist_albums('7', limit=200)
|
|
|
|
assert [album.name for album in first] == ['Cached Deezer Album']
|
|
assert [album.name for album in second] == ['Cached Deezer Album']
|
|
client._api_get.assert_called_once()
|
|
|
|
|
|
def test_itunes_artist_albums_reuses_list_cache_and_skips_validation(monkeypatch):
|
|
cache = MemoryCache()
|
|
monkeypatch.setattr(itunes_mod, 'get_metadata_cache', lambda: cache)
|
|
client = itunes_mod.iTunesClient.__new__(itunes_mod.iTunesClient)
|
|
client._lookup = MagicMock(side_effect=[
|
|
[{
|
|
'wrapperType': 'collection',
|
|
'collectionId': 456,
|
|
'collectionName': 'Cached iTunes Album',
|
|
'artistId': 8,
|
|
'artistName': 'Artist',
|
|
'trackCount': 10,
|
|
'collectionExplicitness': 'notExplicit',
|
|
'releaseDate': '2024-01-01T00:00:00Z',
|
|
}]
|
|
])
|
|
|
|
first = client.get_artist_albums('8', limit=200)
|
|
second = client.get_artist_albums('8', limit=200)
|
|
|
|
assert [album.name for album in first] == ['Cached iTunes Album']
|
|
assert [album.name for album in second] == ['Cached iTunes Album']
|
|
client._lookup.assert_called_once_with(id='8', entity='album', limit=200)
|
|
|
|
|
|
def test_discogs_artist_albums_reuses_list_cache(monkeypatch):
|
|
cache = MemoryCache()
|
|
monkeypatch.setattr(discogs_mod, 'get_metadata_cache', lambda: cache)
|
|
client = discogs_mod.DiscogsClient.__new__(discogs_mod.DiscogsClient)
|
|
client._api_get = MagicMock(side_effect=[
|
|
{'name': 'Artist'},
|
|
{'releases': [{
|
|
'id': 789,
|
|
'type': 'master',
|
|
'role': 'Main',
|
|
'title': 'Cached Discogs Album',
|
|
'artist': 'Artist',
|
|
'year': 2024,
|
|
}]},
|
|
])
|
|
|
|
first = client.get_artist_albums('9', limit=50)
|
|
second = client.get_artist_albums('9', limit=50)
|
|
|
|
assert [album.name for album in first] == ['Cached Discogs Album']
|
|
assert [album.name for album in second] == ['Cached Discogs Album']
|
|
assert client._api_get.call_count == 2
|
|
|
|
|
|
def _deezer_album(i):
|
|
return {
|
|
'id': i, 'title': f'Album {i}', 'record_type': 'album',
|
|
'release_date': '2024-01-01', 'nb_tracks': 10,
|
|
'artist': {'id': 7, 'name': 'Artist'},
|
|
}
|
|
|
|
|
|
def test_deezer_partial_pagination_not_cached(monkeypatch):
|
|
"""#853 follow-up: a transient/malformed error mid-pagination must NOT cache a
|
|
partial discography (mirrors Spotify's truncated-fetch guard) — otherwise an
|
|
incomplete album list serves from cache until TTL."""
|
|
cache = MemoryCache()
|
|
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
|
|
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
|
|
|
|
full_page = {'data': [_deezer_album(i) for i in range(100)]} # full → forces page 2
|
|
# page 1 ok, page 2 errors (None) → incomplete, on BOTH attempts
|
|
client._api_get = MagicMock(side_effect=[full_page, None, full_page, None])
|
|
|
|
first = client.get_artist_albums('7', limit=200)
|
|
assert len(first) == 100 # page-1 albums still returned
|
|
|
|
second = client.get_artist_albums('7', limit=200)
|
|
assert len(second) == 100
|
|
# No partial cache → the second call refetched (4 api calls total), instead of
|
|
# serving a permanently-incomplete discography from cache (which would be 2).
|
|
assert client._api_get.call_count == 4
|
|
|
|
|
|
def test_deezer_complete_multipage_is_cached(monkeypatch):
|
|
"""A clean multi-page pagination still caches (guard didn't break the happy path)."""
|
|
cache = MemoryCache()
|
|
monkeypatch.setattr(deezer_mod, 'get_metadata_cache', lambda: cache)
|
|
client = deezer_mod.DeezerClient.__new__(deezer_mod.DeezerClient)
|
|
|
|
page1 = {'data': [_deezer_album(i) for i in range(100)]} # full
|
|
page2 = {'data': [_deezer_album(i) for i in range(100, 150)]} # short → clean end
|
|
client._api_get = MagicMock(side_effect=[page1, page2])
|
|
|
|
first = client.get_artist_albums('7', limit=200)
|
|
assert len(first) == 150
|
|
second = client.get_artist_albums('7', limit=200) # served from cache
|
|
assert len(second) == 150
|
|
assert client._api_get.call_count == 2 # no refetch → cached
|