/
/
/
1"""Test Tidal API Client."""
2
3from unittest.mock import AsyncMock, MagicMock, Mock
4
5import pytest
6from aiohttp import ClientResponse
7from music_assistant_models.errors import (
8 LoginFailed,
9 MediaNotFoundError,
10 ResourceTemporarilyUnavailable,
11 RetriesExhausted,
12)
13
14from music_assistant.providers.tidal.api_client import MAX_PAGINATION_PAGES, TidalAPIClient
15from music_assistant.providers.tidal.constants import OPEN_API_URL
16
17
18@pytest.fixture
19def api_client(provider_mock: Mock) -> TidalAPIClient:
20 """Return a TidalAPIClient instance."""
21 return TidalAPIClient(provider_mock)
22
23
24async def test_get_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
25 """Test successful GET request."""
26 response = AsyncMock(spec=ClientResponse)
27 response.status = 200
28 response.json.return_value = {"data": "test"}
29
30 # Create a mock that acts as an async context manager
31 request_ctx = AsyncMock()
32 request_ctx.__aenter__.return_value = response
33
34 # The request method itself should be a MagicMock (not AsyncMock)
35 # that returns the context manager
36 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
37
38 result = await api_client.get("test/endpoint")
39 assert result == {"data": "test"}
40
41
42async def test_get_jsonapi_raises_on_missing_data(
43 api_client: TidalAPIClient, provider_mock: Mock
44) -> None:
45 """
46 Test get_jsonapi raises when the response is not a valid JSON:API document.
47
48 An empty body is surfaced by the response handler as {"success": True}; such a
49 response (or any error body) has no top-level "data" and must raise rather than
50 become a silently empty result.
51 """
52 response = AsyncMock(spec=ClientResponse)
53 response.status = 200
54 response.content_length = 0
55 response.json.return_value = {}
56 ctx = AsyncMock()
57 ctx.__aenter__.return_value = response
58 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
59
60 with pytest.raises(ResourceTemporarilyUnavailable):
61 await api_client.get_jsonapi("searchResults/foo")
62
63
64async def test_get_jsonapi_returns_document_on_valid_response(
65 api_client: TidalAPIClient, provider_mock: Mock
66) -> None:
67 """Test get_jsonapi returns a document when the response carries a data member."""
68 response = AsyncMock(spec=ClientResponse)
69 response.status = 200
70 response.json.return_value = {"data": {"id": "1", "type": "tracks"}}
71 ctx = AsyncMock()
72 ctx.__aenter__.return_value = response
73 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
74
75 doc = await api_client.get_jsonapi("tracks/1")
76 assert doc.data == {"id": "1", "type": "tracks"}
77
78
79async def test_get_jsonapi_sends_replace_media(
80 api_client: TidalAPIClient, provider_mock: Mock
81) -> None:
82 """Test replace_media reaches Tidal as the replaceMedia query parameter."""
83 response = AsyncMock(spec=ClientResponse)
84 response.status = 200
85 response.json.return_value = {"data": []}
86 ctx = AsyncMock()
87 ctx.__aenter__.return_value = response
88 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
89
90 await api_client.get_jsonapi("albums/1/relationships/items", replace_media="items")
91
92 params = provider_mock.mass.http_session.request.call_args[1]["params"]
93 assert params["replaceMedia"] == "items"
94
95
96async def test_session_id_scoped_to_unofficial_api(
97 api_client: TidalAPIClient, provider_mock: Mock
98) -> None:
99 """Test sessionId is sent to the unofficial API but not the official one."""
100 response = AsyncMock(spec=ClientResponse)
101 response.status = 200
102 response.json.return_value = {}
103 ctx = AsyncMock()
104 ctx.__aenter__.return_value = response
105 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
106
107 await api_client.get("test/endpoint")
108 assert "sessionId" in provider_mock.mass.http_session.request.call_args[1]["params"]
109
110 await api_client.get("tracks", base_url=OPEN_API_URL)
111 assert "sessionId" not in provider_mock.mass.http_session.request.call_args[1]["params"]
112 assert "countryCode" in provider_mock.mass.http_session.request.call_args[1]["params"]
113
114
115async def test_get_401_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
116 """Test GET request with 401 error and a failing token refresh."""
117 response = AsyncMock(spec=ClientResponse)
118 response.status = 401
119
120 request_ctx = AsyncMock()
121 request_ctx.__aenter__.return_value = response
122 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
123 provider_mock.auth.refresh_token.return_value = False
124
125 with pytest.raises(LoginFailed):
126 await api_client.get("test/endpoint")
127
128 provider_mock.auth.refresh_token.assert_called_once()
129
130
131async def test_get_with_etag_returns_header(
132 api_client: TidalAPIClient, provider_mock: Mock
133) -> None:
134 """Test get_with_etag returns the body together with the response ETag header."""
135 response = AsyncMock(spec=ClientResponse)
136 response.status = 200
137 response.json.return_value = {"numberOfTracks": 3}
138 response.headers = {"ETag": "etag-abc"}
139 ctx = AsyncMock()
140 ctx.__aenter__.return_value = response
141 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
142
143 data, etag = await api_client.get_with_etag("playlists/1")
144
145 assert data == {"numberOfTracks": 3}
146 assert etag == "etag-abc"
147
148
149async def test_get_401_after_refresh_still_401(
150 api_client: TidalAPIClient, provider_mock: Mock
151) -> None:
152 """Test a 401 on the post-refresh retry raises LoginFailed."""
153 response_401 = AsyncMock(spec=ClientResponse)
154 response_401.status = 401
155 ctx = AsyncMock()
156 ctx.__aenter__.return_value = response_401
157 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
158 provider_mock.auth.refresh_token.return_value = True
159
160 with pytest.raises(LoginFailed):
161 await api_client.get("test/endpoint")
162
163 provider_mock.auth.refresh_token.assert_called_once()
164 assert provider_mock.mass.http_session.request.call_count == 2
165
166
167async def test_get_401_refreshes_token_and_retries(
168 api_client: TidalAPIClient, provider_mock: Mock
169) -> None:
170 """Test that a 401 response forces a token refresh and retries the request once."""
171 response_401 = AsyncMock(spec=ClientResponse)
172 response_401.status = 401
173
174 response_ok = AsyncMock(spec=ClientResponse)
175 response_ok.status = 200
176 response_ok.json.return_value = {"data": "test"}
177
178 ctx1 = AsyncMock()
179 ctx1.__aenter__.return_value = response_401
180 ctx2 = AsyncMock()
181 ctx2.__aenter__.return_value = response_ok
182 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2])
183 provider_mock.auth.refresh_token.return_value = True
184
185 result = await api_client.get("test/endpoint")
186
187 assert result == {"data": "test"}
188 provider_mock.auth.refresh_token.assert_called_once()
189 assert provider_mock.mass.http_session.request.call_count == 2
190
191
192async def test_get_404_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
193 """Test GET request with 404 error."""
194 response = AsyncMock(spec=ClientResponse)
195 response.status = 404
196 response.url = "http://test/endpoint"
197
198 request_ctx = AsyncMock()
199 request_ctx.__aenter__.return_value = response
200 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
201
202 with pytest.raises(MediaNotFoundError):
203 await api_client.get("test/endpoint")
204
205
206async def test_get_429_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
207 """Test GET request with 429 error."""
208 response = AsyncMock(spec=ClientResponse)
209 response.status = 429
210 response.headers = {"Retry-After": "10"}
211
212 request_ctx = AsyncMock()
213 request_ctx.__aenter__.return_value = response
214 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
215
216 with pytest.raises(RetriesExhausted):
217 await api_client.get("test/endpoint")
218
219
220async def test_write_jsonapi(api_client: TidalAPIClient, provider_mock: Mock) -> None:
221 """Test write_jsonapi sends the JSON:API content type and serialized body."""
222 response = AsyncMock(spec=ClientResponse)
223 response.status = 204
224 ctx = AsyncMock()
225 ctx.__aenter__.return_value = response
226 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
227
228 await api_client.write_jsonapi(
229 "POST",
230 "userCollectionTracks/me/relationships/items",
231 {"data": [{"type": "tracks", "id": "1"}]},
232 )
233
234 call = provider_mock.mass.http_session.request.call_args
235 assert call[0][0] == "POST"
236 assert call[1]["headers"]["Content-Type"] == "application/vnd.api+json"
237 # the body is sent as a serialized JSON string, not aiohttp's json= kwarg
238 assert '"type": "tracks"' in call[1]["data"]
239 # a per-request Idempotency-Key is sent so a throttler-driven retry dedups server-side
240 assert call[1]["headers"].get("Idempotency-Key")
241
242
243async def test_paginate_jsonapi_follows_cursor(
244 api_client: TidalAPIClient, provider_mock: Mock
245) -> None:
246 """Test paginate_jsonapi follows links.next and stops when the cursor runs out."""
247 page1 = AsyncMock(spec=ClientResponse)
248 page1.status = 200
249 page1.json.return_value = {
250 "data": [{"type": "tracks", "id": "1"}],
251 "links": {"next": "/x?countryCode=AT&page[cursor]=NEXT%3D123&other=1"},
252 }
253 page2 = AsyncMock(spec=ClientResponse)
254 page2.status = 200
255 page2.json.return_value = {"data": [{"type": "tracks", "id": "2"}]}
256
257 ctx1 = AsyncMock()
258 ctx1.__aenter__.return_value = page1
259 ctx2 = AsyncMock()
260 ctx2.__aenter__.return_value = page2
261 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2])
262
263 docs = [doc async for doc in api_client.paginate_jsonapi("tracks")]
264
265 assert len(docs) == 2
266 assert [d.data_list[0]["id"] for d in docs] == ["1", "2"]
267 assert provider_mock.mass.http_session.request.call_count == 2
268 # the second request carried the (url-decoded) cursor from page 1's next link
269 second_params = provider_mock.mass.http_session.request.call_args_list[1][1]["params"]
270 assert second_params["page[cursor]"] == "NEXT=123"
271
272
273async def test_paginate_jsonapi_caps_pages(api_client: TidalAPIClient, provider_mock: Mock) -> None:
274 """Test paginate_jsonapi stops at max_pages and warns when more pages remain."""
275 # every page advertises a fresh next cursor, so the cap is what stops iteration
276 pages = iter(f"C{n}" for n in range(10))
277
278 def _next_ctx(*_a: object, **_k: object) -> AsyncMock:
279 response = AsyncMock(spec=ClientResponse)
280 response.status = 200
281 response.json.return_value = {
282 "data": [],
283 "links": {"next": f"/x?page[cursor]={next(pages)}"},
284 }
285 ctx = AsyncMock()
286 ctx.__aenter__.return_value = response
287 return ctx
288
289 provider_mock.mass.http_session.request = MagicMock(side_effect=_next_ctx)
290
291 docs = [doc async for doc in api_client.paginate_jsonapi("x", max_pages=2)]
292
293 assert len(docs) == 2
294 provider_mock.logger.warning.assert_called_once()
295
296
297async def test_paginate_jsonapi_stops_on_repeated_cursor(
298 api_client: TidalAPIClient, provider_mock: Mock
299) -> None:
300 """Test a server re-serving the same next cursor stops the walk instead of spinning."""
301 response = AsyncMock(spec=ClientResponse)
302 response.status = 200
303 # every page advertises the SAME next cursor
304 response.json.return_value = {"data": [], "links": {"next": "/x?page[cursor]=LOOP"}}
305 ctx = AsyncMock()
306 ctx.__aenter__.return_value = response
307 provider_mock.mass.http_session.request = MagicMock(return_value=ctx)
308
309 docs = [doc async for doc in api_client.paginate_jsonapi("x")]
310
311 # page 1 yields the cursor, page 2 repeats it: two pages, then a warning, no spin
312 assert len(docs) == 2
313 assert provider_mock.mass.http_session.request.call_count == 2
314 provider_mock.logger.warning.assert_called_once()
315
316
317def test_pagination_ceiling_covers_large_libraries() -> None:
318 """Test the default page cap is high enough to walk a full library without truncating."""
319 # The endpoints expose no page-size control, so this cap is the only guard
320 # against truncating real collections; it must stay generous.
321 assert MAX_PAGINATION_PAGES >= 1000
322