/
/
/
1"""Tests for ZvukMusicProvider library methods (get_library_*)."""
2
3from __future__ import annotations
4
5from typing import Any
6from unittest.mock import AsyncMock, Mock, patch
7
8import pytest
9from music_assistant_models.enums import MediaType
10from music_assistant_models.errors import InvalidDataError
11
12from music_assistant.providers.zvuk_music.constants import DEFAULT_LIMIT
13from music_assistant.providers.zvuk_music.provider import ZvukMusicProvider
14
15# ---------------------------------------------------------------------------
16# Helpers
17# ---------------------------------------------------------------------------
18
19_PROVIDER_MODULE = "music_assistant.providers.zvuk_music.provider"
20
21
22def _make_provider() -> Any:
23 """Create a ZvukMusicProvider mock with library methods and _iter_batched bound."""
24 provider = Mock(spec=ZvukMusicProvider)
25 provider.client = Mock()
26 provider.logger = Mock()
27 provider.instance_id = "zvuk_music"
28 provider._iter_batched = ZvukMusicProvider._iter_batched.__get__(provider, ZvukMusicProvider)
29 provider.get_library_artists = ZvukMusicProvider.get_library_artists.__get__(
30 provider, ZvukMusicProvider
31 )
32 provider.get_library_albums = ZvukMusicProvider.get_library_albums.__get__(
33 provider, ZvukMusicProvider
34 )
35 provider.get_library_tracks = ZvukMusicProvider.get_library_tracks.__get__(
36 provider, ZvukMusicProvider
37 )
38 provider.get_library_playlists = ZvukMusicProvider.get_library_playlists.__get__(
39 provider, ZvukMusicProvider
40 )
41 return provider
42
43
44def _make_item(item_id: int) -> Mock:
45 """Create a minimal collection item mock with an id."""
46 item = Mock()
47 item.id = item_id
48 return item
49
50
51# ---------------------------------------------------------------------------
52# Tests for get_library_artists()
53# ---------------------------------------------------------------------------
54
55
56class TestGetLibraryArtists:
57 """Tests for ZvukMusicProvider.get_library_artists()."""
58
59 @pytest.mark.asyncio
60 async def test_none_collection_yields_nothing(self) -> None:
61 """When get_collection() returns None, nothing is yielded."""
62 provider = _make_provider()
63 provider.client.get_collection = AsyncMock(return_value=None)
64
65 results = [a async for a in provider.get_library_artists()]
66
67 assert results == []
68
69 @pytest.mark.asyncio
70 async def test_empty_artists_list_yields_nothing(self) -> None:
71 """When collection.artists is empty, nothing is yielded."""
72 provider = _make_provider()
73 collection = Mock()
74 collection.artists = []
75 provider.client.get_collection = AsyncMock(return_value=collection)
76
77 results = [a async for a in provider.get_library_artists()]
78
79 assert results == []
80
81 @pytest.mark.asyncio
82 async def test_items_are_fetched_and_parsed(self) -> None:
83 """Artists in the collection are fetched in batch and parsed."""
84 provider = _make_provider()
85 collection = Mock()
86 collection.artists = [_make_item(1), _make_item(2)]
87 provider.client.get_collection = AsyncMock(return_value=collection)
88
89 raw_1, raw_2 = _make_item(1), _make_item(2)
90 provider.client.get_artists = AsyncMock(return_value=[raw_1, raw_2])
91
92 parsed_1, parsed_2 = Mock(), Mock()
93
94 with patch(f"{_PROVIDER_MODULE}.parse_artist", side_effect=[parsed_1, parsed_2]):
95 results = [a async for a in provider.get_library_artists()]
96
97 assert results == [parsed_1, parsed_2]
98
99 @pytest.mark.asyncio
100 async def test_invalid_data_error_is_skipped(self) -> None:
101 """InvalidDataError from parse_artist is reported and the item is skipped."""
102 provider = _make_provider()
103 collection = Mock()
104 collection.artists = [_make_item(1), _make_item(2)]
105 provider.client.get_collection = AsyncMock(return_value=collection)
106
107 raw_1, raw_2 = _make_item(1), _make_item(2)
108 provider.client.get_artists = AsyncMock(return_value=[raw_1, raw_2])
109
110 parsed_2 = Mock()
111 err = InvalidDataError("bad data")
112
113 with patch(
114 f"{_PROVIDER_MODULE}.parse_artist",
115 side_effect=[err, parsed_2],
116 ):
117 results = [a async for a in provider.get_library_artists()]
118
119 assert results == [parsed_2]
120 provider.report_skipped_sync_item.assert_called_once_with(
121 MediaType.ARTIST, str(raw_1.id), err
122 )
123
124 @pytest.mark.asyncio
125 async def test_large_collection_fetches_multiple_batches(self) -> None:
126 """Collections larger than DEFAULT_LIMIT trigger multiple fetcher calls."""
127 provider = _make_provider()
128 total = DEFAULT_LIMIT + 10 # 60 items â 2 batches
129 collection = Mock()
130 collection.artists = [_make_item(i) for i in range(1, total + 1)]
131 provider.client.get_collection = AsyncMock(return_value=collection)
132 mock_get_artists = AsyncMock(return_value=[])
133 provider.client.get_artists = mock_get_artists
134
135 with patch(f"{_PROVIDER_MODULE}.parse_artist"):
136 [a async for a in provider.get_library_artists()]
137
138 assert mock_get_artists.await_count == 2
139 first_batch = mock_get_artists.call_args_list[0][0][0]
140 second_batch = mock_get_artists.call_args_list[1][0][0]
141 assert len(first_batch) == DEFAULT_LIMIT
142 assert len(second_batch) == 10
143
144
145# ---------------------------------------------------------------------------
146# Tests for get_library_albums()
147# ---------------------------------------------------------------------------
148
149
150class TestGetLibraryAlbums:
151 """Tests for ZvukMusicProvider.get_library_albums()."""
152
153 @pytest.mark.asyncio
154 async def test_none_collection_yields_nothing(self) -> None:
155 """When get_collection() returns None, nothing is yielded."""
156 provider = _make_provider()
157 provider.client.get_collection = AsyncMock(return_value=None)
158
159 results = [a async for a in provider.get_library_albums()]
160
161 assert results == []
162
163 @pytest.mark.asyncio
164 async def test_items_are_fetched_and_parsed(self) -> None:
165 """Releases in the collection are fetched and parsed as albums."""
166 provider = _make_provider()
167 collection = Mock()
168 collection.releases = [_make_item(10), _make_item(20)]
169 provider.client.get_collection = AsyncMock(return_value=collection)
170
171 raw_1, raw_2 = _make_item(10), _make_item(20)
172 provider.client.get_releases = AsyncMock(return_value=[raw_1, raw_2])
173
174 parsed_1, parsed_2 = Mock(), Mock()
175
176 with patch(f"{_PROVIDER_MODULE}.parse_album", side_effect=[parsed_1, parsed_2]):
177 results = [a async for a in provider.get_library_albums()]
178
179 assert results == [parsed_1, parsed_2]
180
181 @pytest.mark.asyncio
182 async def test_invalid_data_error_is_skipped(self) -> None:
183 """InvalidDataError from parse_album is reported and the item is skipped."""
184 provider = _make_provider()
185 collection = Mock()
186 collection.releases = [_make_item(10)]
187 provider.client.get_collection = AsyncMock(return_value=collection)
188 raw = _make_item(10)
189 provider.client.get_releases = AsyncMock(return_value=[raw])
190
191 err = InvalidDataError("bad release")
192 with patch(f"{_PROVIDER_MODULE}.parse_album", side_effect=err):
193 results = [a async for a in provider.get_library_albums()]
194
195 assert results == []
196 provider.report_skipped_sync_item.assert_called_once_with(MediaType.ALBUM, str(raw.id), err)
197
198
199# ---------------------------------------------------------------------------
200# Tests for get_library_tracks()
201# ---------------------------------------------------------------------------
202
203
204class TestGetLibraryTracks:
205 """Tests for ZvukMusicProvider.get_library_tracks()."""
206
207 @pytest.mark.asyncio
208 async def test_none_collection_yields_nothing(self) -> None:
209 """When get_collection() returns None, nothing is yielded."""
210 provider = _make_provider()
211 provider.client.get_collection = AsyncMock(return_value=None)
212
213 results = [t async for t in provider.get_library_tracks()]
214
215 assert results == []
216
217 @pytest.mark.asyncio
218 async def test_items_are_fetched_and_parsed(self) -> None:
219 """Tracks in the collection are fetched and parsed."""
220 provider = _make_provider()
221 collection = Mock()
222 collection.tracks = [_make_item(100), _make_item(200)]
223 provider.client.get_collection = AsyncMock(return_value=collection)
224
225 raw_1, raw_2 = _make_item(100), _make_item(200)
226 provider.client.get_tracks = AsyncMock(return_value=[raw_1, raw_2])
227
228 parsed_1, parsed_2 = Mock(), Mock()
229
230 with patch(f"{_PROVIDER_MODULE}.parse_track", side_effect=[parsed_1, parsed_2]):
231 results = [t async for t in provider.get_library_tracks()]
232
233 assert results == [parsed_1, parsed_2]
234
235 @pytest.mark.asyncio
236 async def test_invalid_data_error_is_skipped(self) -> None:
237 """InvalidDataError from parse_track causes the item to be skipped."""
238 provider = _make_provider()
239 collection = Mock()
240 collection.tracks = [_make_item(100), _make_item(200)]
241 provider.client.get_collection = AsyncMock(return_value=collection)
242
243 provider.client.get_tracks = AsyncMock(return_value=[Mock(), Mock()])
244 good_track = Mock()
245
246 with patch(
247 f"{_PROVIDER_MODULE}.parse_track",
248 side_effect=[InvalidDataError("bad track"), good_track],
249 ):
250 results = [t async for t in provider.get_library_tracks()]
251
252 assert results == [good_track]
253
254
255# ---------------------------------------------------------------------------
256# Tests for get_library_playlists()
257# ---------------------------------------------------------------------------
258
259
260class TestGetLibraryPlaylists:
261 """Tests for ZvukMusicProvider.get_library_playlists()."""
262
263 @pytest.mark.asyncio
264 async def test_empty_user_playlists_still_yields_synthesis(self) -> None:
265 """When get_user_playlists() returns None, synthesis playlists are still yielded."""
266 provider = _make_provider()
267 provider.client.get_user_playlists = AsyncMock(return_value=None)
268 raw_synth = Mock()
269 provider.client.get_short_playlists = AsyncMock(return_value=[raw_synth])
270
271 parsed_synth = Mock()
272 with patch(f"{_PROVIDER_MODULE}.parse_playlist", return_value=parsed_synth):
273 results = [p async for p in provider.get_library_playlists()]
274
275 assert results == [parsed_synth]
276
277 @pytest.mark.asyncio
278 async def test_user_playlists_are_fetched_and_parsed(self) -> None:
279 """User playlists are fetched in batch and parsed."""
280 provider = _make_provider()
281 provider.client.get_user_playlists = AsyncMock(return_value=[_make_item(1), _make_item(2)])
282 raw_1, raw_2 = _make_item(1), _make_item(2)
283 provider.client.get_playlists = AsyncMock(return_value=[raw_1, raw_2])
284 provider.client.get_short_playlists = AsyncMock(return_value=[])
285
286 parsed_1, parsed_2 = Mock(), Mock()
287
288 with patch(f"{_PROVIDER_MODULE}.parse_playlist", side_effect=[parsed_1, parsed_2]):
289 results = [p async for p in provider.get_library_playlists()]
290
291 assert results == [parsed_1, parsed_2]
292
293 @pytest.mark.asyncio
294 async def test_synthesis_playlists_are_also_yielded(self) -> None:
295 """Synthesis (personalized) playlists are yielded after user playlists."""
296 provider = _make_provider()
297 provider.client.get_user_playlists = AsyncMock(return_value=[_make_item(1)])
298 raw_user = Mock()
299 provider.client.get_playlists = AsyncMock(return_value=[raw_user])
300 raw_synth = Mock()
301 provider.client.get_short_playlists = AsyncMock(return_value=[raw_synth])
302
303 user_parsed = Mock()
304 synth_parsed = Mock()
305
306 with patch(f"{_PROVIDER_MODULE}.parse_playlist", side_effect=[user_parsed, synth_parsed]):
307 results = [p async for p in provider.get_library_playlists()]
308
309 assert results == [user_parsed, synth_parsed]
310
311 @pytest.mark.asyncio
312 async def test_synthesis_invalid_data_error_is_skipped(self) -> None:
313 """InvalidDataError from a synthesis playlist parser is reported and skipped."""
314 provider = _make_provider()
315 # Need at least one user playlist so the method doesn't return early
316 provider.client.get_user_playlists = AsyncMock(return_value=[_make_item(1)])
317 provider.client.get_playlists = AsyncMock(return_value=[_make_item(1)])
318 raw_synth = Mock()
319 provider.client.get_short_playlists = AsyncMock(return_value=[raw_synth])
320
321 # First call (user playlist) succeeds; second call (synthesis) raises
322 good_parsed = Mock()
323 err = InvalidDataError("bad synth")
324 with patch(
325 f"{_PROVIDER_MODULE}.parse_playlist",
326 side_effect=[good_parsed, err],
327 ):
328 results = [p async for p in provider.get_library_playlists()]
329
330 # User playlist is yielded; synthesis item is skipped
331 assert results == [good_parsed]
332 provider.report_skipped_sync_item.assert_called_once_with(
333 MediaType.PLAYLIST, str(raw_synth.id), err
334 )
335
336 @pytest.mark.asyncio
337 async def test_invalid_data_error_in_user_playlist_is_skipped(self) -> None:
338 """InvalidDataError from parse_playlist on a user playlist is skipped."""
339 provider = _make_provider()
340 provider.client.get_user_playlists = AsyncMock(return_value=[_make_item(1), _make_item(2)])
341 raw_1, raw_2 = _make_item(1), _make_item(2)
342 provider.client.get_playlists = AsyncMock(return_value=[raw_1, raw_2])
343 provider.client.get_short_playlists = AsyncMock(return_value=[])
344
345 good_parsed = Mock()
346
347 with patch(
348 f"{_PROVIDER_MODULE}.parse_playlist",
349 side_effect=[InvalidDataError("bad pl"), good_parsed],
350 ):
351 results = [p async for p in provider.get_library_playlists()]
352
353 assert results == [good_parsed]
354
355
356class TestBatchFetchGaps:
357 """Tests for id's a batch fetch does not return."""
358
359 @pytest.mark.asyncio
360 async def test_id_the_fetch_left_out_is_reported(self) -> None:
361 """
362 An id the batch fetch did not return is reported instead of looking removed.
363
364 The fetch returns an empty list for a batch it cannot find, which would otherwise
365 drop every item in that batch out of the library.
366 """
367 provider = _make_provider()
368 collection = Mock()
369 collection.artists = [_make_item(1), _make_item(2)]
370 provider.client.get_collection = AsyncMock(return_value=collection)
371 provider.client.get_artists = AsyncMock(return_value=[])
372
373 with patch(f"{_PROVIDER_MODULE}.parse_artist"):
374 results = [a async for a in provider.get_library_artists()]
375
376 assert results == []
377 reported = {call.args[1] for call in provider.report_skipped_sync_item.call_args_list}
378 assert reported == {"1", "2"}
379
380 @pytest.mark.asyncio
381 async def test_id_the_fetch_returned_is_not_reported(self) -> None:
382 """An item that came back fine is not reported as missing."""
383 provider = _make_provider()
384 collection = Mock()
385 collection.artists = [_make_item(1), _make_item(2)]
386 provider.client.get_collection = AsyncMock(return_value=collection)
387 provider.client.get_artists = AsyncMock(return_value=[_make_item(1)])
388
389 with patch(f"{_PROVIDER_MODULE}.parse_artist"):
390 results = [a async for a in provider.get_library_artists()]
391
392 assert len(results) == 1
393 reported = {call.args[1] for call in provider.report_skipped_sync_item.call_args_list}
394 assert reported == {"2"}
395