/
/
/
1"""Tests for ZvukMusicClient in provider/api_client.py."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, MagicMock, patch
6
7import pytest
8from music_assistant_models.errors import (
9 LoginFailed,
10 ProviderUnavailableError,
11 ResourceTemporarilyUnavailable,
12)
13from zvuk_music import StreamQuality
14from zvuk_music.exceptions import (
15 BadRequestError,
16 BotDetectedError,
17 GraphQLError,
18 NetworkError,
19 NotFoundError,
20 TimedOutError,
21 UnauthorizedError,
22)
23
24from music_assistant.helpers.throttle_retry import Throttler
25from music_assistant.providers.zvuk_music.api_client import ZvukMusicClient, handle_zvuk_errors
26
27# ---------------------------------------------------------------------------
28# Helpers
29# ---------------------------------------------------------------------------
30
31
32def _make_client(token: str = "test-token") -> ZvukMusicClient: # noqa: S107
33 """Create a ZvukMusicClient with a fake token."""
34 return ZvukMusicClient(token=token)
35
36
37def _make_connected_client() -> tuple[ZvukMusicClient, MagicMock]:
38 """
39 Create a ZvukMusicClient with _client already set (simulates post-connect state).
40
41 :return: Tuple of (client, inner_mock) where inner_mock is the mocked ClientAsync.
42 """
43 zvuk_client = _make_client()
44 inner = MagicMock()
45 zvuk_client._client = inner
46 zvuk_client._user_id = "42"
47 return zvuk_client, inner
48
49
50# ---------------------------------------------------------------------------
51# Tests for handle_zvuk_errors decorator
52# ---------------------------------------------------------------------------
53
54
55class TestHandleZvukErrors:
56 """Tests for the handle_zvuk_errors decorator."""
57
58 @pytest.mark.asyncio
59 async def test_unauthorized_error_raises_login_failed(self) -> None:
60 """UnauthorizedError is mapped to LoginFailed."""
61
62 @handle_zvuk_errors()
63 async def failing(_self: object) -> None:
64 raise UnauthorizedError("bad token")
65
66 with pytest.raises(LoginFailed):
67 await failing(None)
68
69 @pytest.mark.asyncio
70 async def test_network_error_raises_resource_temporarily_unavailable(self) -> None:
71 """NetworkError is mapped to ResourceTemporarilyUnavailable."""
72
73 @handle_zvuk_errors()
74 async def failing(_self: object) -> None:
75 raise NetworkError("connection reset")
76
77 with pytest.raises(ResourceTemporarilyUnavailable):
78 await failing(None)
79
80 @pytest.mark.asyncio
81 async def test_timed_out_error_raises_resource_temporarily_unavailable(self) -> None:
82 """TimedOutError is mapped to ResourceTemporarilyUnavailable."""
83
84 @handle_zvuk_errors()
85 async def failing(_self: object) -> None:
86 raise TimedOutError("timeout")
87
88 with pytest.raises(ResourceTemporarilyUnavailable):
89 await failing(None)
90
91 @pytest.mark.asyncio
92 async def test_bad_request_error_raises_resource_temporarily_unavailable(self) -> None:
93 """BadRequestError is mapped to ResourceTemporarilyUnavailable."""
94
95 @handle_zvuk_errors()
96 async def failing(_self: object) -> None:
97 raise BadRequestError("bad request")
98
99 with pytest.raises(ResourceTemporarilyUnavailable):
100 await failing(None)
101
102 @pytest.mark.asyncio
103 async def test_bot_detected_error_raises_provider_unavailable(self) -> None:
104 """BotDetectedError is mapped to ProviderUnavailableError."""
105
106 @handle_zvuk_errors()
107 async def failing(_self: object) -> None:
108 raise BotDetectedError("bot detected")
109
110 with pytest.raises(ProviderUnavailableError):
111 await failing(None)
112
113 @pytest.mark.asyncio
114 async def test_not_found_returns_sentinel_value_when_provided(self) -> None:
115 """NotFoundError returns not_found_return when the param is set."""
116
117 @handle_zvuk_errors(not_found_return=None)
118 async def failing(_self: object) -> str | None:
119 raise NotFoundError("not found")
120
121 result = await failing(None)
122 assert result is None
123
124 @pytest.mark.asyncio
125 async def test_not_found_empty_list_sentinel(self) -> None:
126 """not_found_return=[] returns an empty list on NotFoundError."""
127
128 @handle_zvuk_errors(not_found_return=[])
129 async def failing(_self: object) -> list[str]:
130 raise NotFoundError("not found")
131
132 result = await failing(None)
133 assert result == []
134
135 @pytest.mark.asyncio
136 async def test_not_found_error_reraised_when_no_sentinel(self) -> None:
137 """NotFoundError is re-raised when not_found_return is not provided."""
138
139 @handle_zvuk_errors()
140 async def failing(_self: object) -> None:
141 raise NotFoundError("not found")
142
143 with pytest.raises(NotFoundError):
144 await failing(None)
145
146 @pytest.mark.asyncio
147 async def test_success_returns_value(self) -> None:
148 """The decorated function returns its value normally on success."""
149
150 @handle_zvuk_errors(not_found_return=None)
151 async def succeeding(_self: object) -> str:
152 return "ok"
153
154 result = await succeeding(None)
155 assert result == "ok"
156
157
158# ---------------------------------------------------------------------------
159# Tests for handle_zvuk_errors â rate-limit backoff
160# ---------------------------------------------------------------------------
161
162
163class TestHandleZvukErrorsRateLimit:
164 """Test that 429 NetworkError gets backoff treatment."""
165
166 @pytest.mark.asyncio
167 async def test_rate_limit_network_error_raises_with_backoff(self) -> None:
168 """NetworkError with 429 raises ResourceTemporarilyUnavailable(backoff_time=60)."""
169
170 @handle_zvuk_errors()
171 async def failing(_self: object) -> None:
172 raise NetworkError("HTTP 429 Too Many Requests")
173
174 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
175 await failing(None)
176 assert exc_info.value.backoff_time == 60
177
178 @pytest.mark.asyncio
179 async def test_generic_network_error_raises_without_backoff(self) -> None:
180 """Ordinary NetworkError raises ResourceTemporarilyUnavailable without backoff."""
181
182 @handle_zvuk_errors()
183 async def failing(_self: object) -> None:
184 raise NetworkError("connection reset")
185
186 with pytest.raises(ResourceTemporarilyUnavailable) as exc_info:
187 await failing(None)
188 assert getattr(exc_info.value, "backoff_time", None) != 60
189
190
191# ---------------------------------------------------------------------------
192# Tests for connect()
193# ---------------------------------------------------------------------------
194
195
196class TestConnect:
197 """Tests for ZvukMusicClient.connect()."""
198
199 @pytest.mark.asyncio
200 async def test_connect_calls_init_and_is_authorized(self) -> None:
201 """connect() calls ClientAsync(token=...).init() and is_authorized()."""
202 client = _make_client(token="my-token")
203
204 mock_inner = MagicMock()
205 mock_inner.init = AsyncMock(return_value=mock_inner)
206 mock_inner.is_authorized = AsyncMock(return_value=True)
207 profile = MagicMock()
208 profile.result.id = 99
209 mock_inner.get_profile = AsyncMock(return_value=profile)
210
211 with patch(
212 "music_assistant.providers.zvuk_music.api_client.ClientAsync",
213 return_value=mock_inner,
214 ):
215 await client.connect()
216
217 mock_inner.init.assert_awaited_once()
218 mock_inner.is_authorized.assert_awaited_once()
219
220 @pytest.mark.asyncio
221 async def test_connect_sets_user_id_from_profile(self) -> None:
222 """connect() sets _user_id from profile.result.id."""
223 client = _make_client()
224
225 mock_inner = MagicMock()
226 mock_inner.init = AsyncMock(return_value=mock_inner)
227 mock_inner.is_authorized = AsyncMock(return_value=True)
228 profile = MagicMock()
229 profile.result.id = 777
230 mock_inner.get_profile = AsyncMock(return_value=profile)
231
232 with patch(
233 "music_assistant.providers.zvuk_music.api_client.ClientAsync",
234 return_value=mock_inner,
235 ):
236 await client.connect()
237
238 assert client._user_id == "777"
239
240 @pytest.mark.asyncio
241 async def test_connect_raises_login_failed_when_not_authorized(self) -> None:
242 """connect() raises LoginFailed when is_authorized() returns False."""
243 client = _make_client()
244
245 mock_inner = MagicMock()
246 mock_inner.init = AsyncMock(return_value=mock_inner)
247 mock_inner.is_authorized = AsyncMock(return_value=False)
248
249 with (
250 patch(
251 "music_assistant.providers.zvuk_music.api_client.ClientAsync",
252 return_value=mock_inner,
253 ),
254 pytest.raises(LoginFailed),
255 ):
256 await client.connect()
257
258 @pytest.mark.asyncio
259 async def test_connect_raises_login_failed_on_unauthorized_error(self) -> None:
260 """connect() raises LoginFailed when ClientAsync.init() raises UnauthorizedError."""
261 client = _make_client()
262
263 mock_inner = MagicMock()
264 mock_inner.init = AsyncMock(side_effect=UnauthorizedError("bad token"))
265
266 with (
267 patch(
268 "music_assistant.providers.zvuk_music.api_client.ClientAsync",
269 return_value=mock_inner,
270 ),
271 pytest.raises(LoginFailed),
272 ):
273 await client.connect()
274
275 @pytest.mark.asyncio
276 async def test_connect_raises_resource_temporarily_unavailable_on_network_error(
277 self,
278 ) -> None:
279 """connect() raises ResourceTemporarilyUnavailable on NetworkError."""
280 client = _make_client()
281
282 mock_inner = MagicMock()
283 mock_inner.init = AsyncMock(side_effect=NetworkError("timeout"))
284
285 with (
286 patch(
287 "music_assistant.providers.zvuk_music.api_client.ClientAsync",
288 return_value=mock_inner,
289 ),
290 pytest.raises(ResourceTemporarilyUnavailable),
291 ):
292 await client.connect()
293
294
295# ---------------------------------------------------------------------------
296# Tests for _ensure_connected()
297# ---------------------------------------------------------------------------
298
299
300class TestEnsureConnected:
301 """Tests for ZvukMusicClient._ensure_connected()."""
302
303 def test_raises_provider_unavailable_when_client_is_none(self) -> None:
304 """_ensure_connected() raises ProviderUnavailableError if _client is None."""
305 client = _make_client()
306 assert client._client is None
307
308 with pytest.raises(ProviderUnavailableError):
309 client._ensure_connected()
310
311 def test_returns_client_when_connected(self) -> None:
312 """_ensure_connected() returns the inner client when connected."""
313 client, inner = _make_connected_client()
314 result = client._ensure_connected()
315 assert result is inner
316
317
318# ---------------------------------------------------------------------------
319# Tests for get_collection()
320# ---------------------------------------------------------------------------
321
322
323class TestGetCollection:
324 """Tests for ZvukMusicClient.get_collection() â regression for bug B2."""
325
326 @pytest.mark.asyncio
327 async def test_returns_none_on_not_found_error(self) -> None:
328 """get_collection() returns None on NotFoundError (bug B2 regression)."""
329 client, inner = _make_connected_client()
330 inner.get_collection = AsyncMock(side_effect=NotFoundError("not found"))
331
332 result = await client.get_collection()
333
334 assert result is None
335
336 @pytest.mark.asyncio
337 async def test_returns_collection_on_success(self) -> None:
338 """get_collection() returns the collection object on success."""
339 client, inner = _make_connected_client()
340 mock_collection = MagicMock()
341 inner.get_collection = AsyncMock(return_value=mock_collection)
342
343 result = await client.get_collection()
344
345 assert result is mock_collection
346
347
348# ---------------------------------------------------------------------------
349# Tests for get_editorial_playlist_ids()
350# ---------------------------------------------------------------------------
351
352
353class TestGetEditorialPlaylistIds:
354 """Tests for ZvukMusicClient.get_editorial_playlist_ids()."""
355
356 @pytest.mark.asyncio
357 async def test_returns_ids_from_library(self) -> None:
358 """get_editorial_playlist_ids() returns the list from the library client."""
359 client, inner = _make_connected_client()
360 inner.get_editorial_playlist_ids = AsyncMock(return_value=["111", "222", "333"])
361
362 result = await client.get_editorial_playlist_ids()
363
364 assert result == ["111", "222", "333"]
365 inner.get_editorial_playlist_ids.assert_awaited_once()
366
367 @pytest.mark.asyncio
368 async def test_returns_empty_list_when_library_returns_empty(self) -> None:
369 """get_editorial_playlist_ids() returns [] when library returns []."""
370 client, inner = _make_connected_client()
371 inner.get_editorial_playlist_ids = AsyncMock(return_value=[])
372
373 result = await client.get_editorial_playlist_ids()
374
375 assert result == []
376
377
378# ---------------------------------------------------------------------------
379# Tests for get_direct_stream_url()
380# ---------------------------------------------------------------------------
381
382
383class TestGetDirectStreamUrl:
384 """Tests for ZvukMusicClient.get_direct_stream_url()."""
385
386 @pytest.mark.asyncio
387 async def test_returns_stream_url_from_result(self) -> None:
388 """get_direct_stream_url() returns the stream URL from the library result."""
389 client, inner = _make_connected_client()
390 inner.get_direct_stream_url = AsyncMock(
391 return_value=MagicMock(stream="https://cdn.zvuk.com/track.flac")
392 )
393
394 result = await client.get_direct_stream_url("12345", "flac")
395
396 assert result == "https://cdn.zvuk.com/track.flac"
397
398 @pytest.mark.asyncio
399 async def test_returns_none_when_stream_is_empty(self) -> None:
400 """get_direct_stream_url() returns None when stream field is empty."""
401 client, inner = _make_connected_client()
402 inner.get_direct_stream_url = AsyncMock(return_value=MagicMock(stream=""))
403
404 result = await client.get_direct_stream_url("12345", "flac")
405
406 assert result is None
407
408 @pytest.mark.asyncio
409 async def test_returns_none_when_library_returns_none(self) -> None:
410 """get_direct_stream_url() returns None when library returns None."""
411 client, inner = _make_connected_client()
412 inner.get_direct_stream_url = AsyncMock(return_value=None)
413
414 result = await client.get_direct_stream_url("12345", "high")
415
416 assert result is None
417
418 @pytest.mark.asyncio
419 async def test_passes_quality_as_stream_quality_enum(self) -> None:
420 """get_direct_stream_url() passes StreamQuality enum to the library."""
421 client, inner = _make_connected_client()
422 inner.get_direct_stream_url = AsyncMock(
423 return_value=MagicMock(stream="https://cdn.zvuk.com/t.mp3")
424 )
425
426 await client.get_direct_stream_url("99999", "mid")
427
428 inner.get_direct_stream_url.assert_awaited_once_with("99999", StreamQuality.MID)
429
430
431# ---------------------------------------------------------------------------
432# Tests for get_lyrics()
433# ---------------------------------------------------------------------------
434
435
436class TestGetLyrics:
437 """Tests for ZvukMusicClient.get_lyrics()."""
438
439 @pytest.mark.asyncio
440 async def test_returns_none_when_library_returns_none(self) -> None:
441 """get_lyrics() returns None when library returns None."""
442 client, inner = _make_connected_client()
443 inner.get_lyrics = AsyncMock(return_value=None)
444
445 result = await client.get_lyrics("12345")
446
447 assert result is None
448
449 @pytest.mark.asyncio
450 async def test_returns_lyrics_object_when_present(self) -> None:
451 """get_lyrics() returns the Lyrics object from the library."""
452 client, inner = _make_connected_client()
453 mock_lyrics = MagicMock(lyrics="Some lyrics text", is_synced=False)
454 inner.get_lyrics = AsyncMock(return_value=mock_lyrics)
455
456 result = await client.get_lyrics("12345")
457
458 assert result is mock_lyrics
459 inner.get_lyrics.assert_awaited_once_with("12345")
460
461 @pytest.mark.asyncio
462 async def test_returns_synced_lyrics_object(self) -> None:
463 """get_lyrics() returns the Lyrics object for synced (LRC) lyrics."""
464 client, inner = _make_connected_client()
465 lrc = "[00:00.68]First line\n[00:04.71]Second line\n"
466 mock_lyrics = MagicMock(lyrics=lrc, is_synced=True)
467 inner.get_lyrics = AsyncMock(return_value=mock_lyrics)
468
469 result = await client.get_lyrics("12345")
470
471 assert result is not None
472 assert result.lyrics == lrc
473 assert result.is_synced is True
474
475
476# ---------------------------------------------------------------------------
477# Tests for like_track() / unlike_track()
478# ---------------------------------------------------------------------------
479
480
481class TestThrottler:
482 """Tests for throttling behaviour in ZvukMusicClient."""
483
484 def test_throttler_initialized_on_construction(self) -> None:
485 """ZvukMusicClient should have a _throttler attribute after construction."""
486 client = _make_client()
487 assert hasattr(client, "_throttler")
488 assert isinstance(client._throttler, Throttler)
489
490
491class TestGetClient:
492 """Tests for ZvukMusicClient._get_client()."""
493
494 @pytest.mark.asyncio
495 async def test_get_client_acquires_throttle_slot(self) -> None:
496 """_get_client() must call throttler.acquire() before returning client."""
497 client, inner = _make_connected_client()
498 client._throttler = MagicMock()
499 client._throttler.acquire = AsyncMock()
500
501 result = await client._get_client()
502
503 client._throttler.acquire.assert_awaited_once()
504 assert result is inner
505
506 @pytest.mark.asyncio
507 async def test_get_client_raises_when_not_connected(self) -> None:
508 """_get_client() raises ProviderUnavailableError if client not connected."""
509 client = _make_client()
510 client._throttler = MagicMock()
511 client._throttler.acquire = AsyncMock()
512
513 with pytest.raises(ProviderUnavailableError):
514 await client._get_client()
515
516
517class TestLikeUnlikeTrack:
518 """Tests for ZvukMusicClient.like_track() and unlike_track()."""
519
520 @pytest.mark.asyncio
521 async def test_like_track_returns_true_on_success(self) -> None:
522 """like_track() returns True when the API call succeeds."""
523 client, inner = _make_connected_client()
524 inner.like_track = AsyncMock(return_value=True)
525
526 result = await client.like_track("123")
527
528 assert result is True
529 inner.like_track.assert_awaited_once_with("123")
530
531 @pytest.mark.asyncio
532 async def test_like_track_returns_false_on_bad_request_error(self) -> None:
533 """like_track() returns False when BadRequestError is raised."""
534 client, inner = _make_connected_client()
535 inner.like_track = AsyncMock(side_effect=BadRequestError("bad request"))
536
537 result = await client.like_track("123")
538
539 assert result is False
540
541 @pytest.mark.asyncio
542 async def test_like_track_returns_false_on_network_error(self) -> None:
543 """like_track() returns False when NetworkError is raised."""
544 client, inner = _make_connected_client()
545 inner.like_track = AsyncMock(side_effect=NetworkError("connection error"))
546
547 result = await client.like_track("123")
548
549 assert result is False
550
551 @pytest.mark.asyncio
552 async def test_like_track_returns_false_on_graphql_error(self) -> None:
553 """like_track() returns False when GraphQLError is raised."""
554 client, inner = _make_connected_client()
555 inner.like_track = AsyncMock(side_effect=GraphQLError("graphql error"))
556
557 result = await client.like_track("123")
558
559 assert result is False
560
561 @pytest.mark.asyncio
562 async def test_unlike_track_returns_true_on_success(self) -> None:
563 """unlike_track() returns True when the API call succeeds."""
564 client, inner = _make_connected_client()
565 inner.unlike_track = AsyncMock(return_value=True)
566
567 result = await client.unlike_track("456")
568
569 assert result is True
570 inner.unlike_track.assert_awaited_once_with("456")
571
572 @pytest.mark.asyncio
573 async def test_unlike_track_returns_false_on_bad_request_error(self) -> None:
574 """unlike_track() returns False when BadRequestError is raised."""
575 client, inner = _make_connected_client()
576 inner.unlike_track = AsyncMock(side_effect=BadRequestError("bad request"))
577
578 result = await client.unlike_track("456")
579
580 assert result is False
581
582 @pytest.mark.asyncio
583 async def test_unlike_track_returns_false_on_network_error(self) -> None:
584 """unlike_track() returns False when NetworkError is raised."""
585 client, inner = _make_connected_client()
586 inner.unlike_track = AsyncMock(side_effect=NetworkError("connection error"))
587
588 result = await client.unlike_track("456")
589
590 assert result is False
591