189 lines
6.4 KiB
Python
189 lines
6.4 KiB
Python
"""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"},
|
|
],
|
|
}
|
|
}
|