"""Transport-level tests for the Spotify API client.""" import asyncio import time from collections.abc import Callable, Coroutine import httpx2 import pytest from app.adapters.spotify.auth import TokenSet from app.adapters.spotify.client import SpotifyClient from app.adapters.spotify.errors import SpotifyRateLimitedError from app.adapters.spotify.session import SpotifySession from app.config import Settings from app.domain.models import Track TransportHandler = Callable[[httpx2.Request], Coroutine[None, None, httpx2.Response]] def test_unauthorized_response_refreshes_once_and_returns_result() -> None: async def run() -> None: token_calls = 0 api_calls = 0 async def handler(request: httpx2.Request) -> httpx2.Response: nonlocal api_calls, token_calls if request.url.host == "accounts.spotify.com": token_calls += 1 return _token_response() api_calls += 1 if request.headers["Authorization"] == "Bearer old-access": return httpx2.Response(401) return httpx2.Response(200, json=_search_payload()) tracks = await _search_with_handler(handler) assert [track.id for track in tracks] == ["track-1"] assert token_calls == 1 assert api_calls == 2 asyncio.run(run()) def test_concurrent_unauthorized_responses_share_one_refresh() -> None: async def run() -> None: token_calls = 0 old_api_calls = 0 both_old_requests_arrived = asyncio.Event() async def handler(request: httpx2.Request) -> httpx2.Response: nonlocal old_api_calls, token_calls if request.url.host == "accounts.spotify.com": token_calls += 1 return _token_response() if request.headers["Authorization"] == "Bearer old-access": old_api_calls += 1 if old_api_calls == 2: both_old_requests_arrived.set() await asyncio.wait_for(both_old_requests_arrived.wait(), timeout=1) return httpx2.Response(401) return httpx2.Response(200, json=_search_payload()) async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: client = _client(http) first, second = await asyncio.gather( client.search_tracks("first"), client.search_tracks("second"), ) assert first == second assert token_calls == 1 asyncio.run(run()) def test_get_rate_limit_with_small_delay_retries_once() -> None: async def run() -> None: api_calls = 0 async def handler(request: httpx2.Request) -> httpx2.Response: nonlocal api_calls api_calls += 1 if api_calls == 1: return httpx2.Response(429, headers={"Retry-After": "0"}) return httpx2.Response(200, json=_search_payload()) tracks = await _search_with_handler(handler) assert len(tracks) == 1 assert api_calls == 2 asyncio.run(run()) def test_get_rate_limit_above_cap_raises_without_retry() -> None: async def run() -> None: api_calls = 0 async def handler(request: httpx2.Request) -> httpx2.Response: nonlocal api_calls api_calls += 1 return httpx2.Response(429, headers={"Retry-After": "6"}) with pytest.raises(SpotifyRateLimitedError) as error: await _search_with_handler(handler) assert error.value.retry_after_seconds == 6 assert api_calls == 1 asyncio.run(run()) def test_playlist_write_rate_limit_is_not_retried() -> None: async def run() -> None: api_calls = 0 async def handler(request: httpx2.Request) -> httpx2.Response: nonlocal api_calls api_calls += 1 assert request.method == "POST" assert request.url.path == "/v1/playlists/playlist-1/items" return httpx2.Response(429, headers={"Retry-After": "0"}) async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: with pytest.raises(SpotifyRateLimitedError): await _client(http).add_tracks_to_playlist("playlist-1", ["spotify:track:1"]) assert api_calls == 1 asyncio.run(run()) def test_search_maps_valid_fields_and_drops_malformed_item() -> None: async def run() -> None: async def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=_search_payload()) tracks = await _search_with_handler(handler) assert len(tracks) == 1 assert tracks[0].id == "track-1" assert tracks[0].uri == "spotify:track:1" assert tracks[0].title == "Mapped song" assert tracks[0].artists == ("First artist", "Second artist") assert tracks[0].album_name == "Mapped album" assert tracks[0].album_art_url == "https://images.example/cover.jpg" assert tracks[0].external_url == "https://open.spotify.com/track/track-1" asyncio.run(run()) async def _search_with_handler(handler: TransportHandler) -> list[Track]: async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http: return list(await _client(http).search_tracks("mapped")) def _client(http: httpx2.AsyncClient) -> SpotifyClient: session = SpotifySession( tokens=TokenSet("old-access", "refresh", time.monotonic() + 3600), account_id="account", display_name="Listener", ) return SpotifyClient(http, session, Settings(spotify_client_id="client")) def _token_response() -> httpx2.Response: return httpx2.Response(200, json={"access_token": "new-access", "expires_in": 3600}) def _search_payload() -> dict[str, object]: return { "tracks": { "total": 0, "items": [ { "id": "track-1", "uri": "spotify:track:1", "name": "Mapped song", "artists": [{"name": "First artist"}, {"name": "Second artist"}], "album": { "name": "Mapped album", "images": [{"url": "https://images.example/cover.jpg"}], }, "external_urls": {"spotify": "https://open.spotify.com/track/track-1"}, }, {"id": "missing-required-fields"}, ], } }