mirror of https://github.com/Nezreka/SoulSync.git
Merge pull request #519 from Nezreka/feat/artist-top-tracks-download
Add download buttons + bulk action to artist top-tracks sidebarpull/520/head
commit
f2fb66340a
@ -0,0 +1,218 @@
|
||||
"""Tests for the new artist top-tracks client methods.
|
||||
|
||||
Issue #513: surface an artist's "top X popular songs" for one-click download
|
||||
without pulling the entire discography. Spotify and Deezer expose this via
|
||||
native APIs; iTunes / Discogs / MusicBrainz don't, so the frontend falls
|
||||
back to the existing Last.fm display-only sidebar.
|
||||
|
||||
Scope: client methods only. The Flask endpoint that wraps them is small
|
||||
enough (source dispatch + DB id resolution + JSON response) that
|
||||
exercising the underlying client methods is the load-bearing test layer.
|
||||
A full-app Flask test client wasn't worth pulling in here — importing
|
||||
``web_server`` at test-collection time spins up worker threads that race
|
||||
with caplog-using tests elsewhere in the suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.deezer_client import DeezerClient
|
||||
from core.spotify_client import SpotifyClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spotify client method
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spotify_get_artist_top_tracks_returns_track_list(monkeypatch):
|
||||
"""Wraps spotipy's `artist_top_tracks` and returns the `tracks` array."""
|
||||
client = SpotifyClient.__new__(SpotifyClient)
|
||||
fake_sp = MagicMock()
|
||||
fake_sp.artist_top_tracks.return_value = {
|
||||
'tracks': [
|
||||
{'id': 't1', 'name': 'Song A', 'artists': [{'name': 'Artist'}]},
|
||||
{'id': 't2', 'name': 'Song B', 'artists': [{'name': 'Artist'}]},
|
||||
{'id': 't3', 'name': 'Song C', 'artists': [{'name': 'Artist'}]},
|
||||
]
|
||||
}
|
||||
client.sp = fake_sp
|
||||
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True)
|
||||
|
||||
result = client.get_artist_top_tracks('artist_id', country='US', limit=10)
|
||||
|
||||
assert len(result) == 3
|
||||
assert [t['id'] for t in result] == ['t1', 't2', 't3']
|
||||
fake_sp.artist_top_tracks.assert_called_once_with('artist_id', country='US')
|
||||
|
||||
|
||||
def test_spotify_get_artist_top_tracks_honors_ui_limit(monkeypatch):
|
||||
"""Spotify always returns up to 10 tracks; the limit param is a UI trim only."""
|
||||
client = SpotifyClient.__new__(SpotifyClient)
|
||||
fake_sp = MagicMock()
|
||||
fake_sp.artist_top_tracks.return_value = {
|
||||
'tracks': [{'id': f't{i}', 'name': f'Song {i}'} for i in range(10)]
|
||||
}
|
||||
client.sp = fake_sp
|
||||
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True)
|
||||
|
||||
result = client.get_artist_top_tracks('artist_id', limit=3)
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_spotify_get_artist_top_tracks_returns_empty_when_unauthed(monkeypatch):
|
||||
"""No API call should fire when Spotify isn't authenticated. Lets the
|
||||
endpoint return `success=False, reason=spotify_not_authenticated`
|
||||
instead of throwing."""
|
||||
client = SpotifyClient.__new__(SpotifyClient)
|
||||
fake_sp = MagicMock()
|
||||
client.sp = fake_sp
|
||||
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: False)
|
||||
|
||||
result = client.get_artist_top_tracks('artist_id')
|
||||
assert result == []
|
||||
fake_sp.artist_top_tracks.assert_not_called()
|
||||
|
||||
|
||||
def test_spotify_get_artist_top_tracks_returns_empty_when_artist_id_missing(monkeypatch):
|
||||
"""Defensive guard — no API call for empty/None artist id."""
|
||||
client = SpotifyClient.__new__(SpotifyClient)
|
||||
fake_sp = MagicMock()
|
||||
client.sp = fake_sp
|
||||
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True)
|
||||
|
||||
assert client.get_artist_top_tracks('') == []
|
||||
assert client.get_artist_top_tracks(None) == []
|
||||
fake_sp.artist_top_tracks.assert_not_called()
|
||||
|
||||
|
||||
def test_spotify_get_artist_top_tracks_swallows_api_errors(monkeypatch):
|
||||
"""Network/auth exceptions surface as empty list, not a crash —
|
||||
the endpoint relies on this to fall through gracefully."""
|
||||
client = SpotifyClient.__new__(SpotifyClient)
|
||||
fake_sp = MagicMock()
|
||||
fake_sp.artist_top_tracks.side_effect = RuntimeError("boom")
|
||||
client.sp = fake_sp
|
||||
monkeypatch.setattr(client, 'is_spotify_authenticated', lambda: True)
|
||||
|
||||
result = client.get_artist_top_tracks('artist_id')
|
||||
assert result == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deezer client method
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_deezer_get_artist_top_tracks_returns_spotify_compatible_shape(monkeypatch):
|
||||
"""Deezer's raw shape gets converted to the same dict layout the
|
||||
Spotify endpoint produces (id, name, artists, album, duration_ms,
|
||||
track_number, etc) so downstream code doesn't need to branch."""
|
||||
client = DeezerClient()
|
||||
|
||||
raw_response = {
|
||||
'data': [
|
||||
{
|
||||
'id': 1001,
|
||||
'title': 'Some Hit',
|
||||
'duration': 200, # seconds
|
||||
'rank': 850000,
|
||||
'preview': 'https://example/preview.mp3',
|
||||
'link': 'https://deezer.com/track/1001',
|
||||
'track_position': 3,
|
||||
'disk_number': 1,
|
||||
'explicit_lyrics': False,
|
||||
'artist': {'id': 50, 'name': 'Test Artist'},
|
||||
'album': {
|
||||
'id': 200, 'title': 'Greatest Hits',
|
||||
'cover_xl': 'https://example/cover_xl.jpg',
|
||||
'cover_big': 'https://example/cover_big.jpg',
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(client, '_api_get', lambda path, params=None: raw_response)
|
||||
|
||||
tracks = client.get_artist_top_tracks('50', limit=5)
|
||||
|
||||
assert len(tracks) == 1
|
||||
t = tracks[0]
|
||||
assert t['id'] == '1001'
|
||||
assert t['name'] == 'Some Hit'
|
||||
assert t['duration_ms'] == 200_000 # converted to ms
|
||||
assert t['track_number'] == 3
|
||||
assert t['disc_number'] == 1
|
||||
assert t['artists'] == [{'id': '50', 'name': 'Test Artist'}]
|
||||
assert t['album']['id'] == '200'
|
||||
assert t['album']['name'] == 'Greatest Hits'
|
||||
assert t['album']['album_type'] == 'album'
|
||||
assert any(img['url'] == 'https://example/cover_xl.jpg' for img in t['album']['images'])
|
||||
assert t['_source'] == 'deezer'
|
||||
|
||||
|
||||
def test_deezer_get_artist_top_tracks_empty_when_no_data(monkeypatch):
|
||||
"""Missing artist or empty response → empty list. Endpoint relies on
|
||||
this to report `success=False, reason=no_tracks_found`."""
|
||||
client = DeezerClient()
|
||||
monkeypatch.setattr(client, '_api_get', lambda path, params=None: None)
|
||||
|
||||
assert client.get_artist_top_tracks('50') == []
|
||||
|
||||
|
||||
def test_deezer_get_artist_top_tracks_empty_when_artist_id_missing(monkeypatch):
|
||||
"""Defensive guard — no API call for empty artist id."""
|
||||
client = DeezerClient()
|
||||
called = {'count': 0}
|
||||
|
||||
def fake_api(*args, **kwargs):
|
||||
called['count'] += 1
|
||||
return {'data': []}
|
||||
|
||||
monkeypatch.setattr(client, '_api_get', fake_api)
|
||||
|
||||
assert client.get_artist_top_tracks('') == []
|
||||
assert called['count'] == 0
|
||||
|
||||
|
||||
def test_deezer_get_artist_top_tracks_clamps_limit(monkeypatch):
|
||||
"""Limit param gets clamped at the upper bound (Deezer's max ~100)
|
||||
and falls back to the default when the caller passes 0/None."""
|
||||
client = DeezerClient()
|
||||
captured = {}
|
||||
|
||||
def fake_api(path, params=None):
|
||||
captured['params'] = params
|
||||
return {'data': []}
|
||||
|
||||
monkeypatch.setattr(client, '_api_get', fake_api)
|
||||
|
||||
# Excessive limit clamped down to 100
|
||||
client.get_artist_top_tracks('50', limit=10000)
|
||||
assert captured['params']['limit'] == 100
|
||||
|
||||
# 0 → falsy, falls back to default 10 (better than 1 — caller probably
|
||||
# wanted "give me a sensible top-N", not "give me a single track")
|
||||
client.get_artist_top_tracks('50', limit=0)
|
||||
assert captured['params']['limit'] == 10
|
||||
|
||||
# Small valid limit passes through
|
||||
client.get_artist_top_tracks('50', limit=3)
|
||||
assert captured['params']['limit'] == 3
|
||||
|
||||
|
||||
def test_deezer_get_artist_top_tracks_skips_malformed_entries(monkeypatch):
|
||||
"""Defensive — non-dict entries in the response array get filtered out
|
||||
rather than crashing the loop."""
|
||||
client = DeezerClient()
|
||||
monkeypatch.setattr(client, '_api_get', lambda path, params=None: {
|
||||
'data': [
|
||||
None, # malformed: skipped
|
||||
{'id': 1, 'title': 'Real', 'artist': {'name': 'A'}, 'album': {}},
|
||||
'not a dict', # malformed: skipped
|
||||
]
|
||||
})
|
||||
|
||||
tracks = client.get_artist_top_tracks('50')
|
||||
assert len(tracks) == 1
|
||||
assert tracks[0]['name'] == 'Real'
|
||||
Loading…
Reference in new issue