/
/
/
1"""Test Bandcamp Provider integration."""
2
3from collections.abc import AsyncGenerator
4from typing import cast
5from unittest.mock import AsyncMock, Mock, patch
6
7import pytest
8from bandcamp_async_api import (
9 BandcampAPIError,
10 BandcampMustBeLoggedInError,
11 BandcampNotFoundError,
12 BandcampRateLimitError,
13 SearchResultAlbum,
14 SearchResultArtist,
15 SearchResultTrack,
16)
17from bandcamp_async_api.models import CollectionType
18from music_assistant_models.enums import (
19 ContentType,
20 ImageType,
21 MediaType,
22 ProviderFeature,
23 StreamType,
24)
25from music_assistant_models.errors import (
26 InvalidDataError,
27 LoginFailed,
28 MediaNotFoundError,
29 ResourceTemporarilyUnavailable,
30 RetriesExhausted,
31)
32from music_assistant_models.media_items import Album, Artist, BrowseFolder, Track
33from music_assistant_models.streamdetails import StreamDetails
34
35from music_assistant.helpers.throttle_retry import ThrottlerManager
36from music_assistant.providers.bandcamp import BandcampProvider, split_id
37from music_assistant.providers.bandcamp.constants import (
38 CACHE_EMPTY_RESULTS,
39 CACHE_USER_LISTS,
40 DEFAULT_TOP_TRACKS_LIMIT,
41 SUPPORTED_FEATURES,
42)
43from tests.common import use_real_create_task
44
45
46def _fan_mock(
47 fan_id: int,
48 name: str | None,
49 image_url: str | None = None,
50 url: str | None = None,
51) -> Mock:
52 """Create a mock FanItem with real string attributes for BrowseFolder compatibility."""
53 fan = Mock(spec=["fan_id", "name", "image_url", "url"])
54 fan.fan_id = fan_id
55 fan.name = name
56 fan.image_url = image_url
57 fan.url = url
58 return fan
59
60
61@pytest.fixture
62def mass_mock() -> Mock:
63 """Return a mock MusicAssistant instance."""
64 mass = Mock()
65 mass.http_session = AsyncMock()
66 mass.metadata.locale = "en_US"
67 mass.cache.get = AsyncMock(return_value=None)
68 mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
69 mass.cache.set = AsyncMock()
70 mass.cache.delete = AsyncMock()
71 # setup_data is unset in these unit tests, so get_setup_value falls through to
72 # the provider config's get_value (which the config mock stubs)
73 mass.config.get = Mock(return_value=None)
74 mass.config.get_raw_provider_config_value = Mock(return_value=None)
75 use_real_create_task(mass)
76 return mass
77
78
79@pytest.fixture
80def config_mock() -> Mock:
81 """Return a mock provider config."""
82 config = Mock()
83 config.name = "Bandcamp Test"
84 config.instance_id = "bandcamp_test"
85 config.enabled = True
86 config.values = {}
87 config.get_value.side_effect = lambda key, default=None: {
88 "identity": "mock_identity_token",
89 "search_limit": 10,
90 "top_tracks_limit": 50,
91 "log_level": "INFO",
92 }.get(
93 key,
94 default
95 if default is not None
96 else (10 if key == "search_limit" else (50 if key == "top_tracks_limit" else "INFO")),
97 )
98 return config
99
100
101@pytest.fixture
102async def provider(mass_mock: Mock, manifest_mock: Mock, config_mock: Mock) -> BandcampProvider:
103 """Return a BandcampProvider instance."""
104 provider = BandcampProvider(mass_mock, manifest_mock, config_mock, SUPPORTED_FEATURES)
105 provider.throttler = ThrottlerManager(
106 rate_limit=provider.throttler.throttler.rate_limit,
107 period=provider.throttler.throttler.period,
108 retry_attempts=provider.throttler.retry_attempts,
109 initial_backoff=provider.throttler.initial_backoff,
110 )
111
112 # Initialize the provider
113 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
114 mock_client = AsyncMock()
115 mock_client_class.return_value = mock_client
116 await provider.handle_async_init()
117
118 return provider
119
120
121async def test_provider_initialization(
122 mass_mock: Mock, manifest_mock: Mock, config_mock: Mock
123) -> None:
124 """Test provider initialization."""
125 provider = BandcampProvider(mass_mock, manifest_mock, config_mock)
126
127 assert provider.domain == "bandcamp"
128 assert provider.instance_id == "bandcamp_test"
129
130 # Test that initialization sets the correct values
131 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
132 mock_client = AsyncMock()
133 mock_client_class.return_value = mock_client
134
135 await provider.handle_async_init()
136
137 assert provider.top_tracks_limit == DEFAULT_TOP_TRACKS_LIMIT
138
139
140async def test_handle_async_init_with_identity(provider: BandcampProvider) -> None:
141 """Test successful async initialization with identity token."""
142 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
143 mock_client = AsyncMock()
144 mock_client_class.return_value = mock_client
145
146 await provider.handle_async_init()
147
148 mock_client_class.assert_called_once_with(
149 session=provider.mass.http_session,
150 identity_token="mock_identity_token",
151 default_retry_after=3,
152 )
153 assert provider._client == mock_client
154 assert provider._converters is not None
155
156
157async def test_handle_async_init_without_identity(mass_mock: Mock, manifest_mock: Mock) -> None:
158 """Test async initialization without identity token."""
159 config = Mock()
160 config.values = {}
161 config.get_value.side_effect = lambda key, default=None: (
162 default if default is not None else ("INFO" if key == "log_level" else None)
163 )
164 provider = BandcampProvider(mass_mock, manifest_mock, config)
165
166 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
167 mock_client = AsyncMock()
168 mock_client_class.return_value = mock_client
169
170 await provider.handle_async_init()
171
172 mock_client_class.assert_called_once_with(
173 session=provider.mass.http_session,
174 identity_token=None,
175 default_retry_after=3,
176 )
177
178
179async def test_is_streaming_provider(provider: BandcampProvider) -> None:
180 """Test that Bandcamp is a streaming provider."""
181 assert provider.is_streaming_provider is True
182
183
184async def test_search_with_identity(provider: BandcampProvider) -> None:
185 """Test search functionality with identity token."""
186 mock_search_results = [
187 Mock(spec=SearchResultTrack),
188 Mock(spec=SearchResultAlbum),
189 Mock(spec=SearchResultArtist),
190 ]
191
192 with (
193 patch.object(provider._client, "search", new_callable=AsyncMock) as mock_search,
194 patch.object(provider._converters, "track_from_search") as mock_track_converter,
195 patch.object(provider._converters, "album_from_search") as mock_album_converter,
196 patch.object(provider._converters, "artist_from_search") as mock_artist_converter,
197 ):
198 mock_search.return_value = mock_search_results
199
200 mock_track_converter.return_value = Mock()
201 mock_album_converter.return_value = Mock()
202 mock_artist_converter.return_value = Mock()
203
204 results = await provider.search(
205 "test query", [MediaType.TRACK, MediaType.ALBUM, MediaType.ARTIST], limit=5
206 )
207
208 mock_search.assert_called_once_with("test query")
209 mock_track_converter.assert_called_once()
210 mock_album_converter.assert_called_once()
211 mock_artist_converter.assert_called_once()
212 assert len(results.tracks) == 1
213 assert len(results.albums) == 1
214 assert len(results.artists) == 1
215
216
217async def test_search_without_identity(provider: BandcampProvider) -> None:
218 """Test search returns empty results without identity token."""
219 provider._client.identity = None
220
221 results = await provider.search("test query", [MediaType.TRACK])
222
223 assert len(results.tracks) == 0
224 assert len(results.albums) == 0
225 assert len(results.artists) == 0
226
227
228async def test_search_api_error(provider: BandcampProvider) -> None:
229 """Test search handles API errors gracefully."""
230 with (
231 patch.object(provider._client, "search", side_effect=BandcampAPIError("API Error")),
232 pytest.raises(InvalidDataError, match="Unexpected error during Bandcamp search"),
233 ):
234 await provider.search("test query", [MediaType.TRACK])
235
236
237async def test_get_artist_success(provider: BandcampProvider) -> None:
238 """Test successful artist retrieval."""
239 mock_artist = Mock()
240
241 with (
242 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
243 patch.object(provider._converters, "artist_from_api") as mock_converter,
244 ):
245 mock_get_artist.return_value = mock_artist
246 mock_converter.return_value = Mock()
247
248 result = await provider.get_artist("123")
249
250 mock_get_artist.assert_called_once_with("123")
251 mock_converter.assert_called_once_with(mock_artist)
252 assert result is not None
253
254
255async def test_get_artist_not_found(provider: BandcampProvider) -> None:
256 """Test artist retrieval when not found."""
257 with (
258 patch.object(
259 provider._client, "get_artist", side_effect=BandcampNotFoundError("Not found")
260 ),
261 pytest.raises(MediaNotFoundError, match=r"Artist 123 not found on Bandcamp"),
262 ):
263 await provider.get_artist("123")
264
265
266async def test_get_album_success(provider: BandcampProvider) -> None:
267 """Test successful album retrieval."""
268 mock_album = Mock()
269
270 with (
271 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
272 patch.object(provider._converters, "album_from_api") as mock_converter,
273 ):
274 mock_get_album.return_value = mock_album
275 mock_converter.return_value = Mock()
276
277 result = await provider.get_album("123-456")
278
279 mock_get_album.assert_called_once_with(123, 456)
280 assert result is not None
281
282
283async def test_get_track_success(provider: BandcampProvider) -> None:
284 """Test successful track retrieval."""
285 mock_album = Mock()
286 mock_track = Mock()
287 mock_album.tracks = [mock_track]
288 mock_track.id = 789
289
290 with (
291 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
292 patch.object(provider._converters, "track_from_api") as mock_converter,
293 ):
294 mock_get_album.return_value = mock_album
295 mock_converter.return_value = Mock()
296
297 result = await provider.get_track("123-456-789")
298
299 mock_get_album.assert_called_once_with(123, 456)
300 assert result is not None
301
302
303async def test_get_track_standalone(provider: BandcampProvider) -> None:
304 """Test get_track for a standalone track (album_id=0) uses get_track API path."""
305 mock_album_obj = Mock()
306 mock_album_obj.id = 456
307 mock_album_obj.title = "Standalone Album"
308 mock_album_obj.art_url = "http://example.com/art.jpg"
309
310 mock_api_track = Mock()
311 mock_api_track.album = mock_album_obj
312
313 with (
314 patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track,
315 patch.object(provider._converters, "track_from_api") as mock_converter,
316 ):
317 mock_get_track.return_value = mock_api_track
318 mock_converter.return_value = Mock()
319
320 result = await provider.get_track("123-0-789")
321
322 mock_get_track.assert_called_once_with(123, 789)
323 mock_converter.assert_called_once_with(
324 track=mock_api_track,
325 album_id=456,
326 album_name="Standalone Album",
327 album_image_url="http://example.com/art.jpg",
328 )
329 assert result is not None
330
331
332async def test_get_track_standalone_no_album(provider: BandcampProvider) -> None:
333 """Test get_track for a standalone track where api_track.album is None."""
334 mock_api_track = Mock()
335 mock_api_track.album = None
336
337 with (
338 patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track,
339 patch.object(provider._converters, "track_from_api") as mock_converter,
340 ):
341 mock_get_track.return_value = mock_api_track
342 mock_converter.return_value = Mock()
343
344 result = await provider.get_track("123-0-789")
345
346 mock_get_track.assert_called_once_with(123, 789)
347 mock_converter.assert_called_once_with(
348 track=mock_api_track,
349 album_id=None,
350 album_name="",
351 album_image_url="",
352 )
353 assert result is not None
354
355
356async def test_get_track_not_found(provider: BandcampProvider) -> None:
357 """Test track retrieval when not found."""
358 with (
359 patch.object(provider._client, "get_album", side_effect=BandcampNotFoundError("Not found")),
360 pytest.raises(MediaNotFoundError, match=r"Track 123-456-789 not found on Bandcamp"),
361 ):
362 await provider.get_track("123-456-789")
363
364
365async def test_get_album_tracks_success(provider: BandcampProvider) -> None:
366 """Test successful album tracks retrieval."""
367 mock_album = Mock()
368 mock_track = Mock()
369 mock_track.streaming_url = {"mp3-128": "http://example.com/track.mp3"}
370 mock_album.tracks = [mock_track]
371 mock_album.title = "Test Album"
372 mock_album.art_url = "http://example.com/art.jpg"
373
374 with (
375 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
376 patch.object(provider._converters, "track_from_api") as mock_converter,
377 ):
378 mock_get_album.return_value = mock_album
379 mock_converter.return_value = Mock()
380
381 result = await provider.get_album_tracks("123-456")
382
383 assert len(result) == 1
384 mock_converter.assert_called_once()
385
386
387async def test_get_artist_albums_success(provider: BandcampProvider) -> None:
388 """Test successful artist albums retrieval converts discography items directly."""
389 mock_discography = [
390 {
391 "item_type": "album",
392 "band_id": 123,
393 "item_id": 456,
394 "title": "Test Album",
395 "artist_name": "Test Artist",
396 "band_name": "Test Artist",
397 "art_id": 9876543210,
398 "release_date": "21 Feb 2020 00:00:00 GMT",
399 },
400 {"item_type": "track", "band_id": 123, "item_id": 789}, # should be skipped
401 ]
402
403 with patch.object(
404 provider._client, "get_artist_discography", new_callable=AsyncMock
405 ) as mock_get_discography:
406 mock_get_discography.return_value = mock_discography
407
408 result = await provider.get_artist_albums("123")
409
410 mock_get_discography.assert_called_once_with("123")
411 assert len(result) == 1
412 assert result[0].item_id == "123-456"
413 assert result[0].name == "Test Album"
414 assert result[0].year == 2020
415
416
417async def test_get_artist_albums_label_uses_band_id(provider: BandcampProvider) -> None:
418 """Test that label discography uses each album's band_id, not the label's ID."""
419 mock_discography = [
420 {
421 "item_type": "album",
422 "band_id": 9999, # actual artist, different from label ID
423 "item_id": 100,
424 "title": "Artist Album",
425 "artist_name": "Some Artist",
426 "band_name": "Some Artist",
427 "art_id": 1111,
428 "release_date": "01 Jan 2023 00:00:00 GMT",
429 },
430 ]
431
432 with patch.object(
433 provider._client, "get_artist_discography", new_callable=AsyncMock
434 ) as mock_get_discography:
435 mock_get_discography.return_value = mock_discography
436
437 # Query with label ID "555", but album should use band_id 9999
438 result = await provider.get_artist_albums("555")
439
440 assert len(result) == 1
441 assert result[0].item_id == "9999-100" # band_id, not label ID
442 artists = list(result[0].artists)
443 assert artists[0].item_id == "9999"
444
445
446async def test_get_stream_details_success(provider: BandcampProvider) -> None:
447 """Test stream details fetches fresh URL and audio format from API."""
448 mock_api_track = Mock()
449 mock_api_track.id = 789
450 mock_api_track.streaming_url = {"mp3-320": "http://example.com/track.mp3"}
451 mock_api_album = Mock()
452 mock_api_album.tracks = [mock_api_track]
453
454 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
455 mock_get_album.return_value = mock_api_album
456
457 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
458
459 mock_get_album.assert_called_once_with(123, 456)
460 assert isinstance(result, StreamDetails)
461 assert result.item_id == "123-456-789"
462 assert result.media_type == MediaType.TRACK
463 assert result.stream_type == StreamType.HTTP
464 assert result.path == "http://example.com/track.mp3"
465 assert result.audio_format.content_type == ContentType.MP3
466 assert result.audio_format.bit_rate == 320
467
468
469async def test_get_stream_details_vbr(provider: BandcampProvider) -> None:
470 """Test stream details with VBR mp3-v0 format."""
471 mock_api_track = Mock()
472 mock_api_track.id = 789
473 mock_api_track.streaming_url = {"mp3-v0": "http://example.com/track-v0.mp3"}
474 mock_api_album = Mock()
475 mock_api_album.tracks = [mock_api_track]
476
477 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
478 mock_get_album.return_value = mock_api_album
479
480 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
481
482 assert result.path == "http://example.com/track-v0.mp3"
483 assert result.audio_format.content_type == ContentType.MP3
484 assert result.audio_format.bit_rate is None
485
486
487async def test_get_stream_details_no_streaming_url(provider: BandcampProvider) -> None:
488 """Test stream details when API track has no streaming URL."""
489 mock_api_track = Mock()
490 mock_api_track.id = 789
491 mock_api_track.streaming_url = {}
492 mock_api_album = Mock()
493 mock_api_album.tracks = [mock_api_track]
494
495 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
496 mock_get_album.return_value = mock_api_album
497
498 with pytest.raises(MediaNotFoundError, match=r"No streaming URL found"):
499 await provider.get_stream_details("123-456-789", MediaType.TRACK)
500
501
502async def test_get_stream_details_none_streaming_url(provider: BandcampProvider) -> None:
503 """Test stream details when API track has streaming_url=None."""
504 mock_api_track = Mock()
505 mock_api_track.id = 789
506 mock_api_track.streaming_url = None
507 mock_api_album = Mock()
508 mock_api_album.tracks = [mock_api_track]
509
510 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
511 mock_get_album.return_value = mock_api_album
512
513 with pytest.raises(MediaNotFoundError, match=r"No streaming URL found"):
514 await provider.get_stream_details("123-456-789", MediaType.TRACK)
515
516
517async def test_get_stream_details_bypasses_cache(provider: BandcampProvider) -> None:
518 """Test that get_stream_details calls API directly, not cached get_track."""
519 mock_api_track = Mock()
520 mock_api_track.id = 789
521 mock_api_track.streaming_url = {"mp3-128": "http://example.com/track.mp3"}
522 mock_api_album = Mock()
523 mock_api_album.tracks = [mock_api_track]
524
525 with (
526 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
527 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
528 ):
529 mock_get_album.return_value = mock_api_album
530
531 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
532
533 mock_get_album.assert_called_once()
534 mock_get_track.assert_not_called()
535 assert result.path == "http://example.com/track.mp3"
536 assert result.audio_format.content_type == ContentType.MP3
537
538
539async def test_fetch_api_track_album_path(provider: BandcampProvider) -> None:
540 """Test _fetch_api_track with 3-part ID routes through get_album."""
541 mock_api_track = Mock()
542 mock_api_track.id = 789
543 mock_api_album = Mock()
544 mock_api_album.tracks = [mock_api_track]
545
546 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
547 mock_get_album.return_value = mock_api_album
548
549 api_track, api_album = await provider._fetch_api_track("123-456-789")
550
551 mock_get_album.assert_called_once_with(123, 456)
552 assert api_track is mock_api_track
553 assert api_album is mock_api_album
554
555
556async def test_fetch_api_track_standalone_path(provider: BandcampProvider) -> None:
557 """Test _fetch_api_track with album_id=0 routes through get_track."""
558 mock_api_track = Mock()
559
560 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
561 mock_get_track.return_value = mock_api_track
562
563 api_track, api_album = await provider._fetch_api_track("123-0-789")
564
565 mock_get_track.assert_called_once_with(123, 789)
566 assert api_track is mock_api_track
567 assert api_album is None
568
569
570async def test_fetch_api_track_not_in_album(provider: BandcampProvider) -> None:
571 """Test _fetch_api_track raises when track ID not found in album tracks."""
572 mock_other_track = Mock()
573 mock_other_track.id = 999
574 mock_api_album = Mock()
575 mock_api_album.tracks = [mock_other_track]
576
577 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
578 mock_get_album.return_value = mock_api_album
579
580 with pytest.raises(MediaNotFoundError, match=r"not found in album"):
581 await provider._fetch_api_track("123-456-789")
582
583
584async def test_fetch_api_track_not_found_error(provider: BandcampProvider) -> None:
585 """Test _fetch_api_track converts BandcampNotFoundError."""
586 with (
587 patch.object(
588 provider._client,
589 "get_album",
590 side_effect=BandcampNotFoundError("Not found"),
591 ),
592 pytest.raises(MediaNotFoundError, match=r"not found on Bandcamp"),
593 ):
594 await provider._fetch_api_track("123-456-789")
595
596
597async def test_fetch_api_track_rate_limit_error(provider: BandcampProvider) -> None:
598 """
599 Test _fetch_api_track converts BandcampRateLimitError.
600
601 Since @throttle_with_retries is on _fetch_api_track, persistent rate
602 limiting exhausts retries and raises RetriesExhausted.
603 """
604 rate_error = BandcampRateLimitError("Rate limited")
605 rate_error.retry_after = 3
606
607 with (
608 patch.object(
609 provider._client,
610 "get_album",
611 side_effect=rate_error,
612 ) as mock_get_album,
613 patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
614 pytest.raises(RetriesExhausted),
615 ):
616 await provider._fetch_api_track("123-456-789")
617
618 assert mock_get_album.call_count == provider.throttler.retry_attempts
619 assert mock_sleep.call_count == provider.throttler.retry_attempts - 1
620
621
622async def test_fetch_api_track_generic_api_error(provider: BandcampProvider) -> None:
623 """Test _fetch_api_track converts generic BandcampAPIError to MediaNotFoundError."""
624 with (
625 patch.object(
626 provider._client,
627 "get_album",
628 side_effect=BandcampAPIError("Something went wrong"),
629 ),
630 pytest.raises(MediaNotFoundError, match=r"Failed to get track 123-456-789"),
631 ):
632 await provider._fetch_api_track("123-456-789")
633
634
635def test_split_id_three_parts() -> None:
636 """Test split_id with a 3-part compound ID."""
637 assert split_id("123-456-789") == (123, 456, 789)
638
639
640def test_split_id_two_parts() -> None:
641 """Test split_id with a 2-part compound ID."""
642 assert split_id("123-456") == (123, 456, 0)
643
644
645def test_split_id_one_part() -> None:
646 """Test split_id with a single ID."""
647 assert split_id("123") == (123, 0, 0)
648
649
650async def test_fetch_api_track_two_part_id(provider: BandcampProvider) -> None:
651 """Test _fetch_api_track with 2-part ID routes through get_track."""
652 # split_id("123-789") returns (123, 789, 0); since track_id=0,
653 # the method swaps to album_id=0, track_id=789 and uses get_track.
654 mock_api_track = Mock()
655
656 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
657 mock_get_track.return_value = mock_api_track
658
659 api_track, api_album = await provider._fetch_api_track("123-789")
660
661 mock_get_track.assert_called_once_with(123, 789)
662 assert api_track is mock_api_track
663 assert api_album is None
664
665
666async def test_get_stream_details_standalone_track(provider: BandcampProvider) -> None:
667 """Test stream details for a standalone track (album_id=0)."""
668 mock_api_track = Mock()
669 mock_api_track.streaming_url = {"mp3-128": "http://example.com/standalone.mp3"}
670
671 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
672 mock_get_track.return_value = mock_api_track
673
674 result = await provider.get_stream_details("123-0-789", MediaType.TRACK)
675
676 mock_get_track.assert_called_once_with(123, 789)
677 assert isinstance(result, StreamDetails)
678 assert result.path == "http://example.com/standalone.mp3"
679 assert result.audio_format.content_type == ContentType.MP3
680 assert result.audio_format.bit_rate == 128
681
682
683async def test_get_artist_toptracks_success(provider: BandcampProvider) -> None:
684 """Test successful artist top tracks retrieval."""
685 mock_album = Mock()
686 mock_track = Mock()
687
688 with (
689 patch.object(provider, "get_artist_albums", new_callable=AsyncMock) as mock_get_albums,
690 patch.object(provider, "get_album_tracks", new_callable=AsyncMock) as mock_get_tracks,
691 ):
692 mock_get_albums.return_value = [mock_album]
693 mock_get_tracks.return_value = [mock_track]
694
695 result = await provider.get_artist_toptracks("123")
696
697 assert len(result) == 1
698 mock_get_albums.assert_called_once_with("123")
699
700
701async def test_get_library_artists_success(provider: BandcampProvider) -> None:
702 """Test successful library artists retrieval."""
703 collection_items = [
704 Mock(item_type="band", item_id=100, band_id=100),
705 Mock(item_type="album", item_id=200, band_id=300),
706 ]
707
708 with (
709 patch.object(
710 provider, "_get_all_collection_items", new_callable=AsyncMock
711 ) as mock_get_collection,
712 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
713 ):
714 mock_get_collection.return_value = collection_items
715 mock_get_artist.return_value = Mock()
716
717 artists = [artist async for artist in provider.get_library_artists()]
718
719 assert len(artists) == 2
720 assert mock_get_artist.call_count == 2
721
722
723async def test_get_library_artists_no_identity(provider: BandcampProvider) -> None:
724 """Test that library artists returns nothing without identity."""
725 provider._client.identity = None
726 artists = [artist async for artist in provider.get_library_artists()]
727 assert len(artists) == 0
728
729
730async def test_get_library_albums_success(provider: BandcampProvider) -> None:
731 """Test successful library albums retrieval."""
732 collection_items = [
733 Mock(item_type="album", item_id=456, band_id=123),
734 ]
735
736 with (
737 patch.object(
738 provider, "_get_all_collection_items", new_callable=AsyncMock
739 ) as mock_get_collection,
740 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
741 ):
742 mock_get_collection.return_value = collection_items
743 mock_get_album.return_value = Mock()
744
745 albums = [album async for album in provider.get_library_albums()]
746
747 assert len(albums) == 1
748 mock_get_album.assert_called_once_with("123-456")
749
750
751async def test_get_library_tracks_success(provider: BandcampProvider) -> None:
752 """Test successful library tracks retrieval."""
753 mock_track = Mock()
754
755 with (
756 patch.object(provider, "get_library_albums") as mock_get_albums,
757 patch.object(provider, "get_album_tracks", new_callable=AsyncMock) as mock_get_tracks,
758 ):
759 # Make get_library_albums an async generator
760 async def mock_albums_gen() -> AsyncGenerator[Mock]:
761 yield Mock(item_id="123-456")
762
763 mock_get_albums.return_value = mock_albums_gen()
764 mock_get_tracks.return_value = [mock_track]
765
766 tracks = [track async for track in provider.get_library_tracks()]
767
768 assert len(tracks) == 1
769 mock_get_tracks.assert_called_once_with("123-456")
770
771
772def test_split_id_malformed_non_numeric() -> None:
773 """Test split_id raises InvalidDataError on non-numeric input."""
774 with pytest.raises(InvalidDataError, match=r"Malformed Bandcamp ID"):
775 split_id("abc-def")
776
777
778def test_split_id_malformed_empty() -> None:
779 """Test split_id raises InvalidDataError on empty string."""
780 with pytest.raises(InvalidDataError, match=r"Malformed Bandcamp ID"):
781 split_id("")
782
783
784async def test_fetch_api_track_login_error(provider: BandcampProvider) -> None:
785 """Test _fetch_api_track converts BandcampMustBeLoggedInError to LoginFailed."""
786 with (
787 patch.object(
788 provider._client,
789 "get_album",
790 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
791 ),
792 pytest.raises(LoginFailed, match=r"login is invalid or expired"),
793 ):
794 await provider._fetch_api_track("123-456-789")
795
796
797# --- Feed tests (the feed powers a recommendation row and a browse path) ---
798
799
800async def test_browse_feed_returns_tracks(provider: BandcampProvider) -> None:
801 """Test browsing the feed slug resolves to the feed tracks (the play path for the folder)."""
802 feed_track = Mock()
803 with patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed:
804 mock_feed.return_value = [feed_track]
805
806 result = await provider.browse("bandcamp_test://feed")
807
808 assert result == [feed_track]
809
810
811async def test_get_feed_tracks_filters_non_streamable(provider: BandcampProvider) -> None:
812 """Test _get_feed_tracks skips tracks without a streaming URL and caches the result."""
813 streamable = Mock(streaming_url={"mp3-128": "https://example.com/feed.mp3"})
814 silent = Mock(streaming_url=None)
815 converted = Mock()
816
817 with (
818 patch.object(provider, "_fetch_feed", new_callable=AsyncMock) as mock_fetch,
819 patch.object(
820 provider._converters, "track_from_feed", return_value=converted
821 ) as mock_convert,
822 ):
823 mock_fetch.return_value = Mock(track_list=[streamable, silent])
824
825 result = await provider._get_feed_tracks()
826
827 mock_convert.assert_called_once_with(streamable)
828 assert result == [converted]
829 cast("AsyncMock", provider.mass.cache.set).assert_called_once()
830
831
832async def test_get_feed_tracks_cache_hit(provider: BandcampProvider) -> None:
833 """Test _get_feed_tracks returns cached tracks without hitting the API."""
834 cached = [Mock()]
835
836 with (
837 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached),
838 patch.object(provider, "_fetch_feed", new_callable=AsyncMock) as mock_fetch,
839 ):
840 result = await provider._get_feed_tracks()
841
842 mock_fetch.assert_not_called()
843 assert result == cached
844
845
846# --- Browse tests ---
847
848
849async def test_browse_feature_supported(provider: BandcampProvider) -> None:
850 """Test that BROWSE is in supported features."""
851 assert ProviderFeature.BROWSE in provider.supported_features
852
853
854async def test_browse_root_with_identity(provider: BandcampProvider) -> None:
855 """Test browse root returns standard folders plus Wishlist and Following."""
856 provider._client.identity = "mock_token"
857
858 with patch.object(
859 type(provider).__bases__[0], "browse", new_callable=AsyncMock
860 ) as mock_super_browse:
861 mock_super_browse.return_value = [
862 BrowseFolder(
863 item_id="artists", provider="bandcamp_test", path="bandcamp_test://artists", name=""
864 ),
865 BrowseFolder(
866 item_id="albums", provider="bandcamp_test", path="bandcamp_test://albums", name=""
867 ),
868 ]
869
870 result = await provider.browse("bandcamp_test://")
871
872 assert len(result) == 6
873 folder_ids = [f.item_id for f in result if isinstance(f, BrowseFolder)]
874 assert "wishlist" in folder_ids
875 assert "following" in folder_ids
876 assert "fans" in folder_ids
877 assert "followers" in folder_ids
878
879 wishlist_folder = next(
880 f for f in result if isinstance(f, BrowseFolder) and f.item_id == "wishlist"
881 )
882 assert wishlist_folder.path == "bandcamp_test://wishlist"
883 assert wishlist_folder.name == "Wishlist"
884
885 following_folder = next(
886 f for f in result if isinstance(f, BrowseFolder) and f.item_id == "following"
887 )
888 assert following_folder.path == "bandcamp_test://following"
889 assert following_folder.name == "Following"
890
891
892async def test_browse_root_without_identity(provider: BandcampProvider) -> None:
893 """Test browse root without identity omits Wishlist and Following."""
894 provider._client.identity = None
895
896 with patch.object(
897 type(provider).__bases__[0], "browse", new_callable=AsyncMock
898 ) as mock_super_browse:
899 mock_super_browse.return_value = [
900 BrowseFolder(
901 item_id="artists", provider="bandcamp_test", path="bandcamp_test://artists", name=""
902 ),
903 ]
904
905 result = await provider.browse("bandcamp_test://")
906
907 assert len(result) == 1
908 folder_ids = [f.item_id for f in result if isinstance(f, BrowseFolder)]
909 assert "wishlist" not in folder_ids
910 assert "following" not in folder_ids
911
912
913async def test_browse_standard_subpath_delegates_to_super(provider: BandcampProvider) -> None:
914 """Test that standard subpaths like 'artists' delegate to super().browse()."""
915 with patch.object(
916 type(provider).__bases__[0], "browse", new_callable=AsyncMock
917 ) as mock_super_browse:
918 mock_super_browse.return_value = [Mock(), Mock()]
919
920 result = await provider.browse("bandcamp_test://artists")
921
922 mock_super_browse.assert_called_once_with("bandcamp_test://artists")
923 assert len(result) == 2
924
925
926async def test_browse_wishlist_returns_albums_and_tracks(provider: BandcampProvider) -> None:
927 """Test browsing wishlist returns resolved albums and tracks."""
928 collection_items = [
929 Mock(item_type="album", item_id=456, band_id=123),
930 Mock(item_type="track", item_id=789, band_id=123),
931 ]
932
933 mock_album = Mock()
934 mock_track = Mock()
935
936 with (
937 patch.object(
938 provider, "_get_all_collection_items", new_callable=AsyncMock
939 ) as mock_get_collection,
940 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
941 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
942 ):
943 mock_get_collection.return_value = collection_items
944 mock_get_album.return_value = mock_album
945 mock_get_track.return_value = mock_track
946
947 result = await provider.browse("bandcamp_test://wishlist")
948
949 mock_get_collection.assert_called_once_with(CollectionType.WISHLIST, fan_id=None)
950 mock_get_album.assert_called_once_with("123-456")
951 mock_get_track.assert_called_once_with("123-0-789")
952 assert len(result) == 2
953 assert mock_album in result
954 assert mock_track in result
955
956
957async def test_browse_wishlist_skips_failed_items(provider: BandcampProvider) -> None:
958 """Test that wishlist browse skips items that fail to resolve."""
959 collection_items = [
960 Mock(item_type="album", item_id=456, band_id=123),
961 Mock(item_type="album", item_id=789, band_id=123),
962 ]
963
964 mock_album = Mock()
965
966 with (
967 patch.object(
968 provider, "_get_all_collection_items", new_callable=AsyncMock
969 ) as mock_get_collection,
970 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
971 ):
972 mock_get_collection.return_value = collection_items
973 mock_get_album.side_effect = [mock_album, MediaNotFoundError("not found")]
974
975 result = await provider.browse("bandcamp_test://wishlist")
976
977 assert len(result) == 1
978
979
980async def test_browse_wishlist_login_error(provider: BandcampProvider) -> None:
981 """Test wishlist browse raises LoginFailed on auth error."""
982 with (
983 patch.object(
984 provider,
985 "_get_all_collection_items",
986 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
987 ),
988 pytest.raises(LoginFailed),
989 ):
990 await provider.browse("bandcamp_test://wishlist")
991
992
993async def test_browse_wishlist_rate_limit(provider: BandcampProvider) -> None:
994 """Test wishlist browse raises on rate limit after retries."""
995 rate_error = BandcampRateLimitError("Rate limited")
996 rate_error.retry_after = 3
997
998 with (
999 patch.object(
1000 provider,
1001 "_get_all_collection_items",
1002 side_effect=rate_error,
1003 ),
1004 patch("asyncio.sleep", new_callable=AsyncMock),
1005 pytest.raises(RetriesExhausted),
1006 ):
1007 await provider.browse("bandcamp_test://wishlist")
1008
1009
1010async def test_browse_following_returns_artists(provider: BandcampProvider) -> None:
1011 """Test browsing following returns resolved artists."""
1012 collection_items = [
1013 Mock(spec=["band_id", "name"], band_id=100, name="Artist1"),
1014 Mock(spec=["band_id", "name"], band_id=200, name="Artist2"),
1015 ]
1016
1017 mock_artist_1 = Mock()
1018 mock_artist_2 = Mock()
1019
1020 with (
1021 patch.object(
1022 provider, "_get_all_collection_items", new_callable=AsyncMock
1023 ) as mock_get_collection,
1024 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1025 ):
1026 mock_get_collection.return_value = collection_items
1027 mock_get_artist.side_effect = [mock_artist_1, mock_artist_2]
1028
1029 result = await provider.browse("bandcamp_test://following")
1030
1031 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=None)
1032 assert mock_get_artist.call_count == 2
1033 assert len(result) == 2
1034
1035
1036async def test_browse_following_skips_failed_artists(provider: BandcampProvider) -> None:
1037 """Test that following browse skips artists that fail to resolve."""
1038 collection_items = [
1039 Mock(spec=["band_id", "name"], band_id=100, name="Found"),
1040 Mock(spec=["band_id", "name"], band_id=200, name="NotFound"),
1041 ]
1042
1043 mock_artist = Mock()
1044
1045 with (
1046 patch.object(
1047 provider, "_get_all_collection_items", new_callable=AsyncMock
1048 ) as mock_get_collection,
1049 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1050 ):
1051 mock_get_collection.return_value = collection_items
1052 mock_get_artist.side_effect = [mock_artist, MediaNotFoundError("not found")]
1053
1054 result = await provider.browse("bandcamp_test://following")
1055
1056 assert len(result) == 1
1057
1058
1059async def test_browse_following_login_error(provider: BandcampProvider) -> None:
1060 """Test following browse raises LoginFailed on auth error."""
1061 with (
1062 patch.object(
1063 provider,
1064 "_get_all_collection_items",
1065 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
1066 ),
1067 pytest.raises(LoginFailed),
1068 ):
1069 await provider.browse("bandcamp_test://following")
1070
1071
1072async def test_browse_wishlist_ignores_unknown_item_types(provider: BandcampProvider) -> None:
1073 """Test that wishlist browse ignores items with unknown item_type."""
1074 collection_items = [
1075 Mock(item_type="band", item_id=100, band_id=100),
1076 Mock(item_type="album", item_id=456, band_id=123),
1077 ]
1078
1079 mock_album = Mock()
1080
1081 with (
1082 patch.object(
1083 provider, "_get_all_collection_items", new_callable=AsyncMock
1084 ) as mock_get_collection,
1085 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1086 ):
1087 mock_get_collection.return_value = collection_items
1088 mock_get_album.return_value = mock_album
1089
1090 result = await provider.browse("bandcamp_test://wishlist")
1091
1092 assert len(result) == 1
1093 mock_get_album.assert_called_once_with("123-456")
1094
1095
1096# --- _map_api_errors context manager tests ---
1097
1098
1099async def test_map_api_errors_login_error(provider: BandcampProvider) -> None:
1100 """Test _map_api_errors maps BandcampMustBeLoggedInError to LoginFailed."""
1101 with pytest.raises(LoginFailed, match="Wrong Bandcamp identity token"):
1102 async with provider._map_api_errors("test context"):
1103 raise BandcampMustBeLoggedInError("Must be logged in")
1104
1105
1106async def test_map_api_errors_rate_limit(provider: BandcampProvider) -> None:
1107 """Test _map_api_errors maps BandcampRateLimitError to ResourceTemporarilyUnavailable."""
1108 rate_error = BandcampRateLimitError("Rate limited")
1109 rate_error.retry_after = 5
1110
1111 with pytest.raises(ResourceTemporarilyUnavailable, match="rate limit"):
1112 async with provider._map_api_errors("test context"):
1113 raise rate_error
1114
1115
1116async def test_map_api_errors_generic_api_error(provider: BandcampProvider) -> None:
1117 """Test _map_api_errors maps BandcampAPIError to MediaNotFoundError with context."""
1118 with pytest.raises(MediaNotFoundError, match="my custom context"):
1119 async with provider._map_api_errors("my custom context"):
1120 raise BandcampAPIError("Something went wrong")
1121
1122
1123async def test_map_api_errors_no_exception(provider: BandcampProvider) -> None:
1124 """Test _map_api_errors passes through when no exception is raised."""
1125 async with provider._map_api_errors("test context"):
1126 pass # no exception
1127
1128
1129# --- _browse_person_root tests ---
1130
1131
1132def test_browse_person_root_returns_five_subfolders(provider: BandcampProvider) -> None:
1133 """Test _browse_person_root returns 5 sub-folders for a person."""
1134 folders = provider._browse_person_root(42, "bandcamp_test://fans/42")
1135
1136 assert len(folders) == 5
1137 names = [f.name for f in folders]
1138 assert names == ["Collection", "Wishlist", "Following", "Fans", "Followers"]
1139 for folder in folders:
1140 assert folder.path.startswith("bandcamp_test://fans/42/")
1141 assert folder.item_id.startswith("person_42_")
1142
1143
1144# --- _people_to_folders tests ---
1145
1146
1147def test_people_to_folders_with_images(provider: BandcampProvider) -> None:
1148 """Test _people_to_folders creates folders with thumbnails."""
1149 people = [
1150 _fan_mock(1, "Alice", "http://example.com/alice.jpg", "https://bandcamp.com/alice"),
1151 _fan_mock(2, "Bob", "http://example.com/bob.jpg", "https://bandcamp.com/bob"),
1152 ]
1153
1154 folders = provider._people_to_folders(people, "bandcamp_test://fans")
1155
1156 assert len(folders) == 2
1157 assert folders[0].name == "Alice"
1158 assert folders[0].path == "bandcamp_test://fans/alice"
1159 assert folders[0].image is not None
1160 assert folders[0].image.type == ImageType.THUMB
1161 assert folders[0].image.path == "http://example.com/alice.jpg"
1162 # Verify slugâfan_id mapping was stored
1163 assert provider._slug_to_fan_id["alice"] == 1
1164 assert provider._slug_to_fan_id["bob"] == 2
1165
1166
1167def test_people_to_folders_without_image(provider: BandcampProvider) -> None:
1168 """Test _people_to_folders handles missing image_url."""
1169 people = [_fan_mock(1, "NoPhoto", url="https://bandcamp.com/nophoto")]
1170 folders = provider._people_to_folders(people, "bandcamp_test://fans")
1171
1172 assert len(folders) == 1
1173 assert folders[0].image is None
1174 assert folders[0].path == "bandcamp_test://fans/nophoto"
1175
1176
1177def test_people_to_folders_missing_name(provider: BandcampProvider) -> None:
1178 """Test _people_to_folders falls back to 'User {id}' when name is empty."""
1179 people = [_fan_mock(99, None)]
1180 folders = provider._people_to_folders(people, "bandcamp_test://fans")
1181
1182 assert folders[0].name == "User 99"
1183 # No URL â falls back to numeric fan_id in path
1184 assert folders[0].path == "bandcamp_test://fans/99"
1185
1186
1187# --- _fan_slug unit tests ---
1188
1189
1190def test_fan_slug_extracts_from_url() -> None:
1191 """Test _fan_slug extracts slug from a standard Bandcamp URL."""
1192 person = _fan_mock(1, "Alice", url="https://bandcamp.com/alice")
1193 assert BandcampProvider._fan_slug(person) == "alice"
1194
1195
1196def test_fan_slug_strips_trailing_slash() -> None:
1197 """Test _fan_slug handles trailing slash in URL."""
1198 person = _fan_mock(1, "Alice", url="https://bandcamp.com/alice/")
1199 assert BandcampProvider._fan_slug(person) == "alice"
1200
1201
1202def test_fan_slug_none_url() -> None:
1203 """Test _fan_slug returns None when url is None."""
1204 person = _fan_mock(1, "Alice", url=None)
1205 assert BandcampProvider._fan_slug(person) is None
1206
1207
1208def test_fan_slug_empty_string_url() -> None:
1209 """Test _fan_slug returns None when url is empty string."""
1210 person = _fan_mock(1, "Alice", url="")
1211 assert BandcampProvider._fan_slug(person) is None
1212
1213
1214# --- _resolve_person_segment unit tests ---
1215
1216
1217async def test_resolve_person_segment_slug_hit(provider: BandcampProvider) -> None:
1218 """Test slug cache hit takes priority over numeric parse."""
1219 provider._slug_to_fan_id["42"] = 999 # slug "42" maps to fan 999
1220 assert await provider._resolve_person_segment("42") == 999 # slug wins over int parse
1221
1222
1223async def test_resolve_person_segment_numeric(provider: BandcampProvider) -> None:
1224 """Test numeric segment returns int when no slug match."""
1225 assert await provider._resolve_person_segment("123") == 123
1226
1227
1228async def test_resolve_person_segment_unknown_slug(provider: BandcampProvider) -> None:
1229 """Test unknown non-numeric slug triggers rebuild, returns None if still missing."""
1230 with patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock) as mock_rebuild:
1231 assert await provider._resolve_person_segment("nonexistent") is None
1232 mock_rebuild.assert_called_once()
1233
1234
1235async def test_resolve_person_segment_zero(provider: BandcampProvider) -> None:
1236 """Test zero is returned as valid int (caller validates)."""
1237 assert await provider._resolve_person_segment("0") == 0
1238
1239
1240async def test_resolve_person_segment_rebuild_finds_slug(provider: BandcampProvider) -> None:
1241 """Test unknown slug is resolved after _rebuild_slug_cache populates the map."""
1242
1243 async def fake_rebuild() -> None:
1244 provider._slug_to_fan_id["yerhot"] = 12345
1245
1246 with patch.object(provider, "_rebuild_slug_cache", side_effect=fake_rebuild):
1247 assert await provider._resolve_person_segment("yerhot") == 12345
1248
1249
1250def test_people_to_folders_no_url_falls_back_to_id(provider: BandcampProvider) -> None:
1251 """Test _people_to_folders uses numeric fan_id when url is None."""
1252 people = [_fan_mock(77, "NoUrl")]
1253 folders = provider._people_to_folders(people, "bandcamp_test://fans")
1254
1255 assert folders[0].path == "bandcamp_test://fans/77"
1256 assert "77" not in provider._slug_to_fan_id
1257
1258
1259# --- _browse_person dispatch routing tests ---
1260
1261
1262async def test_browse_fans_top_level(provider: BandcampProvider) -> None:
1263 """Test browsing 'fans' at top level fetches authenticated user's fans."""
1264 collection_items = [_fan_mock(1, "Fan1", url="https://bandcamp.com/fan1")]
1265
1266 with patch.object(
1267 provider, "_get_all_collection_items", new_callable=AsyncMock
1268 ) as mock_get_collection:
1269 mock_get_collection.return_value = collection_items
1270
1271 result = await provider.browse("bandcamp_test://fans")
1272
1273 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=None)
1274 assert len(result) == 1
1275 assert isinstance(result[0], BrowseFolder)
1276 assert result[0].path == "bandcamp_test://fans/fan1"
1277
1278
1279async def test_browse_followers_top_level(provider: BandcampProvider) -> None:
1280 """Test browsing 'followers' at top level fetches authenticated user's followers."""
1281 collection_items = [_fan_mock(2, "Follower1", url="https://bandcamp.com/follower1")]
1282
1283 with patch.object(
1284 provider, "_get_all_collection_items", new_callable=AsyncMock
1285 ) as mock_get_collection:
1286 mock_get_collection.return_value = collection_items
1287
1288 result = await provider.browse("bandcamp_test://followers")
1289
1290 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWERS, fan_id=None)
1291 assert len(result) == 1
1292 assert isinstance(result[0], BrowseFolder)
1293 assert result[0].path == "bandcamp_test://followers/follower1"
1294
1295
1296async def test_browse_fans_person_id_shows_subfolders(provider: BandcampProvider) -> None:
1297 """Test browsing 'fans/42' returns 5 sub-folders for person 42."""
1298 result = await provider.browse("bandcamp_test://fans/42")
1299
1300 assert len(result) == 5
1301 names = [f.name for f in result if isinstance(f, BrowseFolder)]
1302 assert "Collection" in names
1303 assert "Wishlist" in names
1304 assert "Following" in names
1305 assert "Fans" in names
1306 assert "Followers" in names
1307
1308
1309async def test_browse_person_collection(provider: BandcampProvider) -> None:
1310 """Test browsing fans/42/collection fetches person's collection."""
1311 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
1312 mock_album = Mock()
1313
1314 with (
1315 patch.object(
1316 provider, "_get_all_collection_items", new_callable=AsyncMock
1317 ) as mock_get_collection,
1318 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1319 ):
1320 mock_get_collection.return_value = collection_items
1321 mock_get_album.return_value = mock_album
1322
1323 result = await provider.browse("bandcamp_test://fans/42/collection")
1324
1325 mock_get_collection.assert_called_once_with(CollectionType.COLLECTION, fan_id=42)
1326 assert len(result) == 1
1327 assert result[0] is mock_album
1328
1329
1330async def test_browse_person_wishlist(provider: BandcampProvider) -> None:
1331 """Test browsing fans/42/wishlist fetches person's wishlist."""
1332 collection_items = [Mock(item_type="album", item_id=789, band_id=123)]
1333 mock_album = Mock()
1334
1335 with (
1336 patch.object(
1337 provider, "_get_all_collection_items", new_callable=AsyncMock
1338 ) as mock_get_collection,
1339 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1340 ):
1341 mock_get_collection.return_value = collection_items
1342 mock_get_album.return_value = mock_album
1343
1344 result = await provider.browse("bandcamp_test://fans/42/wishlist")
1345
1346 mock_get_collection.assert_called_once_with(CollectionType.WISHLIST, fan_id=42)
1347 assert len(result) == 1
1348 assert result[0] is mock_album
1349
1350
1351async def test_browse_person_following(provider: BandcampProvider) -> None:
1352 """Test browsing fans/42/following fetches person's followed artists."""
1353 collection_items = [Mock(spec=["band_id", "name"], band_id=100, name="Artist1")]
1354 mock_artist = Mock()
1355
1356 with (
1357 patch.object(
1358 provider, "_get_all_collection_items", new_callable=AsyncMock
1359 ) as mock_get_collection,
1360 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1361 ):
1362 mock_get_collection.return_value = collection_items
1363 mock_get_artist.return_value = mock_artist
1364
1365 result = await provider.browse("bandcamp_test://fans/42/following")
1366
1367 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=42)
1368 assert len(result) == 1
1369 assert result[0] is mock_artist
1370
1371
1372async def test_browse_person_fans(provider: BandcampProvider) -> None:
1373 """Test browsing fans/42/fans fetches person 42's fans."""
1374 collection_items = [_fan_mock(99, "SubFan", url="https://bandcamp.com/subfan")]
1375
1376 with patch.object(
1377 provider, "_get_all_collection_items", new_callable=AsyncMock
1378 ) as mock_get_collection:
1379 mock_get_collection.return_value = collection_items
1380
1381 result = await provider.browse("bandcamp_test://fans/42/fans")
1382
1383 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
1384 assert len(result) == 1
1385 assert isinstance(result[0], BrowseFolder)
1386 assert result[0].path == "bandcamp_test://fans/42/fans/subfan"
1387
1388
1389async def test_browse_person_followers(provider: BandcampProvider) -> None:
1390 """Test browsing followers/42/followers fetches person 42's followers."""
1391 collection_items = [_fan_mock(88, "SubFollower", url="https://bandcamp.com/subfollower")]
1392
1393 with patch.object(
1394 provider, "_get_all_collection_items", new_callable=AsyncMock
1395 ) as mock_get_collection:
1396 mock_get_collection.return_value = collection_items
1397
1398 result = await provider.browse("bandcamp_test://followers/42/followers")
1399
1400 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWERS, fan_id=42)
1401 assert len(result) == 1
1402 assert isinstance(result[0], BrowseFolder)
1403 assert result[0].path == "bandcamp_test://followers/42/followers/subfollower"
1404
1405
1406async def test_browse_deep_nesting(provider: BandcampProvider) -> None:
1407 """Test deep social graph traversal: fans/42/fans/99 shows person 99's sub-folders."""
1408 result = await provider.browse("bandcamp_test://fans/42/fans/99")
1409
1410 assert len(result) == 5
1411 # Paths should include the full prefix
1412 for folder in result:
1413 assert isinstance(folder, BrowseFolder)
1414 assert folder.path.startswith("bandcamp_test://fans/42/fans/99/")
1415
1416
1417async def test_browse_deep_nesting_with_slug(provider: BandcampProvider) -> None:
1418 """Test slug-based navigation: fans/alice/fans resolves alice to fan_id."""
1419 # Pre-populate slug mapping (as if we had previously browsed fans)
1420 provider._slug_to_fan_id["alice"] = 42
1421
1422 collection_items = [
1423 _fan_mock(99, "Bob", url="https://bandcamp.com/bob"),
1424 ]
1425
1426 with patch.object(
1427 provider, "_get_all_collection_items", new_callable=AsyncMock
1428 ) as mock_get_collection:
1429 mock_get_collection.return_value = collection_items
1430
1431 result = await provider.browse("bandcamp_test://fans/alice/fans")
1432
1433 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
1434 assert len(result) == 1
1435 assert isinstance(result[0], BrowseFolder)
1436 assert result[0].path == "bandcamp_test://fans/alice/fans/bob"
1437
1438
1439async def test_browse_slug_person_root(provider: BandcampProvider) -> None:
1440 """Test navigating to a slug-identified person shows 5 sub-folders."""
1441 provider._slug_to_fan_id["teancom"] = 42
1442
1443 result = await provider.browse("bandcamp_test://fans/teancom")
1444
1445 assert len(result) == 5
1446 for folder in result:
1447 assert isinstance(folder, BrowseFolder)
1448 assert folder.path.startswith("bandcamp_test://fans/teancom/")
1449
1450
1451async def test_browse_person_invalid_id_zero(provider: BandcampProvider) -> None:
1452 """Test that person ID of 0 raises InvalidDataError."""
1453 with pytest.raises(InvalidDataError, match="Invalid person ID"):
1454 await provider.browse("bandcamp_test://fans/0")
1455
1456
1457async def test_browse_person_invalid_id_negative(provider: BandcampProvider) -> None:
1458 """Test that negative person ID raises InvalidDataError."""
1459 with pytest.raises(InvalidDataError, match="Invalid person ID"):
1460 await provider.browse("bandcamp_test://fans/-1")
1461
1462
1463async def test_browse_person_unknown_subcategory(provider: BandcampProvider) -> None:
1464 """Test that an unknown sub-category raises InvalidDataError."""
1465 with (
1466 patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock),
1467 pytest.raises(InvalidDataError, match="Unknown browse sub-category"),
1468 ):
1469 await provider.browse("bandcamp_test://fans/42/playlists")
1470
1471
1472async def test_browse_person_invalid_path_no_id(provider: BandcampProvider) -> None:
1473 """Test that sub-category without valid person ID raises InvalidDataError."""
1474 with (
1475 patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock),
1476 pytest.raises(InvalidDataError, match="Invalid browse path"),
1477 ):
1478 await provider.browse("bandcamp_test://fans/abc/collection")
1479
1480
1481# --- _browse_person_content with explicit person_id ---
1482
1483
1484async def test_browse_person_content_with_person_id(provider: BandcampProvider) -> None:
1485 """Test _browse_person_content with explicit person_id passes fan_id."""
1486 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
1487 mock_album = Mock()
1488
1489 with (
1490 patch.object(
1491 provider, "_get_all_collection_items", new_callable=AsyncMock
1492 ) as mock_get_collection,
1493 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1494 ):
1495 mock_get_collection.return_value = collection_items
1496 mock_get_album.return_value = mock_album
1497
1498 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
1499
1500 mock_get_collection.assert_called_once_with(CollectionType.COLLECTION, fan_id=42)
1501 assert len(result) == 1
1502 assert result[0] is mock_album
1503
1504
1505async def test_browse_person_content_caches_results(provider: BandcampProvider) -> None:
1506 """Test _browse_person_content caches non-empty results."""
1507 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
1508 mock_album = Mock()
1509
1510 with (
1511 patch.object(
1512 provider, "_get_all_collection_items", new_callable=AsyncMock
1513 ) as mock_get_collection,
1514 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1515 ):
1516 mock_get_collection.return_value = collection_items
1517 mock_get_album.return_value = mock_album
1518
1519 await provider._browse_person_content(42, CollectionType.WISHLIST)
1520
1521 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
1522 mock_cache_set.assert_called_once()
1523 cache_key = mock_cache_set.call_args[0][0]
1524 assert "42" in cache_key
1525 assert CollectionType.WISHLIST.value in cache_key
1526
1527
1528async def test_browse_person_content_cache_hit(provider: BandcampProvider) -> None:
1529 """Test _browse_person_content returns cached result without hitting API."""
1530 cached_items = [
1531 Album(
1532 item_id="1-100",
1533 provider="bandcamp",
1534 name="Cached Album",
1535 provider_mappings=set(),
1536 ).to_dict(),
1537 Track(
1538 item_id="1-100-200",
1539 provider="bandcamp",
1540 name="Cached Track",
1541 provider_mappings=set(),
1542 ).to_dict(),
1543 ]
1544
1545 with (
1546 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached_items),
1547 patch.object(
1548 provider, "_get_all_collection_items", new_callable=AsyncMock
1549 ) as mock_get_collection,
1550 ):
1551 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
1552
1553 mock_get_collection.assert_not_called()
1554 assert len(result) == 2
1555 assert isinstance(result[0], Album)
1556 assert isinstance(result[1], Track)
1557 assert result[0].name == "Cached Album"
1558 assert result[1].name == "Cached Track"
1559
1560
1561async def test_browse_person_content_cache_hit_stale_data(
1562 provider: BandcampProvider, caplog: pytest.LogCaptureFixture
1563) -> None:
1564 """Test _browse_person_content falls through to API on stale/corrupt cache."""
1565 stale_cache = [{"garbage": True}]
1566
1567 with (
1568 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=stale_cache),
1569 patch.object(
1570 provider, "_get_all_collection_items", new_callable=AsyncMock
1571 ) as mock_get_collection,
1572 ):
1573 mock_get_collection.return_value = []
1574 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
1575
1576 mock_get_collection.assert_called_once()
1577 assert result == []
1578 assert "Stale cache" in caplog.text
1579
1580
1581async def test_browse_person_content_empty_cached_with_short_ttl(
1582 provider: BandcampProvider,
1583) -> None:
1584 """Test empty results are cached with CACHE_EMPTY_RESULTS TTL."""
1585 with patch.object(
1586 provider, "_get_all_collection_items", new_callable=AsyncMock
1587 ) as mock_get_collection:
1588 mock_get_collection.return_value = []
1589
1590 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
1591
1592 assert result == []
1593 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
1594 mock_cache_set.assert_called_once()
1595 assert mock_cache_set.call_args.kwargs["expiration"] == CACHE_EMPTY_RESULTS
1596
1597
1598async def test_browse_person_content_nonempty_cached_with_normal_ttl(
1599 provider: BandcampProvider,
1600) -> None:
1601 """Test non-empty results are cached with CACHE_USER_LISTS TTL."""
1602 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
1603 mock_album = Mock()
1604
1605 with (
1606 patch.object(
1607 provider, "_get_all_collection_items", new_callable=AsyncMock
1608 ) as mock_get_collection,
1609 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1610 ):
1611 mock_get_collection.return_value = collection_items
1612 mock_get_album.return_value = mock_album
1613
1614 await provider._browse_person_content(42, CollectionType.COLLECTION)
1615
1616 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
1617 mock_cache_set.assert_called_once()
1618 assert mock_cache_set.call_args.kwargs["expiration"] == CACHE_USER_LISTS
1619
1620
1621async def test_browse_person_content_api_error(provider: BandcampProvider) -> None:
1622 """Test _browse_person_content maps generic API error via _map_api_errors."""
1623 with (
1624 patch.object(
1625 provider,
1626 "_get_all_collection_items",
1627 side_effect=BandcampAPIError("API Error"),
1628 ),
1629 pytest.raises(MediaNotFoundError, match="Failed to get"),
1630 ):
1631 await provider._browse_person_content(42, CollectionType.COLLECTION)
1632
1633
1634# --- _browse_person_following with explicit person_id ---
1635
1636
1637async def test_browse_person_following_with_person_id(provider: BandcampProvider) -> None:
1638 """Test _browse_person_following with explicit person_id."""
1639 collection_items = [Mock(spec=["band_id", "name"], band_id=100, name="Artist1")]
1640 mock_artist = Mock()
1641
1642 with (
1643 patch.object(
1644 provider, "_get_all_collection_items", new_callable=AsyncMock
1645 ) as mock_get_collection,
1646 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1647 ):
1648 mock_get_collection.return_value = collection_items
1649 mock_get_artist.return_value = mock_artist
1650
1651 result = await provider._browse_person_following(42)
1652
1653 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=42)
1654 assert len(result) == 1
1655 assert result[0] is mock_artist
1656
1657
1658async def test_browse_person_following_cache_hit(provider: BandcampProvider) -> None:
1659 """Test _browse_person_following returns cached Artist objects without calling API."""
1660 cached_artists = [
1661 Artist(
1662 item_id="100",
1663 provider="bandcamp",
1664 name="Cached Artist",
1665 provider_mappings=set(),
1666 ),
1667 ]
1668
1669 with (
1670 patch.object(
1671 provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached_artists
1672 ),
1673 patch.object(
1674 provider, "_get_all_collection_items", new_callable=AsyncMock
1675 ) as mock_get_collection,
1676 ):
1677 result = await provider._browse_person_following(42)
1678
1679 mock_get_collection.assert_not_called()
1680 assert len(result) == 1
1681 assert isinstance(result[0], Artist)
1682 assert result[0].name == "Cached Artist"
1683
1684
1685async def test_browse_person_following_skips_not_found(provider: BandcampProvider) -> None:
1686 """Test _browse_person_following logs warning and skips unfound artists."""
1687 collection_items = [
1688 Mock(spec=["band_id", "name"], band_id=100, name="Found"),
1689 Mock(spec=["band_id", "name"], band_id=200, name="NotFound"),
1690 ]
1691 mock_artist = Mock()
1692
1693 with (
1694 patch.object(
1695 provider, "_get_all_collection_items", new_callable=AsyncMock
1696 ) as mock_get_collection,
1697 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1698 ):
1699 mock_get_collection.return_value = collection_items
1700 mock_get_artist.side_effect = [mock_artist, MediaNotFoundError("not found")]
1701
1702 result = await provider._browse_person_following(42)
1703
1704 assert len(result) == 1
1705
1706
1707# --- _browse_person_people tests ---
1708
1709
1710async def test_browse_person_people_with_person_id(provider: BandcampProvider) -> None:
1711 """Test _browse_person_people with explicit person_id fetches their fans."""
1712 collection_items = [_fan_mock(10, "Fan", "http://img.jpg", "https://bandcamp.com/thefan")]
1713
1714 with patch.object(
1715 provider, "_get_all_collection_items", new_callable=AsyncMock
1716 ) as mock_get_collection:
1717 mock_get_collection.return_value = collection_items
1718
1719 result = await provider._browse_person_people(
1720 CollectionType.FOLLOWING_FANS, "bandcamp_test://fans/42/fans", person_id=42
1721 )
1722
1723 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
1724 assert len(result) == 1
1725 assert isinstance(result[0], BrowseFolder)
1726 assert result[0].path == "bandcamp_test://fans/42/fans/thefan"
1727
1728
1729async def test_browse_person_people_api_error(provider: BandcampProvider) -> None:
1730 """Test _browse_person_people maps API error via _map_api_errors."""
1731 with (
1732 patch.object(
1733 provider,
1734 "_get_all_collection_items",
1735 side_effect=BandcampAPIError("API Error"),
1736 ),
1737 pytest.raises(MediaNotFoundError, match="Failed to get"),
1738 ):
1739 await provider._browse_person_people(
1740 CollectionType.FOLLOWERS, "bandcamp_test://followers", person_id=42
1741 )
1742
1743
1744async def test_browse_person_people_cache_hit_rebuilds_slugs(
1745 provider: BandcampProvider,
1746) -> None:
1747 """Test _browse_person_people deserializes cached dicts and repopulates slugs."""
1748 cached_folders = [
1749 BrowseFolder(
1750 item_id="person_111",
1751 provider="bandcamp_test",
1752 path="bandcamp_test://fans/coolslug",
1753 name="Cool User",
1754 ),
1755 BrowseFolder(
1756 item_id="person_222",
1757 provider="bandcamp_test",
1758 path="bandcamp_test://fans/anotherslug",
1759 name="Another User",
1760 ),
1761 ]
1762
1763 async def fake_cache_get(key: str, **kwargs: object) -> list[BrowseFolder] | None: # noqa: ARG001
1764 if "_browse_person_people_" in key:
1765 return cached_folders
1766 return None
1767
1768 provider._slug_to_fan_id.clear()
1769
1770 with patch.object(provider.mass.cache, "get", new_callable=AsyncMock) as mock_cache_get:
1771 mock_cache_get.side_effect = fake_cache_get
1772
1773 result = await provider._browse_person_people(
1774 CollectionType.FOLLOWING_FANS, "bandcamp_test://fans"
1775 )
1776
1777 assert len(result) == 2
1778 assert all(isinstance(f, BrowseFolder) for f in result)
1779 assert result[0].name == "Cool User"
1780 assert result[1].name == "Another User"
1781 assert provider._slug_to_fan_id["coolslug"] == 111
1782 assert provider._slug_to_fan_id["anotherslug"] == 222
1783
1784
1785async def test_rebuild_slug_cache_calls_browse_person_people(
1786 provider: BandcampProvider,
1787) -> None:
1788 """Test _rebuild_slug_cache fetches both fans and followers lists."""
1789 with patch.object(provider, "_browse_person_people", new_callable=AsyncMock) as mock_browse:
1790 mock_browse.return_value = []
1791
1792 await provider._rebuild_slug_cache()
1793
1794 assert mock_browse.call_count == 2
1795 calls = mock_browse.call_args_list
1796 assert calls[0].args == (CollectionType.FOLLOWING_FANS, "bandcamp_test://fans")
1797 assert calls[1].args == (CollectionType.FOLLOWERS, "bandcamp_test://followers")
1798
1799
1800# --- Pagination tests ---
1801
1802
1803def _make_collection_page(
1804 items: list[Mock],
1805 has_more: bool = False,
1806 last_token: str | None = None,
1807) -> Mock:
1808 """Create a mock CollectionSummary page."""
1809 page = Mock()
1810 page.items = items
1811 page.has_more = has_more
1812 page.last_token = last_token
1813 return page
1814
1815
1816async def test_get_all_collection_items_single_page(provider: BandcampProvider) -> None:
1817 """Test _get_all_collection_items with a single page."""
1818 page = _make_collection_page(
1819 [Mock(item_type="album", item_id=1, band_id=10)],
1820 has_more=False,
1821 )
1822 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
1823 mock_get.return_value = page
1824
1825 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
1826
1827 assert len(result) == 1
1828 mock_get.assert_called_once()
1829
1830
1831async def test_get_all_collection_items_multiple_pages(provider: BandcampProvider) -> None:
1832 """Test _get_all_collection_items follows pagination across multiple pages."""
1833 page1 = _make_collection_page(
1834 [Mock(item_type="album", item_id=i, band_id=10) for i in range(50)],
1835 has_more=True,
1836 last_token="token_page2",
1837 )
1838 page2 = _make_collection_page(
1839 [Mock(item_type="album", item_id=i, band_id=10) for i in range(50, 100)],
1840 has_more=True,
1841 last_token="token_page3",
1842 )
1843 page3 = _make_collection_page(
1844 [Mock(item_type="album", item_id=i, band_id=10) for i in range(100, 120)],
1845 has_more=False,
1846 )
1847
1848 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
1849 mock_get.side_effect = [page1, page2, page3]
1850
1851 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
1852
1853 assert len(result) == 120
1854 assert mock_get.call_count == 3
1855 # First call has no older_than_token (positional arg index 1)
1856 assert mock_get.call_args_list[0].args[1] is None
1857 # Subsequent calls pass the last_token from the previous page
1858 assert mock_get.call_args_list[1].args[1] == "token_page2"
1859 assert mock_get.call_args_list[2].args[1] == "token_page3"
1860
1861
1862async def test_get_all_collection_items_stops_on_missing_last_token(
1863 provider: BandcampProvider,
1864) -> None:
1865 """Test _get_all_collection_items stops when has_more is True but last_token is None."""
1866 page = _make_collection_page(
1867 [Mock(item_type="album", item_id=1, band_id=10)],
1868 has_more=True,
1869 last_token=None,
1870 )
1871 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
1872 mock_get.return_value = page
1873
1874 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
1875
1876 assert len(result) == 1
1877 mock_get.assert_called_once()
1878
1879
1880async def test_get_all_collection_items_passes_fan_id(provider: BandcampProvider) -> None:
1881 """Test _get_all_collection_items forwards fan_id to the API client."""
1882 page = _make_collection_page([], has_more=False)
1883 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
1884 mock_get.return_value = page
1885
1886 await provider._get_all_collection_items(CollectionType.WISHLIST, fan_id=42)
1887
1888 mock_get.assert_called_once_with(CollectionType.WISHLIST, None, 42)
1889
1890
1891async def test_get_library_albums_paginates(provider: BandcampProvider) -> None:
1892 """Test get_library_albums yields albums from all pages."""
1893 page1 = _make_collection_page(
1894 [Mock(item_type="album", item_id=i, band_id=10) for i in range(1, 4)],
1895 has_more=True,
1896 last_token="tok2",
1897 )
1898 page2 = _make_collection_page(
1899 [Mock(item_type="album", item_id=i, band_id=10) for i in range(4, 6)],
1900 has_more=False,
1901 )
1902
1903 with (
1904 patch.object(provider._client, "get_collection_items", new_callable=AsyncMock) as mock_get,
1905 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1906 ):
1907 mock_get.side_effect = [page1, page2]
1908 mock_get_album.return_value = Mock()
1909
1910 albums = [album async for album in provider.get_library_albums()]
1911
1912 assert len(albums) == 5
1913 assert mock_get.call_count == 2
1914
1915
1916async def test_get_library_artists_paginates(provider: BandcampProvider) -> None:
1917 """Test get_library_artists yields artists from all pages."""
1918 page1 = _make_collection_page(
1919 [Mock(item_type="album", item_id=1, band_id=100)],
1920 has_more=True,
1921 last_token="tok2",
1922 )
1923 page2 = _make_collection_page(
1924 [Mock(item_type="album", item_id=2, band_id=200)],
1925 has_more=False,
1926 )
1927
1928 with (
1929 patch.object(provider._client, "get_collection_items", new_callable=AsyncMock) as mock_get,
1930 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1931 ):
1932 mock_get.side_effect = [page1, page2]
1933 mock_get_artist.return_value = Mock()
1934
1935 artists = [artist async for artist in provider.get_library_artists()]
1936
1937 assert len(artists) == 2
1938 assert mock_get.call_count == 2
1939 # Band IDs 100 and 200 from pages 1 and 2, converted to str per base class contract
1940 called_ids = {call.args[0] for call in mock_get_artist.call_args_list}
1941 assert called_ids == {"100", "200"}
1942
1943
1944async def test_get_all_collection_items_error_mid_pagination(
1945 provider: BandcampProvider,
1946) -> None:
1947 """Test that an API error on page 2 propagates without returning partial results."""
1948 page1 = _make_collection_page(
1949 [Mock(item_type="album", item_id=1, band_id=10)],
1950 has_more=True,
1951 last_token="token_page2",
1952 )
1953
1954 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
1955 mock_get.side_effect = [page1, BandcampRateLimitError(retry_after=3)]
1956
1957 with pytest.raises(BandcampRateLimitError):
1958 await provider._get_all_collection_items(CollectionType.COLLECTION)
1959
1960 assert mock_get.call_count == 2
1961
1962
1963async def test_browse_person_content_returns_only_resolved_items(
1964 provider: BandcampProvider,
1965) -> None:
1966 """
1967 Test that _browse_person_content returns only resolved Album/Track objects.
1968
1969 Regression test: a previous version reused the same list variable for both
1970 the raw API items and the resolved results, which mixed CollectionItem
1971 objects into the returned list.
1972 """
1973 raw_items = [
1974 Mock(item_type="album", item_id=456, band_id=123),
1975 Mock(item_type="track", item_id=789, band_id=123),
1976 ]
1977 mock_album = Mock(spec=Album)
1978 mock_track = Mock(spec=Track)
1979
1980 with (
1981 patch.object(
1982 provider, "_get_all_collection_items", new_callable=AsyncMock
1983 ) as mock_get_collection,
1984 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1985 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
1986 ):
1987 mock_get_collection.return_value = raw_items
1988 mock_get_album.return_value = mock_album
1989 mock_get_track.return_value = mock_track
1990
1991 result = await provider._browse_person_content(42, CollectionType.WISHLIST)
1992
1993 assert len(result) == 2
1994 assert result[0] is mock_album
1995 assert result[1] is mock_track
1996 # Verify no raw CollectionItem objects leaked into the result
1997 for item in result:
1998 assert item is not raw_items[0]
1999 assert item is not raw_items[1]
2000
2001
2002async def test_get_all_collection_items_detects_token_loop(
2003 provider: BandcampProvider,
2004 caplog: pytest.LogCaptureFixture,
2005) -> None:
2006 """Test _get_all_collection_items stops when the same token repeats."""
2007 stuck_page = _make_collection_page(
2008 [Mock(item_type="album", item_id=1, band_id=10)],
2009 has_more=True,
2010 last_token="same_token_forever",
2011 )
2012
2013 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2014 mock_get.return_value = stuck_page
2015
2016 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
2017
2018 # Should fetch page 1 (token not yet seen), then page 2 (token repeated â stop)
2019 assert mock_get.call_count == 2
2020 assert len(result) == 2
2021 assert "Pagination loop detected" in caplog.text
2022