spotify-mcp-server / tests /test_client.py
LeoWalker's picture
Treat non-JSON success bodies as no content (#8)
0645c76 unverified
Raw
History Blame Contribute Delete
9.13 kB
from __future__ import annotations
import httpx2
import pytest
import spotify_mcp_server.spotify.client as client_module
from spotify_mcp_server.spotify.client import (
SpotifyAPIError,
SpotifyClient,
_retry_after,
sanitize_spotify_payload,
)
pytestmark = pytest.mark.anyio
class Tokens:
def __init__(self) -> None:
self.calls: list[bool] = []
async def access_token(self, *, force_refresh: bool = False) -> str:
self.calls.append(force_refresh)
return "refreshed" if force_refresh else "initial"
def test_sanitize_preserves_shapes_and_removes_deprecated_fields() -> None:
payload = {
"type": "playlist",
"tracks": {"items": []},
"items": [
{
"type": "track",
"name": "Song",
"available_markets": ["US"],
"external_ids": {"isrc": "x"},
"album": {"type": "album", "label": "label", "name": "Album"},
}
],
}
assert sanitize_spotify_payload(payload) == {
"type": "playlist",
"items": [
{
"type": "track",
"name": "Song",
"external_ids": {"isrc": "x"},
"album": {"type": "album", "name": "Album"},
}
],
}
assert sanitize_spotify_payload({"type": "user", "followers": {"total": 3}}) == {
"type": "user",
"followers": {"total": 3},
}
async def test_client_blocks_deprecated_or_unapproved_operation() -> None:
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(204))
) as http:
client = SpotifyClient(Tokens(), client=http)
with pytest.raises(ValueError, match="not in the audited allowlist"):
await client.request("PUT", "/me/tracks", params={"ids": "legacy"})
async def test_request_refresh_retry_and_no_content() -> None:
seen = 0
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal seen
seen += 1
if seen == 1:
return httpx2.Response(401, json={"error": {"message": "expired"}})
assert request.headers["Authorization"] == "Bearer refreshed"
return httpx2.Response(204)
tokens = Tokens()
transport = httpx2.MockTransport(handler)
async with httpx2.AsyncClient(transport=transport) as http:
client = SpotifyClient(tokens, client=http, max_retries=0)
assert await client.request("PUT", "/me/player/pause") is None
assert tokens.calls == [False, True]
async def test_request_treats_non_json_success_body_as_no_content() -> None:
"""Shuffle and repeat answer 200 with a short non-JSON body instead of 204."""
async def handler(_: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, content=b"OK")
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http, max_retries=0)
assert await client.request("PUT", "/me/player/shuffle", params={"state": True}) is None
assert await client.request("PUT", "/me/player/repeat", params={"state": "off"}) is None
async def test_request_retries_rate_limit_then_returns_payload() -> None:
sleeps: list[float] = []
seen = 0
async def handler(_: httpx2.Request) -> httpx2.Response:
nonlocal seen
seen += 1
if seen == 1:
return httpx2.Response(429, headers={"Retry-After": "0"}, json={"error": {}})
return httpx2.Response(200, json={"type": "track", "name": "ok", "popularity": 100})
async def sleep(value: float) -> None:
sleeps.append(value)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http, max_retries=1, sleep=sleep)
assert await client.request("GET", "/tracks/x") == {"type": "track", "name": "ok"}
assert sleeps == [0.0]
async def test_request_raises_structured_error() -> None:
async def handler(_: httpx2.Request) -> httpx2.Response:
return httpx2.Response(
429,
headers={"Retry-After": "nonsense"},
json={"error": {"message": "slow", "reason": "QUOTA_EXCEEDED"}},
)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http, max_retries=0)
with pytest.raises(SpotifyAPIError) as raised:
await client.request("GET", "/search")
assert raised.value.reason == "QUOTA_EXCEEDED"
assert raised.value.retry_after_seconds is None
def test_retry_after_parses_http_date(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(client_module.time, "time", lambda: 1_000.0)
response = httpx2.Response(429, headers={"Retry-After": "Thu, 01 Jan 1970 00:17:10 GMT"})
assert _retry_after(response) == 30.0
async def test_request_retries_network_error_then_structures_failure() -> None:
attempts = 0
sleeps: list[float] = []
async def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
raise httpx2.ConnectError("offline", request=request)
async def sleep(value: float) -> None:
sleeps.append(value)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http, max_retries=1, sleep=sleep)
with pytest.raises(SpotifyAPIError, match="network request failed") as raised:
await client.request("GET", "/tracks/x")
assert raised.value.status_code == 0
assert attempts == 2
assert sleeps == [0.5]
async def test_paged_combines_items_and_honors_budget() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
offset = request.url.params.get("offset", "0")
if offset == "0":
return httpx2.Response(
200,
json={
"items": [{"id": "one"}],
"next": "https://api.spotify.com/v1/me/tracks?offset=1",
"total": 3,
},
)
return httpx2.Response(
200,
json={
"items": [{"id": "two"}],
"next": "https://api.spotify.com/v1/me/tracks?offset=2",
"total": 3,
},
)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http)
page = await client.paged("/me/tracks", params={"offset": 0}, max_pages=2)
assert [item["id"] for item in page["items"]] == ["one", "two"]
assert page["pages_fetched"] == 2
assert page["next"].endswith("offset=2")
async def test_paged_combines_nested_search_containers() -> None:
async def handler(request: httpx2.Request) -> httpx2.Response:
offset = request.url.params.get("offset", "0")
return httpx2.Response(
200,
json={
"albums": {
"items": [{"id": offset}],
"next": (
"https://api.spotify.com/v1/search?q=x&type=album&offset=1"
if offset == "0"
else None
),
}
},
)
async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as http:
client = SpotifyClient(Tokens(), client=http)
page = await client.paged(
"/search",
params={"q": "x", "type": "album", "offset": 0},
max_pages=2,
container_key="albums",
)
assert [item["id"] for item in page["items"]] == ["0", "1"]
assert page["pages_fetched"] == 2
assert page["next"] is None
async def test_paged_normalizes_missing_initial_container() -> None:
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: httpx2.Response(200, json={}))
) as http:
client = SpotifyClient(Tokens(), client=http)
with pytest.raises(SpotifyAPIError, match=r"albums.*paging object"):
await client.paged(
"/search", params={"q": "x", "type": "album"}, max_pages=1, container_key="albums"
)
async def test_paged_normalizes_missing_next_container() -> None:
responses = [
httpx2.Response(
200,
json={
"albums": {
"items": [{"id": "one"}],
"next": "https://api.spotify.com/v1/search?offset=1",
}
},
),
httpx2.Response(200, json={}),
]
async with httpx2.AsyncClient(
transport=httpx2.MockTransport(lambda _: responses.pop(0))
) as http:
client = SpotifyClient(Tokens(), client=http)
with pytest.raises(SpotifyAPIError, match=r"albums.*paging object"):
await client.paged(
"/search", params={"q": "x", "type": "album"}, max_pages=2, container_key="albums"
)