feat: add Spotify client and auth routes
This commit is contained in:
parent
cdab1b4dd5
commit
6769833f7e
11 changed files with 934 additions and 9 deletions
94
backend/tests/test_auth_routes.py
Normal file
94
backend/tests/test_auth_routes.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""End-to-end route tests for the Spotify login surface."""
|
||||
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx2
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import AppMode, Settings
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def test_login_callback_cookie_and_current_user_flow() -> None:
|
||||
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
|
||||
if request.url.host == "accounts.spotify.com":
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": "access",
|
||||
"refresh_token": "refresh",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
)
|
||||
assert request.url == "https://api.spotify.com/v1/me"
|
||||
return httpx2.Response(
|
||||
200,
|
||||
json={"account_id": "stable-account", "display_name": "Ada Listener"},
|
||||
)
|
||||
|
||||
app = create_app(
|
||||
application_settings=_live_settings(),
|
||||
http_transport=httpx2.MockTransport(spotify_handler),
|
||||
)
|
||||
with TestClient(app, follow_redirects=False) as client:
|
||||
unauthenticated_response = client.get("/api/auth/me")
|
||||
login_response = client.get("/api/auth/login")
|
||||
|
||||
authorize_url = urlparse(login_response.headers["location"])
|
||||
authorize_query = parse_qs(authorize_url.query)
|
||||
state = authorize_query["state"][0]
|
||||
|
||||
assert unauthenticated_response.status_code == 401
|
||||
assert login_response.status_code == 307
|
||||
assert authorize_url.netloc == "accounts.spotify.com"
|
||||
assert authorize_url.path == "/authorize"
|
||||
assert authorize_query["code_challenge"][0]
|
||||
assert authorize_query["code_challenge_method"] == ["S256"]
|
||||
|
||||
callback_response = client.get("/callback", params={"code": "code", "state": state})
|
||||
|
||||
assert callback_response.status_code == 307
|
||||
assert callback_response.headers["location"] == "/"
|
||||
assert "discovery_session=" in callback_response.headers["set-cookie"]
|
||||
assert "HttpOnly" in callback_response.headers["set-cookie"]
|
||||
assert "SameSite=lax" in callback_response.headers["set-cookie"]
|
||||
assert client.get("/api/auth/me").json() == {"display_name": "Ada Listener"}
|
||||
|
||||
|
||||
def test_unknown_callback_state_redirects_to_login_error() -> None:
|
||||
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
|
||||
raise AssertionError("Spotify must not be called for an unknown state")
|
||||
|
||||
app = create_app(
|
||||
application_settings=_live_settings(),
|
||||
http_transport=httpx2.MockTransport(spotify_handler),
|
||||
)
|
||||
with TestClient(app, follow_redirects=False) as client:
|
||||
response = client.get("/callback", params={"code": "code", "state": "unknown"})
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/?login=error"
|
||||
|
||||
|
||||
def test_spotify_failure_during_callback_redirects_to_login_error() -> None:
|
||||
async def spotify_handler(request: httpx2.Request) -> httpx2.Response:
|
||||
return httpx2.Response(400)
|
||||
|
||||
app = create_app(
|
||||
application_settings=_live_settings(),
|
||||
http_transport=httpx2.MockTransport(spotify_handler),
|
||||
)
|
||||
with TestClient(app, follow_redirects=False) as client:
|
||||
login_response = client.get("/api/auth/login")
|
||||
authorize_query = parse_qs(urlparse(login_response.headers["location"]).query)
|
||||
response = client.get(
|
||||
"/callback",
|
||||
params={"code": "code", "state": authorize_query["state"][0]},
|
||||
)
|
||||
|
||||
assert response.status_code == 307
|
||||
assert response.headers["location"] == "/?login=error"
|
||||
|
||||
|
||||
def _live_settings() -> Settings:
|
||||
return Settings(app_mode=AppMode.LIVE, spotify_client_id="client-id")
|
||||
268
backend/tests/test_spotify_client.py
Normal file
268
backend/tests/test_spotify_client.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""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"},
|
||||
],
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue