/
/
/
1"""Unit tests for the Apple Music API client error handling and pagination."""
2
3from collections.abc import Awaitable, Callable
4from typing import Any
5from unittest.mock import AsyncMock, MagicMock, patch
6
7import pytest
8from aiohttp import ClientPayloadError, ClientResponseError, ServerDisconnectedError
9from aiohttp.client_reqrep import RequestInfo
10from multidict import CIMultiDict, CIMultiDictProxy
11from music_assistant_models.errors import (
12 LoginFailed,
13 ResourceTemporarilyUnavailable,
14 RetriesExhausted,
15)
16from yarl import URL
17
18from music_assistant.providers.apple_music.api_client import (
19 _LIBRARY_PAGE_SIZE,
20 _PAGE_TRUNCATION_RETRIES,
21 AppleMusicAPIClient,
22 _retry_transient_transport_errors,
23)
24
25# Target for patching the backoff/throttle sleep so retries run instantly.
26_SLEEP_TARGET = "music_assistant.helpers.throttle_retry.asyncio.sleep"
27
28
29class _FakeRequestCtx:
30 """Minimal async context manager mimicking aiohttp's request context."""
31
32 def __init__(self, response: MagicMock) -> None:
33 self._response = response
34
35 async def __aenter__(self) -> MagicMock:
36 return self._response
37
38 async def __aexit__(self, *_exc: object) -> bool:
39 return False
40
41
42def _make_response(
43 status: int = 200,
44 json_data: Any = None,
45 json_exc: BaseException | None = None,
46) -> MagicMock:
47 response = MagicMock()
48 response.status = status
49 response.headers = CIMultiDict()
50 response.content_length = 0
51 response.raise_for_status = MagicMock()
52 if json_exc is not None:
53 response.json = AsyncMock(side_effect=json_exc)
54 else:
55 response.json = AsyncMock(return_value=json_data)
56 return response
57
58
59def _make_client() -> tuple[AppleMusicAPIClient, MagicMock]:
60 """Return an API client together with its mock provider (for session stubbing)."""
61 provider = MagicMock()
62 provider.logger = MagicMock()
63 provider._music_app_token = "app-token"
64 provider._music_user_token = "user-token"
65 return AppleMusicAPIClient(provider), provider
66
67
68def _client_response_error(status: int) -> ClientResponseError:
69 request_info = RequestInfo(
70 url=URL("https://api.music.apple.com/v1/me/library/songs"),
71 method="GET",
72 headers=CIMultiDictProxy(CIMultiDict()),
73 real_url=URL("https://api.music.apple.com/v1/me/library/songs"),
74 )
75 return ClientResponseError(
76 request_info=request_info, history=(), status=status, message="error", headers=None
77 )
78
79
80# ---------------------------------------------------------------------------
81# P1: transient transport errors map to the retryable error type
82# ---------------------------------------------------------------------------
83
84
85@pytest.mark.parametrize(
86 "transport_error",
87 [
88 ClientPayloadError("Not enough data to satisfy content length header."),
89 ServerDisconnectedError("Server disconnected"),
90 TimeoutError("Read timed out"),
91 ],
92)
93@pytest.mark.asyncio
94async def test_transient_transport_errors_mapped_to_retryable(
95 transport_error: BaseException,
96) -> None:
97 """Transport-level errors are converted to ResourceTemporarilyUnavailable."""
98
99 async def _raise(_self: object) -> None:
100 raise transport_error
101
102 wrapped = _retry_transient_transport_errors(_raise)
103 with pytest.raises(ResourceTemporarilyUnavailable):
104 await wrapped(object())
105
106
107@pytest.mark.asyncio
108async def test_client_response_error_not_mapped() -> None:
109 """HTTP status errors (raise_for_status) must not be silently retried."""
110
111 async def _raise(_self: object) -> None:
112 raise _client_response_error(403)
113
114 wrapped = _retry_transient_transport_errors(_raise)
115 with pytest.raises(ClientResponseError):
116 await wrapped(object())
117
118
119@pytest.mark.asyncio
120async def test_decorator_passes_through_success() -> None:
121 """The decorator returns the wrapped result unchanged on success."""
122
123 async def _ok(_self: object, value: int) -> int:
124 return value
125
126 wrapped: Callable[[object, int], Awaitable[int]] = _retry_transient_transport_errors(_ok)
127 assert await wrapped(object(), 42) == 42
128
129
130@pytest.mark.asyncio
131async def test_get_data_recovers_after_transient_payload_error() -> None:
132 """A truncated body on the first attempt is retried and then succeeds."""
133 client, provider = _make_client()
134 payload = {"data": [{"id": "1"}]}
135 provider.mass.http_session.get = MagicMock(
136 side_effect=[
137 _FakeRequestCtx(_make_response(status=200, json_exc=ClientPayloadError("truncated"))),
138 _FakeRequestCtx(_make_response(status=200, json_data=payload)),
139 ]
140 )
141 with patch(_SLEEP_TARGET, new=AsyncMock()):
142 result = await client.get_data("me/library/songs", limit=50, offset=0)
143 assert result == payload
144 assert provider.mass.http_session.get.call_count == 2
145
146
147# ---------------------------------------------------------------------------
148# P2: HTTP 500 is retryable
149# ---------------------------------------------------------------------------
150
151
152@pytest.mark.asyncio
153async def test_get_data_500_recovers_on_retry() -> None:
154 """A transient 500 is retried and then succeeds."""
155 client, provider = _make_client()
156 payload = {"data": [{"id": "1"}]}
157 provider.mass.http_session.get = MagicMock(
158 side_effect=[
159 _FakeRequestCtx(_make_response(status=500)),
160 _FakeRequestCtx(_make_response(status=200, json_data=payload)),
161 ]
162 )
163 with patch(_SLEEP_TARGET, new=AsyncMock()):
164 result = await client.get_data("me/library/songs", limit=50, offset=0)
165 assert result == payload
166
167
168@pytest.mark.asyncio
169async def test_get_data_persistent_500_exhausts_retries() -> None:
170 """A persistent 500 retries retry_attempts times then raises RetriesExhausted."""
171 client, provider = _make_client()
172 provider.mass.http_session.get = MagicMock(
173 return_value=_FakeRequestCtx(_make_response(status=500))
174 )
175 with patch(_SLEEP_TARGET, new=AsyncMock()), pytest.raises(RetriesExhausted):
176 await client.get_data("me/library/songs", limit=50, offset=0)
177 assert provider.mass.http_session.get.call_count == client.throttler.retry_attempts
178
179
180# ---------------------------------------------------------------------------
181# P3: pagination distinguishes a clean/empty end from a mid-list truncation
182# ---------------------------------------------------------------------------
183
184
185@pytest.mark.asyncio
186async def test_get_all_items_assembles_pages() -> None:
187 """Pages are concatenated and pagination stops when no `next` is returned."""
188 client, _ = _make_client()
189 client.get_data = AsyncMock( # type: ignore[method-assign]
190 side_effect=[
191 {"data": [{"id": "a"}, {"id": "b"}], "next": "/v1/me/library/songs?offset=50"},
192 {"data": [{"id": "c"}]},
193 ]
194 )
195 items = await client.get_all_items("me/library/songs")
196 assert [item["id"] for item in items] == ["a", "b", "c"]
197 assert client.get_data.call_args_list[0].kwargs["offset"] == 0
198 assert client.get_data.call_args_list[1].kwargs["offset"] == _LIBRARY_PAGE_SIZE
199
200
201@pytest.mark.asyncio
202async def test_iter_all_items_fetches_pages_lazily() -> None:
203 """iter_all_items yields incrementally and only fetches the next page when needed."""
204 client, _ = _make_client()
205 client.get_data = AsyncMock( # type: ignore[method-assign]
206 side_effect=[
207 {"data": [{"id": "a"}, {"id": "b"}], "next": "/v1/me/library/songs?offset=50"},
208 {"data": [{"id": "c"}]},
209 ]
210 )
211 gen = client.iter_all_items("me/library/songs")
212 first = await anext(gen)
213 assert first["id"] == "a"
214 # The second page must not have been fetched just to yield the first item.
215 assert client.get_data.call_count == 1
216 rest = [item async for item in gen]
217 assert [item["id"] for item in rest] == ["b", "c"]
218 assert client.get_data.call_count == 2
219
220
221@pytest.mark.asyncio
222async def test_get_all_items_empty_first_page_returns_empty() -> None:
223 """An empty collection (empty data, no `next`) yields an empty list."""
224 client, _ = _make_client()
225 client.get_data = AsyncMock(return_value={"data": []}) # type: ignore[method-assign]
226 assert await client.get_all_items("me/library/songs") == []
227
228
229@pytest.mark.asyncio
230async def test_get_all_items_first_page_404_returns_empty() -> None:
231 """A 404 on the very first page (no prior page) is treated as empty, not an error."""
232 client, _ = _make_client()
233 client.get_data = AsyncMock(return_value={}) # type: ignore[method-assign]
234 assert await client.get_all_items("me/library/songs") == []
235
236
237@pytest.mark.asyncio
238async def test_get_all_items_midlist_truncation_recovers() -> None:
239 """A transient mid-list 404 is retried in place and the listing completes."""
240 client, _ = _make_client()
241 client.get_data = AsyncMock( # type: ignore[method-assign]
242 side_effect=[
243 {"data": [{"id": "a"}], "next": "/v1/me/library/songs?offset=50"},
244 {}, # transient truncation on the second page
245 {"data": [{"id": "b"}]}, # in-place retry succeeds
246 ]
247 )
248 items = await client.get_all_items("me/library/songs")
249 assert [item["id"] for item in items] == ["a", "b"]
250
251
252@pytest.mark.asyncio
253async def test_get_all_items_persistent_truncation_raises() -> None:
254 """A 404 that persists across in-place retries surfaces as a loud failure."""
255 client, _ = _make_client()
256 client.get_data = AsyncMock( # type: ignore[method-assign]
257 side_effect=[
258 {"data": [{"id": "a"}], "next": "/v1/me/library/songs?offset=50"},
259 *([{}] * (_PAGE_TRUNCATION_RETRIES + 1)),
260 ]
261 )
262 with pytest.raises(ResourceTemporarilyUnavailable):
263 await client.get_all_items("me/library/songs")
264
265
266# ---------------------------------------------------------------------------
267# P4: a rejected music user token surfaces as an auth error
268# ---------------------------------------------------------------------------
269
270
271@pytest.mark.parametrize("status", [401, 403])
272@pytest.mark.asyncio
273async def test_get_data_auth_error_raises_login_failed(status: int) -> None:
274 """A revoked/expired user token surfaces as LoginFailed, not a bare HTTP error."""
275 client, provider = _make_client()
276 provider.mass.http_session.get = MagicMock(
277 return_value=_FakeRequestCtx(_make_response(status=status))
278 )
279 with patch(_SLEEP_TARGET, new=AsyncMock()), pytest.raises(LoginFailed):
280 await client.get_data("me/storefront")
281 # the token will not become valid on its own, so this must not be retried
282 assert provider.mass.http_session.get.call_count == 1
283
284
285@pytest.mark.parametrize("status", [401, 403])
286@pytest.mark.asyncio
287async def test_write_requests_auth_error_raises_login_failed(status: int) -> None:
288 """Library mutations report a rejected user token as LoginFailed too."""
289 client, provider = _make_client()
290 for method in ("put", "post", "delete"):
291 setattr(
292 provider.mass.http_session,
293 method,
294 MagicMock(return_value=_FakeRequestCtx(_make_response(status=status))),
295 )
296 with patch(_SLEEP_TARGET, new=AsyncMock()):
297 with pytest.raises(LoginFailed):
298 await client.put_data("me/ratings/library-playlists/p.1")
299 with pytest.raises(LoginFailed):
300 await client.post_data("me/library")
301 with pytest.raises(LoginFailed):
302 await client.delete_data("me/library/playlists/p.1")
303
304
305# ---------------------------------------------------------------------------
306# P5: a momentary 429 recovers quickly instead of stalling playback
307# ---------------------------------------------------------------------------
308
309
310@pytest.mark.asyncio
311async def test_get_data_429_retries_within_a_second() -> None:
312 """A 429 is retried after ~1s, so a throttled boundary fetch does not stall playback."""
313 client, provider = _make_client()
314 payload = {"data": [{"id": "1"}]}
315 provider.mass.http_session.get = MagicMock(
316 side_effect=[
317 _FakeRequestCtx(_make_response(status=429)),
318 _FakeRequestCtx(_make_response(status=200, json_data=payload)),
319 ]
320 )
321 sleep_mock = AsyncMock()
322 with patch(_SLEEP_TARGET, new=sleep_mock):
323 result = await client.get_data("me/library/songs", limit=50, offset=0)
324 assert result == payload
325 sleeps = [call.args[0] for call in sleep_mock.await_args_list]
326 assert sleeps, "expected the 429 to be retried after a backoff"
327 # jitter adds at most 10% on top of the initial backoff
328 assert max(sleeps) <= 1.1
329
330
331@pytest.mark.asyncio
332async def test_get_data_sustained_429_is_ridden_out_for_minutes() -> None:
333 """Throttling that outlasts the first retries is waited out, not failed within seconds."""
334 client, provider = _make_client()
335 provider.mass.http_session.get = MagicMock(
336 return_value=_FakeRequestCtx(_make_response(status=429))
337 )
338 sleep_mock = AsyncMock()
339 with patch(_SLEEP_TARGET, new=sleep_mock), pytest.raises(RetriesExhausted):
340 await client.get_data("me/library/songs", limit=50, offset=0)
341 sleeps = [call.args[0] for call in sleep_mock.await_args_list]
342 assert sum(sleeps) > 100
343