discovery-by-llm/backend/tests/test_spotify_client.py
2026-08-10 11:31:06 +02:00

268 lines
8.9 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, SpotifyRequestError
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_quota_exhaustion_raises_without_retry_or_sleep(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def run() -> None:
api_calls = 0
sleep_calls = 0
async def fake_sleep(delay: float) -> None:
nonlocal sleep_calls
sleep_calls += 1
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal api_calls
api_calls += 1
return httpx2.Response(
429,
headers={"Retry-After": "0"},
json={
"error": {
"message": "Quota exhausted",
"reason": "QUOTA_EXCEEDED",
}
},
)
monkeypatch.setattr("app.adapters.spotify.client.asyncio.sleep", fake_sleep)
with pytest.raises(SpotifyRateLimitedError) as error:
await _search_with_handler(handler)
assert error.value.reason == "QUOTA_EXCEEDED"
assert api_calls == 1
assert sleep_calls == 0
asyncio.run(run())
def test_authentication_then_rate_limit_retries_each_policy_once() -> 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 api_calls == 1:
return httpx2.Response(401)
if api_calls == 2:
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 token_calls == 1
assert api_calls == 3
asyncio.run(run())
def test_request_error_includes_parsed_spotify_message() -> None:
async def run() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
400,
json={"error": {"message": "Invalid search request"}},
)
with pytest.raises(SpotifyRequestError) as error:
await _search_with_handler(handler)
assert error.value.message == "Invalid search request"
assert str(error.value) == "Invalid search request"
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"},
],
}
}