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