import httpx import pytest import respx from fansifter_common.httpclient import HTTPClientError from pydantic import SecretStr from resonance_engine.adapters.spotify import ( API_BASE, TOKEN_URL, SpotifyClient, SpotifyHTTPError, ) class TestSpotifyHTTPError: def test_detail_appends_api_error_message(self) -> None: response = httpx.Response( 403, json={"error": {"status": 403, "message": "Insufficient client scope"}}, ) exc = SpotifyHTTPError("status 403", response=response) assert exc.detail == "Insufficient client scope" assert str(exc) == "status 403: Insufficient client scope" def test_detail_none_for_non_json_body(self) -> None: exc = SpotifyHTTPError( "status 403", response=httpx.Response(403, content=b"nope") ) assert exc.detail is None assert str(exc) == "status 403" def test_detail_none_without_response(self) -> None: exc = SpotifyHTTPError("status 403") assert exc.detail is None assert str(exc) == "status 403" class TestSpotifyClient: @pytest.fixture(scope="class") def client(self) -> SpotifyClient: return SpotifyClient("id", SecretStr("secret")) def test_refresh_token_returns_token( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: token_response = { "access_token": "acc", "token_type": "Bearer", "scope": "user-read-private", "expires_in": 3600, } respx_mock.post(TOKEN_URL).mock( return_value=httpx.Response(200, json=token_response) ) result = client.refresh_token(SecretStr("ref")) assert result == { "access_token": SecretStr("acc"), "scope": "user-read-private", } def test_refresh_token_raises_401_on_invalid_grant( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.post(TOKEN_URL).mock( return_value=httpx.Response( 400, json={ "error": "invalid_grant", "error_description": "Refresh token revoked", }, ) ) with pytest.raises(SpotifyHTTPError, match="Refresh token revoked") as exc_info: client.refresh_token(SecretStr("ref")) assert exc_info.value.status_code == 401 def test_refresh_token_raises_401_on_unauthorized_client( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.post(TOKEN_URL).mock( return_value=httpx.Response( 400, json={ "error": "unauthorized_client", "error_description": "Client not authorized", }, ) ) with pytest.raises(SpotifyHTTPError, match="Client not authorized") as exc_info: client.refresh_token(SecretStr("ref")) assert exc_info.value.status_code == 401 def test_refresh_token_keeps_status_on_non_revoked_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.post(TOKEN_URL).mock( return_value=httpx.Response( 400, json={ "error": "invalid_client", "error_description": "Invalid credentials", }, ) ) with pytest.raises(SpotifyHTTPError, match="Invalid credentials") as exc_info: client.refresh_token(SecretStr("ref")) assert exc_info.value.status_code == 400 def test_refresh_token_raises_http_error_on_non_json_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.post(TOKEN_URL).mock(return_value=httpx.Response(500)) with pytest.raises(SpotifyHTTPError, match="HTTP 500") as exc_info: client.refresh_token(SecretStr("ref")) assert exc_info.value.status_code == 500 def test_refresh_token_raises_http_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.post(TOKEN_URL).mock(side_effect=httpx.ConnectError("unreachable")) with pytest.raises(SpotifyHTTPError): client.refresh_token(SecretStr("ref")) def test_get_profile_returns_profile( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: profile = { "id": "user123", "email": "user@example.com", "display_name": "Test User", } respx_mock.get(f"{API_BASE}/me").mock( return_value=httpx.Response(200, json=profile) ) result = client.get_current_user_profile(SecretStr("acc")) assert result == profile def test_get_profile_raises_api_error_on_401( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me").mock(return_value=httpx.Response(401)) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_profile(SecretStr("acc")) assert exc_info.value.status_code == 401 def test_get_profile_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me").mock(side_effect=httpx.ConnectError("timeout")) with pytest.raises(HTTPClientError): client.get_current_user_profile(SecretStr("acc")) def test_get_top_artists_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist = { "id": "a1", "name": "Artist One", "popularity": 80, "genres": ["pop"], "type": "artist", "uri": "spotify:artist:a1", } respx_mock.get(f"{API_BASE}/me/top/artists").mock( return_value=httpx.Response( 200, json={ "items": [artist], "total": 1, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/top/artists", }, ) ) result = client.get_current_user_top_artists(SecretStr("acc")) assert result == [artist] def test_get_top_artists_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist1 = { "id": "a1", "name": "Artist One", "popularity": 80, "genres": ["pop"], "type": "artist", "uri": "spotify:artist:a1", } artist2 = { "id": "a2", "name": "Artist Two", "popularity": 60, "genres": ["rock"], "type": "artist", "uri": "spotify:artist:a2", } def side_effect(request: httpx.Request) -> httpx.Response: offset = int(request.url.params.get("offset", 0)) if offset == 0: return httpx.Response( 200, json={ "items": [artist1], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/top/artists", "next": f"{API_BASE}/me/top/artists?offset=50", }, ) return httpx.Response( 200, json={ "items": [artist2], "total": 2, "limit": 50, "offset": 50, "href": f"{API_BASE}/me/top/artists", }, ) respx_mock.get(f"{API_BASE}/me/top/artists").mock(side_effect=side_effect) result = client.get_current_user_top_artists(SecretStr("acc")) assert result == [artist1, artist2] def test_get_top_artists_passes_time_range( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/artists").mock( return_value=httpx.Response( 200, json={ "items": [], "total": 0, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/top/artists", }, ) ) client.get_current_user_top_artists(SecretStr("acc"), time_range="long_term") assert respx_mock.calls[0].request.url.params["time_range"] == "long_term" def test_get_top_artists_raises_api_error_with_retry_after_on_429( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/artists").mock( return_value=httpx.Response(429, headers={"Retry-After": "30"}) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_top_artists(SecretStr("acc")) assert exc_info.value.status_code == 429 assert exc_info.value.response is not None assert exc_info.value.response.headers["Retry-After"] == "30" def test_get_top_artists_raises_api_error_without_retry_after_on_429( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/artists").mock( return_value=httpx.Response(429) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_top_artists(SecretStr("acc")) assert exc_info.value.status_code == 429 assert exc_info.value.response is not None assert "Retry-After" not in exc_info.value.response.headers def test_get_top_tracks_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist = { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } album = { "id": "al1", "name": "Some Album", "album_type": "album", "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": "spotify:album:al1", "artists": [artist], } track = { "id": "t1", "name": "Track One", "popularity": 75, "type": "track", "uri": "spotify:track:t1", "duration_ms": 200000, "explicit": False, "track_number": 1, "disc_number": 1, "album": album, "artists": [artist], } respx_mock.get(f"{API_BASE}/me/top/tracks").mock( return_value=httpx.Response( 200, json={ "items": [track], "total": 1, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/top/tracks", }, ) ) result = client.get_current_user_top_tracks(SecretStr("acc")) assert result == [track] def test_get_top_tracks_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist = { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } album = { "id": "al1", "name": "Some Album", "album_type": "album", "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": "spotify:album:al1", "artists": [artist], } def _track(track_id: str) -> dict: return { "id": track_id, "name": f"Track {track_id}", "popularity": 75, "type": "track", "uri": f"spotify:track:{track_id}", "duration_ms": 200000, "explicit": False, "track_number": 1, "disc_number": 1, "album": album, "artists": [artist], } track1 = _track("t1") track2 = _track("t2") def side_effect(request: httpx.Request) -> httpx.Response: offset = int(request.url.params.get("offset", 0)) track = track1 if offset == 0 else track2 return httpx.Response( 200, json={ "items": [track], "total": 2, "limit": 50, "offset": offset, "href": f"{API_BASE}/me/top/tracks", "next": f"{API_BASE}/me/top/tracks?offset=50" if offset == 0 else None, }, ) respx_mock.get(f"{API_BASE}/me/top/tracks").mock(side_effect=side_effect) result = client.get_current_user_top_tracks(SecretStr("acc")) assert result == [track1, track2] def test_get_top_tracks_passes_time_range( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/tracks").mock( return_value=httpx.Response( 200, json={ "items": [], "total": 0, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/top/tracks", }, ) ) client.get_current_user_top_tracks(SecretStr("acc"), time_range="short_term") assert respx_mock.calls[0].request.url.params["time_range"] == "short_term" def test_get_top_tracks_raises_api_error_on_429( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/tracks").mock( return_value=httpx.Response(429, headers={"Retry-After": "10"}) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_top_tracks(SecretStr("acc")) assert exc_info.value.status_code == 429 assert exc_info.value.response is not None assert exc_info.value.response.headers["Retry-After"] == "10" def test_get_top_tracks_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/top/tracks").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_top_tracks(SecretStr("acc")) def test_get_recently_played_returns_recently_played( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: item = { "track": { "id": "t1", "name": "Track One", "popularity": 70, "type": "track", "uri": "spotify:track:t1", }, "played_at": "2024-01-01T12:00:00Z", } respx_mock.get(f"{API_BASE}/me/player/recently-played").mock( return_value=httpx.Response( 200, json={ "items": [item], "limit": 50, "href": f"{API_BASE}/me/player/recently-played", }, ) ) result = client.get_current_user_recently_played(SecretStr("acc")) assert result == [item] def test_get_recently_played_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: item1 = { "track": { "id": "t1", "name": "Track One", "popularity": 70, "type": "track", "uri": "spotify:track:t1", }, "played_at": "2024-01-01T12:00:00Z", } item2 = { "track": { "id": "t2", "name": "Track Two", "popularity": 60, "type": "track", "uri": "spotify:track:t2", }, "played_at": "2024-01-02T12:00:00Z", } def side_effect(request: httpx.Request) -> httpx.Response: after = request.url.params.get("after") if after is None: return httpx.Response( 200, json={ "items": [item1], "limit": 50, "href": f"{API_BASE}/me/player/recently-played", "next": f"{API_BASE}/me/player/recently-played?after=1704067200000", "cursors": { "after": "1704067200000", "before": "1704067200001", }, }, ) return httpx.Response( 200, json={ "items": [item2], "limit": 50, "href": f"{API_BASE}/me/player/recently-played", }, ) respx_mock.get(f"{API_BASE}/me/player/recently-played").mock( side_effect=side_effect ) result = client.get_current_user_recently_played(SecretStr("acc")) assert result == [item1, item2] def test_get_recently_played_passes_after_parameter( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/player/recently-played").mock( return_value=httpx.Response( 200, json={ "items": [], "limit": 50, "href": f"{API_BASE}/me/player/recently-played", }, ) ) client.get_current_user_recently_played(SecretStr("acc"), after=1704067200000) assert respx_mock.calls[0].request.url.params["after"] == "1704067200000" def test_get_recently_played_raises_api_error_on_403( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/player/recently-played").mock( return_value=httpx.Response(403) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_recently_played(SecretStr("acc")) assert exc_info.value.status_code == 403 def test_get_recently_played_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/player/recently-played").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_recently_played(SecretStr("acc")) def test_get_current_user_playlists_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: def _playlist(pid: str) -> dict: return { "id": pid, "name": f"Playlist {pid}", "description": None, "public": True, "collaborative": False, "snapshot_id": f"snap_{pid}", "type": "playlist", "uri": f"spotify:playlist:{pid}", "tracks": { "href": f"{API_BASE}/playlists/{pid}/tracks", "total": 5, }, } playlist1 = _playlist("p1") playlist2 = _playlist("p2") respx_mock.get(f"{API_BASE}/me/playlists").mock( return_value=httpx.Response( 200, json={ "items": [playlist1, playlist2], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/playlists", "next": None, }, ) ) result = client.get_current_user_playlists(SecretStr("acc")) assert result == [playlist1, playlist2] def test_get_current_user_playlists_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: def _playlist(pid: str) -> dict: return { "id": pid, "name": f"Playlist {pid}", "description": None, "public": True, "collaborative": False, "snapshot_id": f"snap_{pid}", "type": "playlist", "uri": f"spotify:playlist:{pid}", "tracks": { "href": f"{API_BASE}/playlists/{pid}/tracks", "total": 5, }, } playlist1 = _playlist("p1") playlist2 = _playlist("p2") next_url = f"{API_BASE}/me/playlists?offset=50&limit=50" def side_effect(request: httpx.Request) -> httpx.Response: offset = int(request.url.params.get("offset", 0)) if offset == 0: return httpx.Response( 200, json={ "items": [playlist1], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/playlists", "next": next_url, }, ) return httpx.Response( 200, json={ "items": [playlist2], "total": 2, "limit": 50, "offset": 50, "href": f"{API_BASE}/me/playlists", "next": None, }, ) respx_mock.get(f"{API_BASE}/me/playlists").mock(side_effect=side_effect) result = client.get_current_user_playlists(SecretStr("acc")) assert result == [playlist1, playlist2] def test_get_current_user_playlists_raises_api_error_on_401( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/playlists").mock( return_value=httpx.Response(401) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_playlists(SecretStr("acc")) assert exc_info.value.status_code == 401 def test_get_current_user_playlists_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/playlists").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_playlists(SecretStr("acc")) def test_get_saved_albums_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: def _saved_album(aid: str) -> dict: return { "added_at": "2024-01-01T00:00:00Z", "album": { "id": aid, "name": f"Album {aid}", "album_type": "album", "total_tracks": 10, "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": f"spotify:album:{aid}", "artists": [ { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } ], "label": "Some Label", "popularity": 75, }, } album1 = _saved_album("a1") album2 = _saved_album("a2") respx_mock.get(f"{API_BASE}/me/albums").mock( return_value=httpx.Response( 200, json={ "items": [album1, album2], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/albums", "next": None, }, ) ) result = client.get_current_user_saved_albums(SecretStr("acc")) assert result == [album1, album2] def test_get_saved_albums_tolerates_null_items( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: # Spotify sometimes returns a null item (unavailable album); it must not # fail the whole page — the null is dropped, the rest returned. album = { "added_at": "2024-01-01T00:00:00Z", "album": { "id": "a1", "name": "Album a1", "album_type": "album", "total_tracks": 10, "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": "spotify:album:a1", "artists": [ { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } ], "label": "Some Label", "popularity": 75, }, } respx_mock.get(f"{API_BASE}/me/albums").mock( return_value=httpx.Response( 200, json={ "items": [album, None], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/albums", "next": None, }, ) ) result = client.get_current_user_saved_albums(SecretStr("acc")) assert result == [album] def test_get_saved_albums_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: def _saved_album(aid: str) -> dict: return { "added_at": "2024-01-01T00:00:00Z", "album": { "id": aid, "name": f"Album {aid}", "album_type": "album", "total_tracks": 10, "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": f"spotify:album:{aid}", "artists": [ { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } ], "label": "Some Label", "popularity": 75, }, } album1 = _saved_album("a1") album2 = _saved_album("a2") next_url = f"{API_BASE}/me/albums?offset=50&limit=50" def side_effect(request: httpx.Request) -> httpx.Response: offset = int(request.url.params.get("offset", 0)) album = album1 if offset == 0 else album2 return httpx.Response( 200, json={ "items": [album], "total": 2, "limit": 50, "offset": offset, "href": f"{API_BASE}/me/albums", "next": next_url if offset == 0 else None, }, ) respx_mock.get(f"{API_BASE}/me/albums").mock(side_effect=side_effect) result = client.get_current_user_saved_albums(SecretStr("acc")) assert result == [album1, album2] def test_get_saved_albums_raises_api_error_on_401( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/albums").mock(return_value=httpx.Response(401)) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_saved_albums(SecretStr("acc")) assert exc_info.value.status_code == 401 def test_get_saved_albums_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/albums").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_saved_albums(SecretStr("acc")) def test_get_saved_tracks_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist = { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } album = { "id": "al1", "name": "Some Album", "album_type": "album", "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": "spotify:album:al1", "artists": [artist], } def _saved_track(tid: str, track_number: int) -> dict: return { "added_at": "2024-01-01T00:00:00Z", "track": { "id": tid, "name": f"Track {tid}", "popularity": 70, "type": "track", "uri": f"spotify:track:{tid}", "duration_ms": 210000, "explicit": False, "track_number": track_number, "disc_number": 1, "album": album, "artists": [artist], }, } track1 = _saved_track("t1", 1) track2 = _saved_track("t2", 2) respx_mock.get(f"{API_BASE}/me/tracks").mock( return_value=httpx.Response( 200, json={ "items": [track1, track2], "total": 2, "limit": 50, "offset": 0, "href": f"{API_BASE}/me/tracks", }, ) ) result = client.get_current_user_saved_tracks(SecretStr("acc")) assert result == [track1, track2] def test_get_saved_tracks_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist = { "id": "ar1", "name": "Artist One", "type": "artist", "uri": "spotify:artist:ar1", } album = { "id": "al1", "name": "Some Album", "album_type": "album", "release_date": "2024-01-01", "release_date_precision": "day", "type": "album", "uri": "spotify:album:al1", "artists": [artist], } def _saved_track(tid: str) -> dict: return { "added_at": "2024-01-01T00:00:00Z", "track": { "id": tid, "name": f"Track {tid}", "popularity": 70, "type": "track", "uri": f"spotify:track:{tid}", "duration_ms": 210000, "explicit": False, "track_number": 1, "disc_number": 1, "album": album, "artists": [artist], }, } track1 = _saved_track("t1") track2 = _saved_track("t2") next_url = f"{API_BASE}/me/tracks?offset=50&limit=50" def side_effect(request: httpx.Request) -> httpx.Response: offset = int(request.url.params.get("offset", 0)) track = track1 if offset == 0 else track2 return httpx.Response( 200, json={ "items": [track], "total": 2, "limit": 50, "offset": offset, "href": f"{API_BASE}/me/tracks", "next": next_url if offset == 0 else None, }, ) respx_mock.get(f"{API_BASE}/me/tracks").mock(side_effect=side_effect) result = client.get_current_user_saved_tracks(SecretStr("acc")) assert result == [track1, track2] def test_get_saved_tracks_raises_api_error_on_401( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/tracks").mock(return_value=httpx.Response(401)) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_saved_tracks(SecretStr("acc")) assert exc_info.value.status_code == 401 def test_get_saved_tracks_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/tracks").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_saved_tracks(SecretStr("acc")) def test_get_followed_artists_returns_all_items_on_single_page( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist1 = { "id": "a1", "name": "Artist One", "popularity": 80, "genres": ["pop"], "type": "artist", "uri": "spotify:artist:a1", } artist2 = { "id": "a2", "name": "Artist Two", "popularity": 60, "genres": ["rock"], "type": "artist", "uri": "spotify:artist:a2", } respx_mock.get(f"{API_BASE}/me/following").mock( return_value=httpx.Response( 200, json={ "artists": { "items": [artist1, artist2], "total": 2, "limit": 50, "href": f"{API_BASE}/me/following?type=artist", "next": None, } }, ) ) result = client.get_current_user_followed_artists(SecretStr("acc")) assert result == [artist1, artist2] def test_get_followed_artists_fetches_all_pages( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: artist1 = { "id": "a1", "name": "Artist One", "popularity": 80, "genres": ["pop"], "type": "artist", "uri": "spotify:artist:a1", } artist2 = { "id": "a2", "name": "Artist Two", "popularity": 60, "genres": ["rock"], "type": "artist", "uri": "spotify:artist:a2", } def side_effect(request: httpx.Request) -> httpx.Response: after = request.url.params.get("after") if after is None: return httpx.Response( 200, json={ "artists": { "items": [artist1], "total": 2, "limit": 50, "href": f"{API_BASE}/me/following?type=artist", "next": f"{API_BASE}/me/following?type=artist&after=a1", "cursors": {"after": "a1", "before": ""}, } }, ) return httpx.Response( 200, json={ "artists": { "items": [artist2], "total": 2, "limit": 50, "href": f"{API_BASE}/me/following?type=artist", "next": None, } }, ) respx_mock.get(f"{API_BASE}/me/following").mock(side_effect=side_effect) result = client.get_current_user_followed_artists(SecretStr("acc")) assert result == [artist1, artist2] def test_get_followed_artists_raises_api_error_on_401( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/following").mock( return_value=httpx.Response(401) ) with pytest.raises(HTTPClientError) as exc_info: client.get_current_user_followed_artists(SecretStr("acc")) assert exc_info.value.status_code == 401 def test_get_followed_artists_raises_api_error_on_network_error( self, client: SpotifyClient, respx_mock: respx.MockRouter ) -> None: respx_mock.get(f"{API_BASE}/me/following").mock( side_effect=httpx.ConnectError("timeout") ) with pytest.raises(HTTPClientError): client.get_current_user_followed_artists(SecretStr("acc"))