/
/
/
1"""Test Bandcamp Provider integration."""
2
3import asyncio
4from collections.abc import AsyncGenerator
5from typing import cast
6from unittest.mock import AsyncMock, Mock, call, patch
7
8import pytest
9from bandcamp_async_api import (
10 BandcampAPIError,
11 BandcampMustBeLoggedInError,
12 BandcampNotFoundError,
13 BandcampRateLimitError,
14 SearchResultAlbum,
15 SearchResultArtist,
16 SearchResultTrack,
17)
18from bandcamp_async_api.models import CollectionType
19from music_assistant_models.enums import (
20 ContentType,
21 ImageType,
22 MediaType,
23 ProviderFeature,
24 StreamType,
25)
26from music_assistant_models.errors import (
27 InvalidDataError,
28 LoginFailed,
29 MediaNotFoundError,
30 RateLimited,
31 ResourceTemporarilyUnavailable,
32 RetriesExhausted,
33)
34from music_assistant_models.media_items import Album, Artist, BrowseFolder, Track
35from music_assistant_models.streamdetails import StreamDetails
36
37from music_assistant.helpers.throttle_retry import ThrottlerManager
38from music_assistant.providers.bandcamp import BandcampProvider, split_id
39from music_assistant.providers.bandcamp.constants import (
40 CACHE_EMPTY_RESULTS,
41 CACHE_USER_LISTS,
42 DEFAULT_TOP_TRACKS_LIMIT,
43 SUPPORTED_FEATURES,
44)
45from tests.common import use_real_create_task
46
47
48def _fan_mock(
49 fan_id: int,
50 name: str | None,
51 image_url: str | None = None,
52 url: str | None = None,
53) -> Mock:
54 """Create a mock FanItem with real string attributes for BrowseFolder compatibility."""
55 fan = Mock(spec=["fan_id", "name", "image_url", "url"])
56 fan.fan_id = fan_id
57 fan.name = name
58 fan.image_url = image_url
59 fan.url = url
60 return fan
61
62
63@pytest.fixture
64def mass_mock() -> Mock:
65 """Return a mock MusicAssistant instance."""
66 mass = Mock()
67 mass.http_session = AsyncMock()
68 mass.metadata.locale = "en_US"
69 mass.cache.get = AsyncMock(return_value=None)
70 mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
71 mass.cache.set = AsyncMock()
72 mass.cache.delete = AsyncMock()
73 # setup_data is unset in these unit tests, so get_setup_value falls through to
74 # the provider config's get_value (which the config mock stubs)
75 mass.config.get = Mock(return_value=None)
76 mass.config.get_raw_provider_config_value = Mock(return_value=None)
77 use_real_create_task(mass)
78 return mass
79
80
81@pytest.fixture
82def config_mock() -> Mock:
83 """Return a mock provider config."""
84 config = Mock()
85 config.name = "Bandcamp Test"
86 config.instance_id = "bandcamp_test"
87 config.enabled = True
88 config.values = {}
89 config.get_value.side_effect = lambda key, default=None: {
90 "identity": "mock_identity_token",
91 "search_limit": 10,
92 "top_tracks_limit": 50,
93 "log_level": "INFO",
94 }.get(
95 key,
96 default
97 if default is not None
98 else (10 if key == "search_limit" else (50 if key == "top_tracks_limit" else "INFO")),
99 )
100 return config
101
102
103@pytest.fixture
104async def provider(mass_mock: Mock, manifest_mock: Mock, config_mock: Mock) -> BandcampProvider:
105 """Return a BandcampProvider instance."""
106 provider = BandcampProvider(mass_mock, manifest_mock, config_mock, SUPPORTED_FEATURES)
107 provider.throttler = ThrottlerManager(
108 rate_limit=provider.throttler.throttler.rate_limit,
109 period=provider.throttler.throttler.period,
110 retry_attempts=provider.throttler.retry_attempts,
111 initial_backoff=provider.throttler.initial_backoff,
112 )
113
114 # Initialize the provider
115 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
116 mock_client = AsyncMock()
117 mock_client_class.return_value = mock_client
118 await provider.handle_async_init()
119
120 return provider
121
122
123async def test_provider_initialization(
124 mass_mock: Mock, manifest_mock: Mock, config_mock: Mock
125) -> None:
126 """Test provider initialization."""
127 provider = BandcampProvider(mass_mock, manifest_mock, config_mock)
128
129 assert provider.domain == "bandcamp"
130 assert provider.instance_id == "bandcamp_test"
131
132 # Test that initialization sets the correct values
133 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
134 mock_client = AsyncMock()
135 mock_client_class.return_value = mock_client
136
137 await provider.handle_async_init()
138
139 assert provider.top_tracks_limit == DEFAULT_TOP_TRACKS_LIMIT
140
141
142async def test_handle_async_init_with_identity(provider: BandcampProvider) -> None:
143 """Test successful async initialization with identity token."""
144 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
145 mock_client = AsyncMock()
146 mock_client_class.return_value = mock_client
147
148 await provider.handle_async_init()
149
150 mock_client_class.assert_called_once_with(
151 session=provider.mass.http_session,
152 identity_token="mock_identity_token",
153 default_retry_after=3,
154 )
155 assert provider._client == mock_client
156 assert provider._converters is not None
157
158
159async def test_handle_async_init_without_identity(mass_mock: Mock, manifest_mock: Mock) -> None:
160 """Test async initialization without identity token."""
161 config = Mock()
162 config.values = {}
163 config.get_value.side_effect = lambda key, default=None: (
164 default if default is not None else ("INFO" if key == "log_level" else None)
165 )
166 provider = BandcampProvider(mass_mock, manifest_mock, config)
167
168 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
169 mock_client = AsyncMock()
170 mock_client_class.return_value = mock_client
171
172 await provider.handle_async_init()
173
174 mock_client_class.assert_called_once_with(
175 session=provider.mass.http_session,
176 identity_token=None,
177 default_retry_after=3,
178 )
179
180
181async def test_is_streaming_provider(provider: BandcampProvider) -> None:
182 """Test that Bandcamp is a streaming provider."""
183 assert provider.is_streaming_provider is True
184
185
186def _search_track_mock(
187 *,
188 artist_id: int = 123,
189 artist_name: str = "Test Artist",
190 album_id: int | None = 456,
191 track_id: int = 789,
192) -> Mock:
193 """Construct a SearchResultTrack mock with concrete attribute values."""
194 item = Mock(spec=SearchResultTrack)
195 item.id = track_id
196 item.name = "Track"
197 item.artist_id = artist_id
198 item.artist_name = artist_name
199 item.album_id = album_id
200 item.album_name = "Album"
201 item.url = ""
202 item.image_url = None
203 return item
204
205
206def _search_album_mock(
207 *,
208 artist_id: int = 123,
209 artist_name: str = "Test Artist",
210 album_id: int = 456,
211 artist_url: str = "",
212) -> Mock:
213 """Construct a SearchResultAlbum mock with concrete attribute values."""
214 item = Mock(spec=SearchResultAlbum)
215 item.id = album_id
216 item.name = "Album"
217 item.artist_id = artist_id
218 item.artist_name = artist_name
219 item.artist_url = artist_url
220 item.url = ""
221 item.image_url = None
222 return item
223
224
225def _search_artist_mock(
226 *,
227 artist_id: int = 123,
228 name: str = "Test Artist",
229 is_label: bool = False,
230 url: str = "",
231) -> Mock:
232 """Construct a SearchResultArtist mock with concrete attribute values."""
233 item = Mock(spec=SearchResultArtist)
234 item.id = artist_id
235 item.name = name
236 item.url = url
237 item.image_url = None
238 item.tags = None
239 item.is_label = is_label
240 return item
241
242
243async def test_search_with_identity(provider: BandcampProvider) -> None:
244 """Test search functionality with identity token."""
245 mock_search_results = [
246 _search_track_mock(),
247 _search_album_mock(),
248 _search_artist_mock(),
249 ]
250
251 with (
252 patch.object(provider._client, "search", new_callable=AsyncMock) as mock_search,
253 patch.object(provider._converters, "track_from_search") as mock_track_converter,
254 patch.object(provider._converters, "album_from_search") as mock_album_converter,
255 patch.object(provider._converters, "artist_from_search") as mock_artist_converter,
256 ):
257 mock_search.return_value = mock_search_results
258
259 mock_track_converter.return_value = Mock()
260 mock_album_converter.return_value = Mock()
261 mock_artist_converter.return_value = Mock()
262
263 results = await provider.search(
264 "test query", [MediaType.TRACK, MediaType.ALBUM, MediaType.ARTIST], limit=5
265 )
266
267 mock_search.assert_called_once_with("test query")
268 mock_track_converter.assert_called_once()
269 mock_album_converter.assert_called_once()
270 mock_artist_converter.assert_called_once()
271 assert len(results.tracks) == 1
272 assert len(results.albums) == 1
273 # The album/track results match the artist `b` result so no
274 # synthetic artists are emitted; the count stays at 1.
275 assert len(results.artists) == 1
276
277
278async def test_search_synthesizes_artists_for_label_releases(
279 provider: BandcampProvider,
280) -> None:
281 """
282 Surface a synthetic artist when an album's performer != the page owner.
283
284 A label-released album whose credit doesn't appear as a `b` result
285 becomes its own artist entry under MediaType.ARTIST search.
286 """
287 label_id = 441379041
288 # `b` result for the label, plus two `a` results â one by the label
289 # itself and one by Mortaja (a performer with no band page).
290 label_band = _search_artist_mock(
291 artist_id=label_id, name="audiophob", url="https://audiophob.bandcamp.com"
292 )
293 label_album = _search_album_mock(
294 artist_id=label_id, artist_name="audiophob", album_id=1198969224
295 )
296 mortaja_album = _search_album_mock(
297 artist_id=label_id,
298 artist_name="Mortaja",
299 album_id=1938115920,
300 artist_url="https://audiophob.bandcamp.com",
301 )
302 another_album = _search_album_mock(
303 artist_id=label_id,
304 artist_name="Another Performer",
305 album_id=1938115921,
306 artist_url="https://audiophob.bandcamp.com",
307 )
308
309 with patch.object(provider._client, "search", new_callable=AsyncMock) as mock_search:
310 mock_search.return_value = [label_band, label_album, mortaja_album, another_album]
311
312 results = await provider.search("Mortaja", [MediaType.ALBUM, MediaType.ARTIST], limit=10)
313
314 artist_ids = {a.item_id for a in results.artists}
315 # Real label artist + both synthetic performers from audiophob.
316 assert artist_ids == {
317 str(label_id),
318 f"{label_id}:mortaja",
319 f"{label_id}:another-performer",
320 }
321 synthetic = [cast("Artist", artist) for artist in results.artists if ":" in artist.item_id]
322 assert len(synthetic) == 2
323 assert len({artist.uri for artist in synthetic}) == 2
324 assert "https://audiophob.bandcamp.com" not in {artist.uri for artist in synthetic}
325 assert {next(iter(artist.provider_mappings)).url for artist in synthetic} == {
326 "https://audiophob.bandcamp.com"
327 }
328
329 # The album by the label itself should link to the real ID; the
330 # performer albums should link to their respective synthetic IDs.
331 album_artist_ids = {next(iter(cast("Album", a).artists)).item_id for a in results.albums}
332 assert album_artist_ids == {
333 str(label_id),
334 f"{label_id}:mortaja",
335 f"{label_id}:another-performer",
336 }
337
338
339async def test_search_does_not_duplicate_existing_artists(
340 provider: BandcampProvider,
341) -> None:
342 """
343 Don't duplicate an artist as a synthetic when a real `b` result matches.
344
345 When an album's performer slug equals the band's own slug, the artist
346 link reuses the real `{band_id}` and no synthetic entry is emitted.
347 """
348 band_id = 3658985110
349 real = _search_artist_mock(artist_id=band_id, name="Apollo Brown")
350 own_album = _search_album_mock(artist_id=band_id, artist_name="Apollo Brown", album_id=1)
351
352 with patch.object(provider._client, "search", new_callable=AsyncMock) as mock_search:
353 mock_search.return_value = [real, own_album]
354
355 results = await provider.search("Apollo Brown", [MediaType.ARTIST], limit=10)
356
357 assert [a.item_id for a in results.artists] == [str(band_id)]
358
359
360async def test_search_unifies_label_release_to_real_performer_band(
361 provider: BandcampProvider,
362) -> None:
363 """
364 A label-released album whose performer has their own band page links to the real band.
365
366 Regression test for music-assistant/support#5389 / Apollo Brown on
367 Hip Dozer. When a user searches by album name (e.g. "Night Moves"),
368 Bandcamp's autocomplete returns the album row with ``band_id`` =
369 Hip Dozer (the label) and ``artist_name`` = "Apollo Brown" â but no
370 ``b`` row for Apollo Brown's own page in the same response. The
371 secondary autocomplete lookup for "Apollo Brown" finds the real
372 band, and the album's artist link uses that real ``band_id``
373 instead of a synthetic ``{label_id}:apollo-brown``. This unifies the
374 artist with what direct-search-by-artist would find.
375 """
376 label_id = 4119123456
377 apollo_band_id = 3658985110
378 hipdozer_band = _search_artist_mock(artist_id=label_id, name="Hip Dozer", is_label=True)
379 night_moves = _search_album_mock(artist_id=label_id, artist_name="Apollo Brown", album_id=1)
380
381 # Primary autocomplete for "Night Moves" returns the album + label.
382 # Secondary lookup for "Apollo Brown" returns the real artist `b` row.
383 primary_response = [hipdozer_band, night_moves]
384 secondary_response = [_search_artist_mock(artist_id=apollo_band_id, name="Apollo Brown")]
385
386 apollo_real_artist = Mock(spec=Artist)
387 apollo_real_artist.item_id = str(apollo_band_id)
388
389 async def fake_search(query: str) -> list[Mock]:
390 if query == "Apollo Brown":
391 return secondary_response
392 return primary_response
393
394 with (
395 patch.object(provider._client, "search", side_effect=fake_search) as mock_search,
396 patch.object(
397 provider, "get_artist", new_callable=AsyncMock, return_value=apollo_real_artist
398 ) as mock_get_artist,
399 ):
400 results = await provider.search(
401 "Night Moves", [MediaType.ALBUM, MediaType.ARTIST], limit=10
402 )
403
404 # The album's artist link points to Apollo Brown's real band_id,
405 # NOT a synthetic `{label_id}:apollo-brown`.
406 album_artist_ids = {next(iter(cast("Album", a).artists)).item_id for a in results.albums}
407 assert album_artist_ids == {str(apollo_band_id)}
408
409 # The artist results include Apollo Brown materialized via get_artist.
410 artist_ids = {a.item_id for a in results.artists}
411 assert str(apollo_band_id) in artist_ids
412 # No synthetic artist for a performer who has their own band page.
413 assert not any(":" in a.item_id for a in results.artists)
414 # Hip Dozer (the label) is still surfaced â it was a `b` row in
415 # the primary response and matches MediaType.ARTIST directly.
416 assert str(label_id) in artist_ids
417
418 # Two autocomplete calls: the primary search + one secondary
419 # lookup for "Apollo Brown". (Hip Dozer didn't need a lookup â
420 # it was already the page owner.)
421 assert mock_search.call_count == 2
422 mock_get_artist.assert_awaited_once_with(str(apollo_band_id))
423
424
425async def test_lookup_performer_band_id_caches_negative_result(
426 provider: BandcampProvider, mass_mock: Mock
427) -> None:
428 """
429 A performer with no own band page caches the 'not found' outcome.
430
431 The cache layer treats ``None`` as a miss, so we store integer 0 as
432 the negative-cache sentinel. A repeated lookup for the same slug
433 must NOT trigger a second autocomplete call.
434 """
435 cache_data: dict[str, int] = {}
436
437 async def fake_get(key: str, **_: object) -> int | None:
438 return cache_data.get(key)
439
440 async def fake_set(key: str, value: int, **_: object) -> None:
441 cache_data[key] = value
442
443 mass_mock.cache.get.side_effect = fake_get
444 mass_mock.cache.set.side_effect = fake_set
445
446 # Secondary search returns no matching b-row for "Mortaja" â the
447 # response contains an unrelated label.
448 unrelated = _search_artist_mock(artist_id=441379041, name="audiophob", is_label=True)
449 with patch.object(
450 provider._client, "search", new_callable=AsyncMock, return_value=[unrelated]
451 ) as mock_search:
452 first = await provider._lookup_performer_band_id("Mortaja")
453 second = await provider._lookup_performer_band_id("Mortaja")
454
455 assert first is None
456 assert second is None
457 # The negative result is cached; the upstream search runs once.
458 assert mock_search.call_count == 1
459 # Sentinel 0 was written for the slug.
460 assert cache_data["performer_band_id.mortaja"] == 0
461
462
463async def test_lookup_performer_band_id_does_not_cache_api_errors(
464 provider: BandcampProvider, mass_mock: Mock
465) -> None:
466 """Transient autocomplete failures must not become negative cache entries."""
467 with (
468 patch.object(
469 provider._client,
470 "search",
471 new_callable=AsyncMock,
472 side_effect=BandcampAPIError("temporary failure"),
473 ),
474 pytest.raises(BandcampAPIError, match="temporary failure"),
475 ):
476 await provider._lookup_performer_band_id("Mortaja")
477
478 mass_mock.cache.set.assert_not_awaited()
479
480
481async def test_lookup_performer_band_id_skips_label_results(
482 provider: BandcampProvider,
483) -> None:
484 """
485 A label that shares a performer's exact name must not be returned as a band.
486
487 Without the ``is_label`` filter, a label called "Apollo Brown"
488 (rare but possible) would shadow the actual artist's band page.
489 """
490 label_with_same_name = _search_artist_mock(artist_id=999, name="Apollo Brown", is_label=True)
491 real_artist = _search_artist_mock(artist_id=3658985110, name="Apollo Brown")
492
493 # Order matters: even when the label appears first, we skip it and
494 # return the non-label match.
495 with patch.object(
496 provider._client,
497 "search",
498 new_callable=AsyncMock,
499 return_value=[label_with_same_name, real_artist],
500 ):
501 result = await provider._lookup_performer_band_id("Apollo Brown")
502 assert result == 3658985110
503
504
505async def test_search_resilient_to_lookup_exception_in_one_slug(
506 provider: BandcampProvider,
507) -> None:
508 """
509 One slug's lookup raising must not kill the whole batch.
510
511 ``_resolve_search_artist_ids`` runs lookups in parallel via
512 ``asyncio.gather(..., return_exceptions=True)``; an unexpected
513 exception in any single lookup must degrade that slug to "no own
514 band page" (synthetic fallback) rather than abort the entire search.
515 """
516 label_a_id = 1111111
517 label_b_id = 2222222
518 real_b_band_id = 3658985110
519 label_a = _search_artist_mock(artist_id=label_a_id, name="Label A", is_label=True)
520 label_b = _search_artist_mock(artist_id=label_b_id, name="Label B", is_label=True)
521 album_a = _search_album_mock(artist_id=label_a_id, artist_name="Performer A", album_id=10)
522 album_b = _search_album_mock(artist_id=label_b_id, artist_name="Performer B", album_id=20)
523
524 async def fake_lookup(name: str) -> int | None:
525 if name == "Performer A":
526 raise RuntimeError("boom")
527 if name == "Performer B":
528 return real_b_band_id
529 return None
530
531 with (
532 patch.object(
533 provider._client,
534 "search",
535 new_callable=AsyncMock,
536 return_value=[label_a, label_b, album_a, album_b],
537 ),
538 patch.object(provider, "_lookup_performer_band_id", side_effect=fake_lookup),
539 patch.object(
540 provider, "get_artist", new_callable=AsyncMock, return_value=Mock(spec=Artist)
541 ),
542 ):
543 results = await provider.search("test", [MediaType.ALBUM], limit=20)
544
545 # Performer A's lookup blew up â falls through to synthetic.
546 # Performer B's lookup succeeded â uses the real band_id.
547 album_artist_ids = {next(iter(cast("Album", a).artists)).item_id for a in results.albums}
548 assert f"{label_a_id}:performer-a" in album_artist_ids
549 assert str(real_b_band_id) in album_artist_ids
550
551
552async def test_lookup_performer_band_id_corrupt_cache_falls_through(
553 provider: BandcampProvider, mass_mock: Mock
554) -> None:
555 """
556 A non-integer cache payload is discarded and a fresh fetch runs.
557
558 Defensive guard: if the cache returns a value that can't be coerced
559 to int (schema change, manual edit, â¦), the lookup must NOT raise
560 ``ValueError`` into the parallel ``asyncio.gather`` â it must log,
561 discard the entry, and re-fetch from the upstream API.
562 """
563 mass_mock.cache.get.return_value = "not-an-int"
564
565 real_band_id = 3658985110
566 real_artist = _search_artist_mock(artist_id=real_band_id, name="Apollo Brown")
567 with patch.object(
568 provider._client, "search", new_callable=AsyncMock, return_value=[real_artist]
569 ) as mock_search:
570 result = await provider._lookup_performer_band_id("Apollo Brown")
571
572 assert result == real_band_id
573 mock_search.assert_awaited_once_with("Apollo Brown")
574
575
576async def test_search_suppresses_retries_exhausted_from_get_artist(
577 provider: BandcampProvider,
578) -> None:
579 """
580 ``RetriesExhausted`` from ``get_artist`` must not crash an in-progress search.
581
582 The materialized-artist emission is best-effort â its failure should
583 leave album/track results intact rather than propagate up.
584 """
585 label_id = 4119123456
586 apollo_band_id = 3658985110
587 label_band = _search_artist_mock(artist_id=label_id, name="Hip Dozer", is_label=True)
588 night_moves = _search_album_mock(artist_id=label_id, artist_name="Apollo Brown", album_id=1)
589 secondary_response = [_search_artist_mock(artist_id=apollo_band_id, name="Apollo Brown")]
590
591 async def fake_search(query: str) -> list[Mock]:
592 if query == "Apollo Brown":
593 return secondary_response
594 return [label_band, night_moves]
595
596 with (
597 patch.object(provider._client, "search", side_effect=fake_search),
598 patch.object(
599 provider,
600 "get_artist",
601 new_callable=AsyncMock,
602 side_effect=RetriesExhausted("throttle exhausted"),
603 ),
604 ):
605 results = await provider.search("Night Moves", [MediaType.ALBUM, MediaType.ARTIST])
606
607 # Album result still produced despite the artist-materialization failure.
608 assert len(results.albums) == 1
609 # The artist-materialization branch was suppressed â no Apollo Brown
610 # in artists, but Hip Dozer (the in-batch `b` row) is still surfaced.
611 artist_ids = {a.item_id for a in results.artists}
612 assert str(label_id) in artist_ids
613 assert str(apollo_band_id) not in artist_ids
614
615
616async def test_get_album_unifies_label_release_to_real_performer_band(
617 provider: BandcampProvider,
618) -> None:
619 """
620 Fetching a label-released album embeds the performer's real band_id.
621
622 Regression test paralleling
623 :func:`test_search_unifies_label_release_to_real_performer_band` but
624 on the album-fetch path. When MA opens or syncs a label-released
625 album, the artist link must point to the performer's own band page
626 rather than a synthetic ID â otherwise list view and detail view
627 diverge.
628 """
629 label_id = 4119123456
630 apollo_band_id = 3658985110
631
632 api_album = Mock()
633 api_album.artist.id = label_id
634 api_album.artist.name = "Hip Dozer"
635 api_album.tralbum_artist = "Apollo Brown"
636
637 secondary_response = [_search_artist_mock(artist_id=apollo_band_id, name="Apollo Brown")]
638
639 with (
640 patch.object(provider._client, "get_album", new_callable=AsyncMock, return_value=api_album),
641 patch.object(
642 provider._client, "search", new_callable=AsyncMock, return_value=secondary_response
643 ) as mock_search,
644 patch.object(provider._converters, "album_from_api") as mock_converter,
645 ):
646 mock_converter.return_value = Mock()
647 await provider.get_album(f"{label_id}-456")
648
649 mock_converter.assert_called_once_with(api_album, artist_item_id=str(apollo_band_id))
650 mock_search.assert_awaited_once_with("Apollo Brown")
651
652
653async def test_search_without_identity(provider: BandcampProvider) -> None:
654 """Test search returns empty results without identity token."""
655 provider._client.identity = None
656
657 results = await provider.search("test query", [MediaType.TRACK])
658
659 assert len(results.tracks) == 0
660 assert len(results.albums) == 0
661 assert len(results.artists) == 0
662
663
664async def test_search_api_error(provider: BandcampProvider) -> None:
665 """Test search handles API errors gracefully."""
666 with (
667 patch.object(provider._client, "search", side_effect=BandcampAPIError("API Error")),
668 pytest.raises(InvalidDataError, match="Unexpected error during Bandcamp search"),
669 ):
670 await provider.search("test query", [MediaType.TRACK])
671
672
673async def test_get_artist_success(provider: BandcampProvider) -> None:
674 """Test successful artist retrieval."""
675 mock_artist = Mock()
676
677 with (
678 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
679 patch.object(provider._converters, "artist_from_api") as mock_converter,
680 ):
681 mock_get_artist.return_value = mock_artist
682 mock_converter.return_value = Mock()
683
684 result = await provider.get_artist("123")
685
686 # The composite-ID parser converts the string to an int before
687 # forwarding to the underlying client.
688 mock_get_artist.assert_called_once_with(123)
689 mock_converter.assert_called_once_with(mock_artist)
690 assert result is not None
691
692
693async def test_get_artist_synthetic_builds_from_discography(
694 provider: BandcampProvider,
695) -> None:
696 """
697 Genuine label-style synthetic resolves via the band's discography.
698
699 The slug names a per-page performer distinct from the band's own
700 name, so the band-name short-circuit doesn't apply and we look up
701 the credit in the discography.
702 """
703 label_id = 441379041
704 label_artist = Mock(id=label_id, url="https://audiophob.bandcamp.com")
705 label_artist.name = "audiophob" # the band's own (page-owner) name
706
707 discography = [
708 {
709 "item_type": "album",
710 "band_id": label_id,
711 "item_id": 1938115920,
712 "title": "Combined Minds",
713 "artist_name": "Mortaja",
714 "band_name": "audiophob",
715 "art_id": 2825942492,
716 "release_date": "18 Oct 2018 00:00:00 GMT",
717 },
718 ]
719
720 with (
721 patch.object(
722 provider._client, "get_artist_discography", new_callable=AsyncMock
723 ) as mock_disco,
724 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
725 ):
726 mock_get_artist.return_value = label_artist
727 mock_disco.return_value = discography
728
729 result = await provider.get_artist(f"{label_id}:mortaja")
730
731 assert result.item_id == f"{label_id}:mortaja"
732 assert result.name == "Mortaja"
733 # The hosting URL is mapping metadata, not synthetic artist identity.
734 assert result.uri == f"bandcamp_test://artist/{label_id}:mortaja"
735 mapping = next(iter(result.provider_mappings))
736 assert mapping.url == "https://audiophob.bandcamp.com"
737 # Both API calls happen: band-name lookup didn't match the slug,
738 # so we proceeded to look in the discography.
739 mock_get_artist.assert_called_once_with(label_id)
740 mock_disco.assert_called_once_with(label_id)
741
742
743async def test_get_artist_synthetic_band_own_slug_collapses_to_real(
744 provider: BandcampProvider,
745) -> None:
746 """
747 Synthetic slug equal to the band's own slug resolves to the real band.
748
749 This is the drift-collapse path. Bandcamp's autocomplete sometimes
750 returns ``a``/``t`` rows for a band-by-itself album without the
751 matching ``b`` row in the same response; the search-time path mints
752 a synthetic ``{band_id}:slug-of-band-own-name`` because it can't
753 disambiguate without the ``b`` row. When the user navigates to that
754 synthetic, we collapse it back to the real band before MA can
755 persist a duplicate library entry.
756
757 Critically, the discography has matching items (band-by-itself
758 entries with ``artist_name=null`` fall through to
759 ``band_name='Apollo Brown'`` whose slug matches), but we must NOT
760 build a shadow synthetic from them.
761 """
762 band_id = 3658985110
763 api_artist = Mock(id=band_id, url="https://apollobrown360.bandcamp.com")
764 api_artist.name = "Apollo Brown"
765
766 with (
767 patch.object(
768 provider._client, "get_artist_discography", new_callable=AsyncMock
769 ) as mock_disco,
770 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
771 ):
772 mock_get_artist.return_value = api_artist
773 # Discography includes items that WOULD match the slug if we
774 # consulted it (artist_name=null â falls through to band_name
775 # which equals "Apollo Brown" â slug "apollo-brown"). The fix
776 # depends on never reaching this lookup once the band-name
777 # short-circuit fires.
778 mock_disco.return_value = [
779 {
780 "item_type": "album",
781 "band_id": band_id,
782 "item_id": 999,
783 "title": "Skilled Trade",
784 "artist_name": None,
785 "band_name": "Apollo Brown",
786 },
787 ]
788
789 result = await provider.get_artist(f"{band_id}:apollo-brown")
790
791 assert result.item_id == str(band_id)
792 assert result.name == "Apollo Brown"
793 mock_get_artist.assert_called_once_with(band_id)
794 # Short-circuit: no discography fetch when the band's own name
795 # already matches the synthetic slug.
796 mock_disco.assert_not_called()
797
798
799async def test_get_artist_albums_owner_slug_collapses_to_real_discography(
800 provider: BandcampProvider,
801) -> None:
802 """A legacy owner-slug ID returns the real band's unfiltered discography."""
803 band_id = 3658985110
804 api_artist = Mock(id=band_id)
805 api_artist.name = "Apollo Brown"
806 discography = [
807 {
808 "item_type": "album",
809 "band_id": band_id,
810 "item_id": 1,
811 "title": "Own Album",
812 "artist_name": None,
813 "band_name": "Apollo Brown",
814 },
815 {
816 "item_type": "album",
817 "band_id": band_id,
818 "item_id": 2,
819 "title": "Another Own Album",
820 "artist_name": "Apollo Brown",
821 "band_name": "Apollo Brown",
822 },
823 ]
824
825 with (
826 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
827 patch.object(
828 provider._client, "get_artist_discography", new_callable=AsyncMock
829 ) as mock_get_discography,
830 patch.object(provider._client, "search", new_callable=AsyncMock) as mock_search,
831 ):
832 mock_get_artist.return_value = api_artist
833 mock_get_discography.return_value = discography
834
835 result = await provider.get_artist_albums(f"{band_id}:apollo-brown")
836
837 assert {album.name for album in result} == {"Own Album", "Another Own Album"}
838 assert all(next(iter(album.artists)).item_id == str(band_id) for album in result)
839 mock_get_artist.assert_awaited_once_with(band_id)
840 mock_get_discography.assert_awaited_once_with(band_id)
841 mock_search.assert_not_awaited()
842
843
844async def test_lookup_performer_band_ids_propagates_cancellation(
845 provider: BandcampProvider,
846) -> None:
847 """Cancellation must not be converted into a failed lookup result."""
848 with (
849 patch.object(
850 provider,
851 "_lookup_performer_band_id",
852 new_callable=AsyncMock,
853 side_effect=asyncio.CancelledError,
854 ),
855 pytest.raises(asyncio.CancelledError),
856 ):
857 await provider._lookup_performer_band_ids_parallel({"mortaja": "Mortaja"})
858
859
860async def test_lookup_performer_band_ids_reraises_other_base_exception(
861 provider: BandcampProvider,
862) -> None:
863 """Non-Exception BaseExceptions must not be treated as band IDs."""
864
865 class TestBaseException(BaseException):
866 pass
867
868 with (
869 patch.object(
870 provider,
871 "_lookup_performer_band_id",
872 new_callable=AsyncMock,
873 side_effect=TestBaseException,
874 ),
875 pytest.raises(TestBaseException),
876 ):
877 await provider._lookup_performer_band_ids_parallel({"mortaja": "Mortaja"})
878
879
880async def test_get_synthetic_artist_rate_limit_at_band_lookup(
881 provider: BandcampProvider,
882) -> None:
883 """
884 Rate-limit on the initial band lookup converts to RateLimited.
885
886 Tests ``_get_synthetic_artist`` directly to bypass the public method's
887 ``@throttle_with_retries`` decorator. The decorator's job is to retry
888 on this exception; our job is to make sure we *raise* it with the
889 backoff hint preserved so the decorator (and any other caller) can
890 use it.
891 """
892 band_id = 441379041
893 with patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist:
894 mock_get_artist.side_effect = BandcampRateLimitError("Rate limited", retry_after=42)
895 with pytest.raises(RateLimited) as exc_info:
896 await provider._get_synthetic_artist(f"{band_id}:mortaja", band_id, "mortaja")
897 assert exc_info.value.backoff_time == 42
898
899
900async def test_get_synthetic_artist_rate_limit_at_discography(
901 provider: BandcampProvider,
902) -> None:
903 """
904 Rate-limit on the discography fetch surfaces with backoff hint.
905
906 Regression guard: an earlier version of this method caught
907 ``BandcampAPIError`` (the parent of ``BandcampRateLimitError``) in a
908 single ``except`` clause for the secondary lookup, swallowing the
909 backoff hint. Both API call sites must now handle rate-limits
910 explicitly.
911 """
912 band_id = 441379041
913 label_artist = Mock(id=band_id)
914 label_artist.name = "audiophob"
915
916 with (
917 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
918 patch.object(
919 provider._client, "get_artist_discography", new_callable=AsyncMock
920 ) as mock_disco,
921 ):
922 mock_get_artist.return_value = label_artist
923 mock_disco.side_effect = BandcampRateLimitError("Rate limited", retry_after=99)
924 with pytest.raises(RateLimited) as exc_info:
925 await provider._get_synthetic_artist(f"{band_id}:mortaja", band_id, "mortaja")
926 assert exc_info.value.backoff_time == 99
927
928
929async def test_get_artist_synthetic_no_matching_performer_raises(
930 provider: BandcampProvider,
931) -> None:
932 """
933 Unresolvable synthetic IDs raise MediaNotFoundError.
934
935 A synthetic slug that matches neither the band's own name nor any
936 per-item performer credit in the discography is genuinely unknown â
937 we surface MediaNotFoundError rather than fabricate a phantom.
938 """
939 band_id = 441379041
940 label_artist = Mock(id=band_id)
941 label_artist.name = "audiophob"
942
943 with (
944 patch.object(provider._client, "get_artist", new_callable=AsyncMock) as mock_get_artist,
945 patch.object(
946 provider._client, "get_artist_discography", new_callable=AsyncMock
947 ) as mock_disco,
948 ):
949 mock_get_artist.return_value = label_artist
950 mock_disco.return_value = [
951 {
952 "item_type": "album",
953 "band_id": band_id,
954 "item_id": 1,
955 "title": "Some Other Album",
956 "artist_name": "Some Other Artist",
957 "band_name": "audiophob",
958 },
959 ]
960 with pytest.raises(MediaNotFoundError):
961 await provider.get_artist(f"{band_id}:nonexistent-performer")
962
963
964async def test_get_artist_malformed_id_raises(provider: BandcampProvider) -> None:
965 """Non-numeric band_id portions surface as InvalidDataError."""
966 with pytest.raises(InvalidDataError, match=r"Malformed Bandcamp artist ID"):
967 await provider.get_artist("not-a-number")
968
969
970async def test_get_artist_not_found(provider: BandcampProvider) -> None:
971 """Test artist retrieval when not found."""
972 with (
973 patch.object(
974 provider._client, "get_artist", side_effect=BandcampNotFoundError("Not found")
975 ),
976 pytest.raises(MediaNotFoundError, match=r"Artist 123 not found on Bandcamp"),
977 ):
978 await provider.get_artist("123")
979
980
981async def test_get_album_success(provider: BandcampProvider) -> None:
982 """Test successful album retrieval."""
983 mock_album = Mock()
984 mock_album.artist.id = 123
985 mock_album.artist.name = "Test Band"
986 mock_album.tralbum_artist = None
987
988 with (
989 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
990 patch.object(provider._converters, "album_from_api") as mock_converter,
991 ):
992 mock_get_album.return_value = mock_album
993 mock_converter.return_value = Mock()
994
995 result = await provider.get_album("123-456")
996
997 mock_get_album.assert_called_once_with(123, 456)
998 # Plain band_id passes through when there's no performer credit.
999 mock_converter.assert_called_once_with(mock_album, artist_item_id="123")
1000 assert result is not None
1001
1002
1003async def test_get_track_success(provider: BandcampProvider) -> None:
1004 """Test successful track retrieval."""
1005 mock_album = Mock()
1006 mock_track = Mock()
1007 mock_album.tracks = [mock_track]
1008 mock_album.artist.id = 123
1009 mock_album.artist.name = "Test Band"
1010 mock_album.tralbum_artist = None
1011 mock_track.id = 789
1012
1013 with (
1014 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
1015 patch.object(provider._converters, "track_from_api") as mock_converter,
1016 ):
1017 mock_get_album.return_value = mock_album
1018 mock_converter.return_value = Mock()
1019
1020 result = await provider.get_track("123-456-789")
1021
1022 mock_get_album.assert_called_once_with(123, 456)
1023 assert result is not None
1024
1025
1026async def test_get_track_standalone(provider: BandcampProvider) -> None:
1027 """Test get_track for a standalone track (album_id=0) uses get_track API path."""
1028 mock_album_obj = Mock()
1029 mock_album_obj.id = 456
1030 mock_album_obj.title = "Standalone Album"
1031 mock_album_obj.art_url = "http://example.com/art.jpg"
1032
1033 mock_api_track = Mock()
1034 mock_api_track.album = mock_album_obj
1035 # Standalone single tracks carry their own performer credit; the
1036 # provider forwards it to the converter so synthetic IDs are emitted
1037 # for label-released singles. Performer matches band name here so the
1038 # secondary lookup short-circuits to the plain band_id.
1039 mock_api_track.tralbum_artist = "Test Artist"
1040 mock_api_track.artist.id = 123
1041 mock_api_track.artist.name = "Test Artist"
1042
1043 with (
1044 patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track,
1045 patch.object(provider._converters, "track_from_api") as mock_converter,
1046 ):
1047 mock_get_track.return_value = mock_api_track
1048 mock_converter.return_value = Mock()
1049
1050 result = await provider.get_track("123-0-789")
1051
1052 mock_get_track.assert_called_once_with(123, 789)
1053 mock_converter.assert_called_once_with(
1054 track=mock_api_track,
1055 album_id=456,
1056 album_name="Standalone Album",
1057 album_image_url="http://example.com/art.jpg",
1058 tralbum_artist="Test Artist",
1059 artist_item_id="123",
1060 )
1061 assert result is not None
1062
1063
1064async def test_get_track_standalone_no_album(provider: BandcampProvider) -> None:
1065 """Test get_track for a standalone track where api_track.album is None."""
1066 mock_api_track = Mock()
1067 mock_api_track.album = None
1068 mock_api_track.tralbum_artist = None
1069 mock_api_track.artist.id = 123
1070 mock_api_track.artist.name = "Test Band"
1071
1072 with (
1073 patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track,
1074 patch.object(provider._converters, "track_from_api") as mock_converter,
1075 ):
1076 mock_get_track.return_value = mock_api_track
1077 mock_converter.return_value = Mock()
1078
1079 result = await provider.get_track("123-0-789")
1080
1081 mock_get_track.assert_called_once_with(123, 789)
1082 mock_converter.assert_called_once_with(
1083 track=mock_api_track,
1084 album_id=None,
1085 album_name="",
1086 album_image_url="",
1087 tralbum_artist=None,
1088 artist_item_id="123",
1089 )
1090 assert result is not None
1091
1092
1093async def test_get_track_not_found(provider: BandcampProvider) -> None:
1094 """Test track retrieval when not found."""
1095 with (
1096 patch.object(provider._client, "get_album", side_effect=BandcampNotFoundError("Not found")),
1097 pytest.raises(MediaNotFoundError, match=r"Track 123-456-789 not found on Bandcamp"),
1098 ):
1099 await provider.get_track("123-456-789")
1100
1101
1102async def test_get_album_tracks_success(provider: BandcampProvider) -> None:
1103 """Test successful album tracks retrieval."""
1104 mock_album = Mock()
1105 mock_track = Mock()
1106 mock_track.streaming_url = {"mp3-128": "http://example.com/track.mp3"}
1107 mock_album.tracks = [mock_track]
1108 mock_album.title = "Test Album"
1109 mock_album.art_url = "http://example.com/art.jpg"
1110 mock_album.artist.id = 123
1111 mock_album.artist.name = "Test Band"
1112 mock_album.tralbum_artist = None
1113
1114 with (
1115 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
1116 patch.object(provider._converters, "track_from_api") as mock_converter,
1117 ):
1118 mock_get_album.return_value = mock_album
1119 mock_converter.return_value = Mock()
1120
1121 result = await provider.get_album_tracks("123-456")
1122
1123 assert len(result) == 1
1124 mock_converter.assert_called_once()
1125
1126
1127async def test_get_artist_albums_success(provider: BandcampProvider) -> None:
1128 """Test successful artist albums retrieval converts discography items directly."""
1129 mock_discography = [
1130 {
1131 "item_type": "album",
1132 "band_id": 123,
1133 "item_id": 456,
1134 "title": "Test Album",
1135 "artist_name": "Test Artist",
1136 "band_name": "Test Artist",
1137 "art_id": 9876543210,
1138 "release_date": "21 Feb 2020 00:00:00 GMT",
1139 },
1140 {"item_type": "track", "band_id": 123, "item_id": 789}, # should be skipped
1141 ]
1142
1143 with patch.object(
1144 provider._client, "get_artist_discography", new_callable=AsyncMock
1145 ) as mock_get_discography:
1146 mock_get_discography.return_value = mock_discography
1147
1148 result = await provider.get_artist_albums("123")
1149
1150 mock_get_discography.assert_called_once_with(123)
1151 assert len(result) == 1
1152 assert result[0].item_id == "123-456"
1153 assert result[0].name == "Test Album"
1154 assert result[0].year == 2020
1155
1156
1157async def test_get_artist_albums_label_uses_band_id(provider: BandcampProvider) -> None:
1158 """Test that label discography uses each album's band_id, not the label's ID."""
1159 mock_discography = [
1160 {
1161 "item_type": "album",
1162 "band_id": 9999, # actual artist, different from label ID
1163 "item_id": 100,
1164 "title": "Artist Album",
1165 "artist_name": "Some Artist",
1166 "band_name": "Some Artist",
1167 "art_id": 1111,
1168 "release_date": "01 Jan 2023 00:00:00 GMT",
1169 },
1170 ]
1171
1172 with patch.object(
1173 provider._client, "get_artist_discography", new_callable=AsyncMock
1174 ) as mock_get_discography:
1175 mock_get_discography.return_value = mock_discography
1176
1177 # Query with label ID "555", but album should use band_id 9999
1178 result = await provider.get_artist_albums("555")
1179
1180 assert len(result) == 1
1181 assert result[0].item_id == "9999-100" # band_id, not label ID
1182 artists = list(result[0].artists)
1183 # artist_name == band_name â real artist ID for the album's own band.
1184 assert artists[0].item_id == "9999"
1185
1186
1187async def test_get_artist_albums_label_unifies_performer_to_real_band(
1188 provider: BandcampProvider,
1189) -> None:
1190 """
1191 Listing a label's discography unifies label-released performers to their real band pages.
1192
1193 When the user navigates to a label artist (e.g. Hip Dozer), the
1194 discography listing must use real performer band_ids â matching what
1195 ``get_album`` would emit on click. Otherwise the album list shows
1196 synthetic IDs while the album detail shows real IDs, sending the
1197 same artist-name link to two different destinations.
1198 """
1199 label_id = 4119123456
1200 apollo_band_id = 3658985110
1201 mock_discography = [
1202 {
1203 "item_type": "album",
1204 "band_id": label_id,
1205 "item_id": 686338649,
1206 "title": "Night Moves",
1207 "artist_name": "Apollo Brown",
1208 "band_name": "Hip Dozer",
1209 "art_id": 2560657053,
1210 "release_date": "01 Jan 2020 00:00:00 GMT",
1211 },
1212 {
1213 "item_type": "album",
1214 "band_id": label_id,
1215 "item_id": 100,
1216 "title": "Label Compilation",
1217 # Band-by-itself row: artist_name == band_name â no lookup.
1218 "artist_name": "Hip Dozer",
1219 "band_name": "Hip Dozer",
1220 "art_id": 1234,
1221 "release_date": "01 Jun 2021 00:00:00 GMT",
1222 },
1223 ]
1224 secondary_response = [_search_artist_mock(artist_id=apollo_band_id, name="Apollo Brown")]
1225
1226 with (
1227 patch.object(
1228 provider._client,
1229 "get_artist_discography",
1230 new_callable=AsyncMock,
1231 return_value=mock_discography,
1232 ),
1233 patch.object(
1234 provider._client,
1235 "search",
1236 new_callable=AsyncMock,
1237 return_value=secondary_response,
1238 ) as mock_search,
1239 ):
1240 result = await provider.get_artist_albums(str(label_id))
1241
1242 by_name = {album.name: album for album in result}
1243 apollo_album = by_name["Night Moves"]
1244 label_album = by_name["Label Compilation"]
1245
1246 # Label-released performer with own band page â real band_id, not synthetic.
1247 assert next(iter(apollo_album.artists)).item_id == str(apollo_band_id)
1248 # Band-by-itself row: artist_name slug == band_name slug â plain band_id,
1249 # no lookup attempted.
1250 assert next(iter(label_album.artists)).item_id == str(label_id)
1251
1252 # One lookup for the unique unmapped performer; the band-by-itself row
1253 # short-circuited before reaching the lookup.
1254 mock_search.assert_awaited_once_with("Apollo Brown")
1255
1256
1257async def test_get_artist_albums_synthetic_id_filters_discography(
1258 provider: BandcampProvider,
1259) -> None:
1260 """A synthetic artist ID should return only the matching performer's albums."""
1261 label_id = 441379041
1262 mock_discography = [
1263 {
1264 "item_type": "album",
1265 "band_id": label_id,
1266 "item_id": 1938115920,
1267 "title": "Combined Minds",
1268 "artist_name": "Mortaja",
1269 "band_name": "audiophob",
1270 "art_id": 2825942492,
1271 "release_date": "18 Oct 2018 00:00:00 GMT",
1272 },
1273 {
1274 "item_type": "album",
1275 "band_id": label_id,
1276 "item_id": 4042974093,
1277 "title": "Basalt",
1278 "artist_name": "Spherical Disrupted",
1279 "band_name": "audiophob",
1280 "art_id": 1234,
1281 "release_date": "01 Jan 2024 00:00:00 GMT",
1282 },
1283 {
1284 "item_type": "album",
1285 "band_id": label_id,
1286 "item_id": 1185688687,
1287 "title": "Bone Chamber",
1288 "artist_name": "Mortaja",
1289 "band_name": "audiophob",
1290 "art_id": 5678,
1291 "release_date": "01 Jan 2017 00:00:00 GMT",
1292 },
1293 ]
1294
1295 with (
1296 patch.object(
1297 provider._client,
1298 "get_artist",
1299 new_callable=AsyncMock,
1300 ) as mock_get_artist,
1301 patch.object(
1302 provider._client, "get_artist_discography", new_callable=AsyncMock
1303 ) as mock_get_discography,
1304 patch.object(
1305 provider._client, "search", new_callable=AsyncMock, return_value=[]
1306 ) as mock_search,
1307 ):
1308 mock_get_artist.return_value = Mock(id=label_id)
1309 mock_get_artist.return_value.name = "audiophob"
1310 mock_get_discography.return_value = mock_discography
1311
1312 result = await provider.get_artist_albums(f"{label_id}:mortaja")
1313
1314 mock_get_artist.assert_awaited_once_with(label_id)
1315 mock_get_discography.assert_awaited_once_with(label_id)
1316 mock_search.assert_awaited_once_with("Mortaja")
1317 # Only the two Mortaja albums; the Spherical Disrupted entry is filtered out.
1318 assert {album.name for album in result} == {"Combined Minds", "Bone Chamber"}
1319 for album in result:
1320 artist = next(iter(album.artists))
1321 assert artist.item_id == f"{label_id}:mortaja"
1322
1323
1324async def test_get_stream_details_success(provider: BandcampProvider) -> None:
1325 """Test stream details fetches fresh URL and audio format from API."""
1326 mock_api_track = Mock()
1327 mock_api_track.id = 789
1328 mock_api_track.streaming_url = {"mp3-320": "http://example.com/track.mp3"}
1329 mock_api_album = Mock()
1330 mock_api_album.tracks = [mock_api_track]
1331
1332 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1333 mock_get_album.return_value = mock_api_album
1334
1335 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
1336
1337 mock_get_album.assert_called_once_with(123, 456)
1338 assert isinstance(result, StreamDetails)
1339 assert result.item_id == "123-456-789"
1340 assert result.media_type == MediaType.TRACK
1341 assert result.stream_type == StreamType.HTTP
1342 assert result.path == "http://example.com/track.mp3"
1343 assert result.audio_format.content_type == ContentType.MP3
1344 assert result.audio_format.bit_rate == 320
1345
1346
1347async def test_get_stream_details_vbr(provider: BandcampProvider) -> None:
1348 """Test stream details with VBR mp3-v0 format."""
1349 mock_api_track = Mock()
1350 mock_api_track.id = 789
1351 mock_api_track.streaming_url = {"mp3-v0": "http://example.com/track-v0.mp3"}
1352 mock_api_album = Mock()
1353 mock_api_album.tracks = [mock_api_track]
1354
1355 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1356 mock_get_album.return_value = mock_api_album
1357
1358 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
1359
1360 assert result.path == "http://example.com/track-v0.mp3"
1361 assert result.audio_format.content_type == ContentType.MP3
1362 assert result.audio_format.bit_rate is None
1363
1364
1365async def test_get_stream_details_no_streaming_url(provider: BandcampProvider) -> None:
1366 """Test stream details when API track has no streaming URL."""
1367 mock_api_track = Mock()
1368 mock_api_track.id = 789
1369 mock_api_track.streaming_url = {}
1370 mock_api_album = Mock()
1371 mock_api_album.tracks = [mock_api_track]
1372
1373 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1374 mock_get_album.return_value = mock_api_album
1375
1376 with pytest.raises(MediaNotFoundError, match=r"No streaming URL found"):
1377 await provider.get_stream_details("123-456-789", MediaType.TRACK)
1378
1379
1380async def test_get_stream_details_none_streaming_url(provider: BandcampProvider) -> None:
1381 """Test stream details when API track has streaming_url=None."""
1382 mock_api_track = Mock()
1383 mock_api_track.id = 789
1384 mock_api_track.streaming_url = None
1385 mock_api_album = Mock()
1386 mock_api_album.tracks = [mock_api_track]
1387
1388 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1389 mock_get_album.return_value = mock_api_album
1390
1391 with pytest.raises(MediaNotFoundError, match=r"No streaming URL found"):
1392 await provider.get_stream_details("123-456-789", MediaType.TRACK)
1393
1394
1395async def test_get_stream_details_bypasses_cache(provider: BandcampProvider) -> None:
1396 """Test that get_stream_details calls API directly, not cached get_track."""
1397 mock_api_track = Mock()
1398 mock_api_track.id = 789
1399 mock_api_track.streaming_url = {"mp3-128": "http://example.com/track.mp3"}
1400 mock_api_album = Mock()
1401 mock_api_album.tracks = [mock_api_track]
1402
1403 with (
1404 patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album,
1405 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
1406 ):
1407 mock_get_album.return_value = mock_api_album
1408
1409 result = await provider.get_stream_details("123-456-789", MediaType.TRACK)
1410
1411 mock_get_album.assert_called_once()
1412 mock_get_track.assert_not_called()
1413 assert result.path == "http://example.com/track.mp3"
1414 assert result.audio_format.content_type == ContentType.MP3
1415
1416
1417async def test_fetch_api_track_album_path(provider: BandcampProvider) -> None:
1418 """Test _fetch_api_track with 3-part ID routes through get_album."""
1419 mock_api_track = Mock()
1420 mock_api_track.id = 789
1421 mock_api_album = Mock()
1422 mock_api_album.tracks = [mock_api_track]
1423
1424 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1425 mock_get_album.return_value = mock_api_album
1426
1427 api_track, api_album = await provider._fetch_api_track("123-456-789")
1428
1429 mock_get_album.assert_called_once_with(123, 456)
1430 assert api_track is mock_api_track
1431 assert api_album is mock_api_album
1432
1433
1434async def test_fetch_api_track_standalone_path(provider: BandcampProvider) -> None:
1435 """Test _fetch_api_track with album_id=0 routes through get_track."""
1436 mock_api_track = Mock()
1437
1438 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
1439 mock_get_track.return_value = mock_api_track
1440
1441 api_track, api_album = await provider._fetch_api_track("123-0-789")
1442
1443 mock_get_track.assert_called_once_with(123, 789)
1444 assert api_track is mock_api_track
1445 assert api_album is None
1446
1447
1448async def test_fetch_api_track_not_in_album(provider: BandcampProvider) -> None:
1449 """Test _fetch_api_track raises when track ID not found in album tracks."""
1450 mock_other_track = Mock()
1451 mock_other_track.id = 999
1452 mock_api_album = Mock()
1453 mock_api_album.tracks = [mock_other_track]
1454
1455 with patch.object(provider._client, "get_album", new_callable=AsyncMock) as mock_get_album:
1456 mock_get_album.return_value = mock_api_album
1457
1458 with pytest.raises(MediaNotFoundError, match=r"not found in album"):
1459 await provider._fetch_api_track("123-456-789")
1460
1461
1462async def test_fetch_api_track_not_found_error(provider: BandcampProvider) -> None:
1463 """Test _fetch_api_track converts BandcampNotFoundError."""
1464 with (
1465 patch.object(
1466 provider._client,
1467 "get_album",
1468 side_effect=BandcampNotFoundError("Not found"),
1469 ),
1470 pytest.raises(MediaNotFoundError, match=r"not found on Bandcamp"),
1471 ):
1472 await provider._fetch_api_track("123-456-789")
1473
1474
1475async def test_fetch_api_track_rate_limit_error(provider: BandcampProvider) -> None:
1476 """
1477 Test _fetch_api_track converts BandcampRateLimitError.
1478
1479 Since @throttle_with_retries is on _fetch_api_track, persistent rate
1480 limiting exhausts retries and raises RetriesExhausted.
1481 """
1482 rate_error = BandcampRateLimitError("Rate limited")
1483 rate_error.retry_after = 3
1484
1485 with (
1486 patch.object(
1487 provider._client,
1488 "get_album",
1489 side_effect=rate_error,
1490 ) as mock_get_album,
1491 patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep,
1492 pytest.raises(RetriesExhausted),
1493 ):
1494 await provider._fetch_api_track("123-456-789")
1495
1496 assert mock_get_album.call_count == provider.throttler.retry_attempts
1497 assert mock_sleep.call_count == provider.throttler.retry_attempts - 1
1498
1499
1500async def test_fetch_api_track_generic_api_error(provider: BandcampProvider) -> None:
1501 """Test _fetch_api_track converts generic BandcampAPIError to MediaNotFoundError."""
1502 with (
1503 patch.object(
1504 provider._client,
1505 "get_album",
1506 side_effect=BandcampAPIError("Something went wrong"),
1507 ),
1508 pytest.raises(MediaNotFoundError, match=r"Failed to get track 123-456-789"),
1509 ):
1510 await provider._fetch_api_track("123-456-789")
1511
1512
1513def test_split_id_three_parts() -> None:
1514 """Test split_id with a 3-part compound ID."""
1515 assert split_id("123-456-789") == (123, 456, 789)
1516
1517
1518def test_split_id_two_parts() -> None:
1519 """Test split_id with a 2-part compound ID."""
1520 assert split_id("123-456") == (123, 456, 0)
1521
1522
1523def test_split_id_one_part() -> None:
1524 """Test split_id with a single ID."""
1525 assert split_id("123") == (123, 0, 0)
1526
1527
1528async def test_fetch_api_track_two_part_id(provider: BandcampProvider) -> None:
1529 """Test _fetch_api_track with 2-part ID routes through get_track."""
1530 # split_id("123-789") returns (123, 789, 0); since track_id=0,
1531 # the method swaps to album_id=0, track_id=789 and uses get_track.
1532 mock_api_track = Mock()
1533
1534 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
1535 mock_get_track.return_value = mock_api_track
1536
1537 api_track, api_album = await provider._fetch_api_track("123-789")
1538
1539 mock_get_track.assert_called_once_with(123, 789)
1540 assert api_track is mock_api_track
1541 assert api_album is None
1542
1543
1544async def test_get_stream_details_standalone_track(provider: BandcampProvider) -> None:
1545 """Test stream details for a standalone track (album_id=0)."""
1546 mock_api_track = Mock()
1547 mock_api_track.streaming_url = {"mp3-128": "http://example.com/standalone.mp3"}
1548
1549 with patch.object(provider._client, "get_track", new_callable=AsyncMock) as mock_get_track:
1550 mock_get_track.return_value = mock_api_track
1551
1552 result = await provider.get_stream_details("123-0-789", MediaType.TRACK)
1553
1554 mock_get_track.assert_called_once_with(123, 789)
1555 assert isinstance(result, StreamDetails)
1556 assert result.path == "http://example.com/standalone.mp3"
1557 assert result.audio_format.content_type == ContentType.MP3
1558 assert result.audio_format.bit_rate == 128
1559
1560
1561async def test_get_artist_toptracks_success(provider: BandcampProvider) -> None:
1562 """Test successful artist top tracks retrieval."""
1563 mock_album = Mock()
1564 mock_track = Mock()
1565
1566 with (
1567 patch.object(provider, "get_artist_albums", new_callable=AsyncMock) as mock_get_albums,
1568 patch.object(provider, "get_album_tracks", new_callable=AsyncMock) as mock_get_tracks,
1569 ):
1570 mock_get_albums.return_value = [mock_album]
1571 mock_get_tracks.return_value = [mock_track]
1572
1573 result = await provider.get_artist_toptracks("123")
1574
1575 assert len(result) == 1
1576 mock_get_albums.assert_called_once_with("123")
1577
1578
1579async def test_get_library_artists_success(provider: BandcampProvider) -> None:
1580 """Test successful library artists retrieval."""
1581 collection_items = [
1582 Mock(item_type="band", item_id=100, band_id=100),
1583 Mock(item_type="album", item_id=200, band_id=300),
1584 ]
1585
1586 with (
1587 patch.object(
1588 provider, "_get_all_collection_items", new_callable=AsyncMock
1589 ) as mock_get_collection,
1590 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1591 ):
1592 mock_get_collection.return_value = collection_items
1593 mock_get_artist.return_value = Mock()
1594
1595 artists = [artist async for artist in provider.get_library_artists()]
1596
1597 assert len(artists) == 2
1598 assert mock_get_artist.call_count == 2
1599
1600
1601async def test_get_library_artists_no_identity(provider: BandcampProvider) -> None:
1602 """Test that library artists returns nothing without identity."""
1603 provider._client.identity = None
1604 artists = [artist async for artist in provider.get_library_artists()]
1605 assert len(artists) == 0
1606
1607
1608async def test_get_library_albums_success(provider: BandcampProvider) -> None:
1609 """Test successful library albums retrieval."""
1610 collection_items = [
1611 Mock(item_type="album", item_id=456, band_id=123),
1612 ]
1613
1614 with (
1615 patch.object(
1616 provider, "_get_all_collection_items", new_callable=AsyncMock
1617 ) as mock_get_collection,
1618 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1619 ):
1620 mock_get_collection.return_value = collection_items
1621 mock_get_album.return_value = Mock()
1622
1623 albums = [album async for album in provider.get_library_albums()]
1624
1625 assert len(albums) == 1
1626 mock_get_album.assert_called_once_with("123-456")
1627
1628
1629async def test_get_library_tracks_success(provider: BandcampProvider) -> None:
1630 """Test successful library tracks retrieval."""
1631 mock_track = Mock()
1632
1633 with (
1634 patch.object(provider, "get_library_albums") as mock_get_albums,
1635 patch.object(provider, "get_album_tracks", new_callable=AsyncMock) as mock_get_tracks,
1636 ):
1637 # Make get_library_albums an async generator
1638 async def mock_albums_gen() -> AsyncGenerator[Mock]:
1639 yield Mock(item_id="123-456")
1640
1641 mock_get_albums.return_value = mock_albums_gen()
1642 mock_get_tracks.return_value = [mock_track]
1643
1644 tracks = [track async for track in provider.get_library_tracks()]
1645
1646 assert len(tracks) == 1
1647 mock_get_tracks.assert_called_once_with("123-456")
1648
1649
1650def test_split_id_malformed_non_numeric() -> None:
1651 """Test split_id raises InvalidDataError on non-numeric input."""
1652 with pytest.raises(InvalidDataError, match=r"Malformed Bandcamp ID"):
1653 split_id("abc-def")
1654
1655
1656def test_split_id_malformed_empty() -> None:
1657 """Test split_id raises InvalidDataError on empty string."""
1658 with pytest.raises(InvalidDataError, match=r"Malformed Bandcamp ID"):
1659 split_id("")
1660
1661
1662async def test_fetch_api_track_login_error(provider: BandcampProvider) -> None:
1663 """Test _fetch_api_track converts BandcampMustBeLoggedInError to LoginFailed."""
1664 with (
1665 patch.object(
1666 provider._client,
1667 "get_album",
1668 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
1669 ),
1670 pytest.raises(LoginFailed, match=r"login is invalid or expired"),
1671 ):
1672 await provider._fetch_api_track("123-456-789")
1673
1674
1675# --- Feed tests (the feed powers a recommendation row and a browse path) ---
1676
1677
1678async def test_browse_feed_returns_tracks(provider: BandcampProvider) -> None:
1679 """Test browsing the feed slug resolves to the feed tracks (the play path for the folder)."""
1680 feed_track = Mock()
1681 with patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed:
1682 mock_feed.return_value = [feed_track]
1683
1684 result = await provider.browse("bandcamp_test://feed")
1685
1686 assert result == [feed_track]
1687
1688
1689async def test_get_feed_tracks_filters_non_streamable(provider: BandcampProvider) -> None:
1690 """Test _get_feed_tracks skips tracks without a streaming URL and caches the result."""
1691 streamable = Mock(streaming_url={"mp3-128": "https://example.com/feed.mp3"})
1692 silent = Mock(streaming_url=None)
1693 converted = Mock()
1694
1695 with (
1696 patch.object(provider, "_fetch_feed", new_callable=AsyncMock) as mock_fetch,
1697 patch.object(
1698 provider._converters, "track_from_feed", return_value=converted
1699 ) as mock_convert,
1700 ):
1701 mock_fetch.return_value = Mock(track_list=[streamable, silent])
1702
1703 result = await provider._get_feed_tracks()
1704
1705 mock_convert.assert_called_once_with(streamable)
1706 assert result == [converted]
1707 cast("AsyncMock", provider.mass.cache.set).assert_called_once()
1708
1709
1710async def test_get_feed_tracks_cache_hit(provider: BandcampProvider) -> None:
1711 """Test _get_feed_tracks returns cached tracks without hitting the API."""
1712 cached = [Mock()]
1713
1714 with (
1715 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached),
1716 patch.object(provider, "_fetch_feed", new_callable=AsyncMock) as mock_fetch,
1717 ):
1718 result = await provider._get_feed_tracks()
1719
1720 mock_fetch.assert_not_called()
1721 assert result == cached
1722
1723
1724# --- Browse tests ---
1725
1726
1727async def test_browse_feature_supported(provider: BandcampProvider) -> None:
1728 """Test that BROWSE is in supported features."""
1729 assert ProviderFeature.BROWSE in provider.supported_features
1730
1731
1732async def test_browse_root_with_identity(provider: BandcampProvider) -> None:
1733 """Test browse root returns standard folders plus Wishlist and Following."""
1734 provider._client.identity = "mock_token"
1735
1736 with patch.object(
1737 type(provider).__bases__[0], "browse", new_callable=AsyncMock
1738 ) as mock_super_browse:
1739 mock_super_browse.return_value = [
1740 BrowseFolder(
1741 item_id="artists", provider="bandcamp_test", path="bandcamp_test://artists", name=""
1742 ),
1743 BrowseFolder(
1744 item_id="albums", provider="bandcamp_test", path="bandcamp_test://albums", name=""
1745 ),
1746 ]
1747
1748 result = await provider.browse("bandcamp_test://")
1749
1750 assert len(result) == 6
1751 folder_ids = [f.item_id for f in result if isinstance(f, BrowseFolder)]
1752 assert "wishlist" in folder_ids
1753 assert "following" in folder_ids
1754 assert "fans" in folder_ids
1755 assert "followers" in folder_ids
1756
1757 wishlist_folder = next(
1758 f for f in result if isinstance(f, BrowseFolder) and f.item_id == "wishlist"
1759 )
1760 assert wishlist_folder.path == "bandcamp_test://wishlist"
1761 assert wishlist_folder.name == "Wishlist"
1762
1763 following_folder = next(
1764 f for f in result if isinstance(f, BrowseFolder) and f.item_id == "following"
1765 )
1766 assert following_folder.path == "bandcamp_test://following"
1767 assert following_folder.name == "Following"
1768
1769
1770async def test_browse_root_without_identity(provider: BandcampProvider) -> None:
1771 """Test browse root without identity omits Wishlist and Following."""
1772 provider._client.identity = None
1773
1774 with patch.object(
1775 type(provider).__bases__[0], "browse", new_callable=AsyncMock
1776 ) as mock_super_browse:
1777 mock_super_browse.return_value = [
1778 BrowseFolder(
1779 item_id="artists", provider="bandcamp_test", path="bandcamp_test://artists", name=""
1780 ),
1781 ]
1782
1783 result = await provider.browse("bandcamp_test://")
1784
1785 assert len(result) == 1
1786 folder_ids = [f.item_id for f in result if isinstance(f, BrowseFolder)]
1787 assert "wishlist" not in folder_ids
1788 assert "following" not in folder_ids
1789
1790
1791async def test_browse_standard_subpath_delegates_to_super(provider: BandcampProvider) -> None:
1792 """Test that standard subpaths like 'artists' delegate to super().browse()."""
1793 with patch.object(
1794 type(provider).__bases__[0], "browse", new_callable=AsyncMock
1795 ) as mock_super_browse:
1796 mock_super_browse.return_value = [Mock(), Mock()]
1797
1798 result = await provider.browse("bandcamp_test://artists")
1799
1800 mock_super_browse.assert_called_once_with("bandcamp_test://artists")
1801 assert len(result) == 2
1802
1803
1804async def test_browse_wishlist_returns_albums_and_tracks(provider: BandcampProvider) -> None:
1805 """Test browsing wishlist returns resolved albums and tracks."""
1806 collection_items = [
1807 Mock(item_type="album", item_id=456, band_id=123),
1808 Mock(item_type="track", item_id=789, band_id=123),
1809 ]
1810
1811 mock_album = Mock()
1812 mock_track = Mock()
1813
1814 with (
1815 patch.object(
1816 provider, "_get_all_collection_items", new_callable=AsyncMock
1817 ) as mock_get_collection,
1818 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1819 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
1820 ):
1821 mock_get_collection.return_value = collection_items
1822 mock_get_album.return_value = mock_album
1823 mock_get_track.return_value = mock_track
1824
1825 result = await provider.browse("bandcamp_test://wishlist")
1826
1827 mock_get_collection.assert_called_once_with(CollectionType.WISHLIST, fan_id=None)
1828 mock_get_album.assert_called_once_with("123-456")
1829 mock_get_track.assert_called_once_with("123-0-789")
1830 assert len(result) == 2
1831 assert mock_album in result
1832 assert mock_track in result
1833
1834
1835async def test_browse_wishlist_skips_failed_items(provider: BandcampProvider) -> None:
1836 """Test that wishlist browse skips items that fail to resolve."""
1837 collection_items = [
1838 Mock(item_type="album", item_id=456, band_id=123),
1839 Mock(item_type="album", item_id=789, band_id=123),
1840 ]
1841
1842 mock_album = Mock()
1843
1844 with (
1845 patch.object(
1846 provider, "_get_all_collection_items", new_callable=AsyncMock
1847 ) as mock_get_collection,
1848 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1849 ):
1850 mock_get_collection.return_value = collection_items
1851 mock_get_album.side_effect = [mock_album, MediaNotFoundError("not found")]
1852
1853 result = await provider.browse("bandcamp_test://wishlist")
1854
1855 assert len(result) == 1
1856
1857
1858async def test_browse_wishlist_login_error(provider: BandcampProvider) -> None:
1859 """Test wishlist browse raises LoginFailed on auth error."""
1860 with (
1861 patch.object(
1862 provider,
1863 "_get_all_collection_items",
1864 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
1865 ),
1866 pytest.raises(LoginFailed),
1867 ):
1868 await provider.browse("bandcamp_test://wishlist")
1869
1870
1871async def test_browse_wishlist_rate_limit(provider: BandcampProvider) -> None:
1872 """Test wishlist browse raises on rate limit after retries."""
1873 rate_error = BandcampRateLimitError("Rate limited")
1874 rate_error.retry_after = 3
1875
1876 with (
1877 patch.object(
1878 provider,
1879 "_get_all_collection_items",
1880 side_effect=rate_error,
1881 ),
1882 patch("asyncio.sleep", new_callable=AsyncMock),
1883 pytest.raises(RetriesExhausted),
1884 ):
1885 await provider.browse("bandcamp_test://wishlist")
1886
1887
1888async def test_browse_following_returns_artists(provider: BandcampProvider) -> None:
1889 """Test browsing following returns resolved artists."""
1890 collection_items = [
1891 Mock(spec=["band_id", "name"], band_id=100, name="Artist1"),
1892 Mock(spec=["band_id", "name"], band_id=200, name="Artist2"),
1893 ]
1894
1895 mock_artist_1 = Mock()
1896 mock_artist_2 = Mock()
1897
1898 with (
1899 patch.object(
1900 provider, "_get_all_collection_items", new_callable=AsyncMock
1901 ) as mock_get_collection,
1902 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1903 ):
1904 mock_get_collection.return_value = collection_items
1905 mock_get_artist.side_effect = [mock_artist_1, mock_artist_2]
1906
1907 result = await provider.browse("bandcamp_test://following")
1908
1909 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=None)
1910 mock_get_artist.assert_has_awaits([call("100"), call("200")])
1911 assert mock_get_artist.call_count == 2
1912 assert len(result) == 2
1913
1914
1915async def test_browse_following_skips_failed_artists(provider: BandcampProvider) -> None:
1916 """Test that following browse skips artists that fail to resolve."""
1917 collection_items = [
1918 Mock(spec=["band_id", "name"], band_id=100, name="Found"),
1919 Mock(spec=["band_id", "name"], band_id=200, name="NotFound"),
1920 ]
1921
1922 mock_artist = Mock()
1923
1924 with (
1925 patch.object(
1926 provider, "_get_all_collection_items", new_callable=AsyncMock
1927 ) as mock_get_collection,
1928 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
1929 ):
1930 mock_get_collection.return_value = collection_items
1931 mock_get_artist.side_effect = [mock_artist, MediaNotFoundError("not found")]
1932
1933 result = await provider.browse("bandcamp_test://following")
1934
1935 assert len(result) == 1
1936
1937
1938async def test_browse_following_login_error(provider: BandcampProvider) -> None:
1939 """Test following browse raises LoginFailed on auth error."""
1940 with (
1941 patch.object(
1942 provider,
1943 "_get_all_collection_items",
1944 side_effect=BandcampMustBeLoggedInError("Must be logged in"),
1945 ),
1946 pytest.raises(LoginFailed),
1947 ):
1948 await provider.browse("bandcamp_test://following")
1949
1950
1951async def test_browse_wishlist_ignores_unknown_item_types(provider: BandcampProvider) -> None:
1952 """Test that wishlist browse ignores items with unknown item_type."""
1953 collection_items = [
1954 Mock(item_type="band", item_id=100, band_id=100),
1955 Mock(item_type="album", item_id=456, band_id=123),
1956 ]
1957
1958 mock_album = Mock()
1959
1960 with (
1961 patch.object(
1962 provider, "_get_all_collection_items", new_callable=AsyncMock
1963 ) as mock_get_collection,
1964 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
1965 ):
1966 mock_get_collection.return_value = collection_items
1967 mock_get_album.return_value = mock_album
1968
1969 result = await provider.browse("bandcamp_test://wishlist")
1970
1971 assert len(result) == 1
1972 mock_get_album.assert_called_once_with("123-456")
1973
1974
1975# --- _map_api_errors context manager tests ---
1976
1977
1978async def test_map_api_errors_login_error(provider: BandcampProvider) -> None:
1979 """Test _map_api_errors maps BandcampMustBeLoggedInError to LoginFailed."""
1980 with pytest.raises(LoginFailed, match="Wrong Bandcamp identity token"):
1981 async with provider._map_api_errors("test context"):
1982 raise BandcampMustBeLoggedInError("Must be logged in")
1983
1984
1985async def test_map_api_errors_rate_limit(provider: BandcampProvider) -> None:
1986 """Test _map_api_errors maps BandcampRateLimitError to ResourceTemporarilyUnavailable."""
1987 rate_error = BandcampRateLimitError("Rate limited")
1988 rate_error.retry_after = 5
1989
1990 with pytest.raises(ResourceTemporarilyUnavailable, match="rate limit"):
1991 async with provider._map_api_errors("test context"):
1992 raise rate_error
1993
1994
1995async def test_map_api_errors_generic_api_error(provider: BandcampProvider) -> None:
1996 """Test _map_api_errors maps BandcampAPIError to MediaNotFoundError with context."""
1997 with pytest.raises(MediaNotFoundError, match="my custom context"):
1998 async with provider._map_api_errors("my custom context"):
1999 raise BandcampAPIError("Something went wrong")
2000
2001
2002async def test_map_api_errors_no_exception(provider: BandcampProvider) -> None:
2003 """Test _map_api_errors passes through when no exception is raised."""
2004 async with provider._map_api_errors("test context"):
2005 pass # no exception
2006
2007
2008# --- _browse_person_root tests ---
2009
2010
2011def test_browse_person_root_returns_five_subfolders(provider: BandcampProvider) -> None:
2012 """Test _browse_person_root returns 5 sub-folders for a person."""
2013 folders = provider._browse_person_root(42, "bandcamp_test://fans/42")
2014
2015 assert len(folders) == 5
2016 names = [f.name for f in folders]
2017 assert names == ["Collection", "Wishlist", "Following", "Fans", "Followers"]
2018 for folder in folders:
2019 assert folder.path.startswith("bandcamp_test://fans/42/")
2020 assert folder.item_id.startswith("person_42_")
2021
2022
2023# --- _people_to_folders tests ---
2024
2025
2026def test_people_to_folders_with_images(provider: BandcampProvider) -> None:
2027 """Test _people_to_folders creates folders with thumbnails."""
2028 people = [
2029 _fan_mock(1, "Alice", "http://example.com/alice.jpg", "https://bandcamp.com/alice"),
2030 _fan_mock(2, "Bob", "http://example.com/bob.jpg", "https://bandcamp.com/bob"),
2031 ]
2032
2033 folders = provider._people_to_folders(people, "bandcamp_test://fans")
2034
2035 assert len(folders) == 2
2036 assert folders[0].name == "Alice"
2037 assert folders[0].path == "bandcamp_test://fans/alice"
2038 assert folders[0].image is not None
2039 assert folders[0].image.type == ImageType.THUMB
2040 assert folders[0].image.path == "http://example.com/alice.jpg"
2041 # Verify slugâfan_id mapping was stored
2042 assert provider._slug_to_fan_id["alice"] == 1
2043 assert provider._slug_to_fan_id["bob"] == 2
2044
2045
2046def test_people_to_folders_without_image(provider: BandcampProvider) -> None:
2047 """Test _people_to_folders handles missing image_url."""
2048 people = [_fan_mock(1, "NoPhoto", url="https://bandcamp.com/nophoto")]
2049 folders = provider._people_to_folders(people, "bandcamp_test://fans")
2050
2051 assert len(folders) == 1
2052 assert folders[0].image is None
2053 assert folders[0].path == "bandcamp_test://fans/nophoto"
2054
2055
2056def test_people_to_folders_missing_name(provider: BandcampProvider) -> None:
2057 """Test _people_to_folders falls back to 'User {id}' when name is empty."""
2058 people = [_fan_mock(99, None)]
2059 folders = provider._people_to_folders(people, "bandcamp_test://fans")
2060
2061 assert folders[0].name == "User 99"
2062 # No URL â falls back to numeric fan_id in path
2063 assert folders[0].path == "bandcamp_test://fans/99"
2064
2065
2066# --- _fan_slug unit tests ---
2067
2068
2069def test_fan_slug_extracts_from_url() -> None:
2070 """Test _fan_slug extracts slug from a standard Bandcamp URL."""
2071 person = _fan_mock(1, "Alice", url="https://bandcamp.com/alice")
2072 assert BandcampProvider._fan_slug(person) == "alice"
2073
2074
2075def test_fan_slug_strips_trailing_slash() -> None:
2076 """Test _fan_slug handles trailing slash in URL."""
2077 person = _fan_mock(1, "Alice", url="https://bandcamp.com/alice/")
2078 assert BandcampProvider._fan_slug(person) == "alice"
2079
2080
2081def test_fan_slug_none_url() -> None:
2082 """Test _fan_slug returns None when url is None."""
2083 person = _fan_mock(1, "Alice", url=None)
2084 assert BandcampProvider._fan_slug(person) is None
2085
2086
2087def test_fan_slug_empty_string_url() -> None:
2088 """Test _fan_slug returns None when url is empty string."""
2089 person = _fan_mock(1, "Alice", url="")
2090 assert BandcampProvider._fan_slug(person) is None
2091
2092
2093# --- _resolve_person_segment unit tests ---
2094
2095
2096async def test_resolve_person_segment_slug_hit(provider: BandcampProvider) -> None:
2097 """Test slug cache hit takes priority over numeric parse."""
2098 provider._slug_to_fan_id["42"] = 999 # slug "42" maps to fan 999
2099 assert await provider._resolve_person_segment("42") == 999 # slug wins over int parse
2100
2101
2102async def test_resolve_person_segment_numeric(provider: BandcampProvider) -> None:
2103 """Test numeric segment returns int when no slug match."""
2104 assert await provider._resolve_person_segment("123") == 123
2105
2106
2107async def test_resolve_person_segment_unknown_slug(provider: BandcampProvider) -> None:
2108 """Test unknown non-numeric slug triggers rebuild, returns None if still missing."""
2109 with patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock) as mock_rebuild:
2110 assert await provider._resolve_person_segment("nonexistent") is None
2111 mock_rebuild.assert_called_once()
2112
2113
2114async def test_resolve_person_segment_zero(provider: BandcampProvider) -> None:
2115 """Test zero is returned as valid int (caller validates)."""
2116 assert await provider._resolve_person_segment("0") == 0
2117
2118
2119async def test_resolve_person_segment_rebuild_finds_slug(provider: BandcampProvider) -> None:
2120 """Test unknown slug is resolved after _rebuild_slug_cache populates the map."""
2121
2122 async def fake_rebuild() -> None:
2123 provider._slug_to_fan_id["yerhot"] = 12345
2124
2125 with patch.object(provider, "_rebuild_slug_cache", side_effect=fake_rebuild):
2126 assert await provider._resolve_person_segment("yerhot") == 12345
2127
2128
2129def test_people_to_folders_no_url_falls_back_to_id(provider: BandcampProvider) -> None:
2130 """Test _people_to_folders uses numeric fan_id when url is None."""
2131 people = [_fan_mock(77, "NoUrl")]
2132 folders = provider._people_to_folders(people, "bandcamp_test://fans")
2133
2134 assert folders[0].path == "bandcamp_test://fans/77"
2135 assert "77" not in provider._slug_to_fan_id
2136
2137
2138# --- _browse_person dispatch routing tests ---
2139
2140
2141async def test_browse_fans_top_level(provider: BandcampProvider) -> None:
2142 """Test browsing 'fans' at top level fetches authenticated user's fans."""
2143 collection_items = [_fan_mock(1, "Fan1", url="https://bandcamp.com/fan1")]
2144
2145 with patch.object(
2146 provider, "_get_all_collection_items", new_callable=AsyncMock
2147 ) as mock_get_collection:
2148 mock_get_collection.return_value = collection_items
2149
2150 result = await provider.browse("bandcamp_test://fans")
2151
2152 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=None)
2153 assert len(result) == 1
2154 assert isinstance(result[0], BrowseFolder)
2155 assert result[0].path == "bandcamp_test://fans/fan1"
2156
2157
2158async def test_browse_followers_top_level(provider: BandcampProvider) -> None:
2159 """Test browsing 'followers' at top level fetches authenticated user's followers."""
2160 collection_items = [_fan_mock(2, "Follower1", url="https://bandcamp.com/follower1")]
2161
2162 with patch.object(
2163 provider, "_get_all_collection_items", new_callable=AsyncMock
2164 ) as mock_get_collection:
2165 mock_get_collection.return_value = collection_items
2166
2167 result = await provider.browse("bandcamp_test://followers")
2168
2169 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWERS, fan_id=None)
2170 assert len(result) == 1
2171 assert isinstance(result[0], BrowseFolder)
2172 assert result[0].path == "bandcamp_test://followers/follower1"
2173
2174
2175async def test_browse_fans_person_id_shows_subfolders(provider: BandcampProvider) -> None:
2176 """Test browsing 'fans/42' returns 5 sub-folders for person 42."""
2177 result = await provider.browse("bandcamp_test://fans/42")
2178
2179 assert len(result) == 5
2180 names = [f.name for f in result if isinstance(f, BrowseFolder)]
2181 assert "Collection" in names
2182 assert "Wishlist" in names
2183 assert "Following" in names
2184 assert "Fans" in names
2185 assert "Followers" in names
2186
2187
2188async def test_browse_person_collection(provider: BandcampProvider) -> None:
2189 """Test browsing fans/42/collection fetches person's collection."""
2190 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
2191 mock_album = Mock()
2192
2193 with (
2194 patch.object(
2195 provider, "_get_all_collection_items", new_callable=AsyncMock
2196 ) as mock_get_collection,
2197 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2198 ):
2199 mock_get_collection.return_value = collection_items
2200 mock_get_album.return_value = mock_album
2201
2202 result = await provider.browse("bandcamp_test://fans/42/collection")
2203
2204 mock_get_collection.assert_called_once_with(CollectionType.COLLECTION, fan_id=42)
2205 assert len(result) == 1
2206 assert result[0] is mock_album
2207
2208
2209async def test_browse_person_wishlist(provider: BandcampProvider) -> None:
2210 """Test browsing fans/42/wishlist fetches person's wishlist."""
2211 collection_items = [Mock(item_type="album", item_id=789, band_id=123)]
2212 mock_album = Mock()
2213
2214 with (
2215 patch.object(
2216 provider, "_get_all_collection_items", new_callable=AsyncMock
2217 ) as mock_get_collection,
2218 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2219 ):
2220 mock_get_collection.return_value = collection_items
2221 mock_get_album.return_value = mock_album
2222
2223 result = await provider.browse("bandcamp_test://fans/42/wishlist")
2224
2225 mock_get_collection.assert_called_once_with(CollectionType.WISHLIST, fan_id=42)
2226 assert len(result) == 1
2227 assert result[0] is mock_album
2228
2229
2230async def test_browse_person_following(provider: BandcampProvider) -> None:
2231 """Test browsing fans/42/following fetches person's followed artists."""
2232 collection_items = [Mock(spec=["band_id", "name"], band_id=100, name="Artist1")]
2233 mock_artist = Mock()
2234
2235 with (
2236 patch.object(
2237 provider, "_get_all_collection_items", new_callable=AsyncMock
2238 ) as mock_get_collection,
2239 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
2240 ):
2241 mock_get_collection.return_value = collection_items
2242 mock_get_artist.return_value = mock_artist
2243
2244 result = await provider.browse("bandcamp_test://fans/42/following")
2245
2246 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=42)
2247 assert len(result) == 1
2248 assert result[0] is mock_artist
2249
2250
2251async def test_browse_person_fans(provider: BandcampProvider) -> None:
2252 """Test browsing fans/42/fans fetches person 42's fans."""
2253 collection_items = [_fan_mock(99, "SubFan", url="https://bandcamp.com/subfan")]
2254
2255 with patch.object(
2256 provider, "_get_all_collection_items", new_callable=AsyncMock
2257 ) as mock_get_collection:
2258 mock_get_collection.return_value = collection_items
2259
2260 result = await provider.browse("bandcamp_test://fans/42/fans")
2261
2262 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
2263 assert len(result) == 1
2264 assert isinstance(result[0], BrowseFolder)
2265 assert result[0].path == "bandcamp_test://fans/42/fans/subfan"
2266
2267
2268async def test_browse_person_followers(provider: BandcampProvider) -> None:
2269 """Test browsing followers/42/followers fetches person 42's followers."""
2270 collection_items = [_fan_mock(88, "SubFollower", url="https://bandcamp.com/subfollower")]
2271
2272 with patch.object(
2273 provider, "_get_all_collection_items", new_callable=AsyncMock
2274 ) as mock_get_collection:
2275 mock_get_collection.return_value = collection_items
2276
2277 result = await provider.browse("bandcamp_test://followers/42/followers")
2278
2279 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWERS, fan_id=42)
2280 assert len(result) == 1
2281 assert isinstance(result[0], BrowseFolder)
2282 assert result[0].path == "bandcamp_test://followers/42/followers/subfollower"
2283
2284
2285async def test_browse_deep_nesting(provider: BandcampProvider) -> None:
2286 """Test deep social graph traversal: fans/42/fans/99 shows person 99's sub-folders."""
2287 result = await provider.browse("bandcamp_test://fans/42/fans/99")
2288
2289 assert len(result) == 5
2290 # Paths should include the full prefix
2291 for folder in result:
2292 assert isinstance(folder, BrowseFolder)
2293 assert folder.path.startswith("bandcamp_test://fans/42/fans/99/")
2294
2295
2296async def test_browse_deep_nesting_with_slug(provider: BandcampProvider) -> None:
2297 """Test slug-based navigation: fans/alice/fans resolves alice to fan_id."""
2298 # Pre-populate slug mapping (as if we had previously browsed fans)
2299 provider._slug_to_fan_id["alice"] = 42
2300
2301 collection_items = [
2302 _fan_mock(99, "Bob", url="https://bandcamp.com/bob"),
2303 ]
2304
2305 with patch.object(
2306 provider, "_get_all_collection_items", new_callable=AsyncMock
2307 ) as mock_get_collection:
2308 mock_get_collection.return_value = collection_items
2309
2310 result = await provider.browse("bandcamp_test://fans/alice/fans")
2311
2312 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
2313 assert len(result) == 1
2314 assert isinstance(result[0], BrowseFolder)
2315 assert result[0].path == "bandcamp_test://fans/alice/fans/bob"
2316
2317
2318async def test_browse_slug_person_root(provider: BandcampProvider) -> None:
2319 """Test navigating to a slug-identified person shows 5 sub-folders."""
2320 provider._slug_to_fan_id["teancom"] = 42
2321
2322 result = await provider.browse("bandcamp_test://fans/teancom")
2323
2324 assert len(result) == 5
2325 for folder in result:
2326 assert isinstance(folder, BrowseFolder)
2327 assert folder.path.startswith("bandcamp_test://fans/teancom/")
2328
2329
2330async def test_browse_person_invalid_id_zero(provider: BandcampProvider) -> None:
2331 """Test that person ID of 0 raises InvalidDataError."""
2332 with pytest.raises(InvalidDataError, match="Invalid person ID"):
2333 await provider.browse("bandcamp_test://fans/0")
2334
2335
2336async def test_browse_person_invalid_id_negative(provider: BandcampProvider) -> None:
2337 """Test that negative person ID raises InvalidDataError."""
2338 with pytest.raises(InvalidDataError, match="Invalid person ID"):
2339 await provider.browse("bandcamp_test://fans/-1")
2340
2341
2342async def test_browse_person_unknown_subcategory(provider: BandcampProvider) -> None:
2343 """Test that an unknown sub-category raises InvalidDataError."""
2344 with (
2345 patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock),
2346 pytest.raises(InvalidDataError, match="Unknown browse sub-category"),
2347 ):
2348 await provider.browse("bandcamp_test://fans/42/playlists")
2349
2350
2351async def test_browse_person_invalid_path_no_id(provider: BandcampProvider) -> None:
2352 """Test that sub-category without valid person ID raises InvalidDataError."""
2353 with (
2354 patch.object(provider, "_rebuild_slug_cache", new_callable=AsyncMock),
2355 pytest.raises(InvalidDataError, match="Invalid browse path"),
2356 ):
2357 await provider.browse("bandcamp_test://fans/abc/collection")
2358
2359
2360# --- _browse_person_content with explicit person_id ---
2361
2362
2363async def test_browse_person_content_with_person_id(provider: BandcampProvider) -> None:
2364 """Test _browse_person_content with explicit person_id passes fan_id."""
2365 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
2366 mock_album = Mock()
2367
2368 with (
2369 patch.object(
2370 provider, "_get_all_collection_items", new_callable=AsyncMock
2371 ) as mock_get_collection,
2372 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2373 ):
2374 mock_get_collection.return_value = collection_items
2375 mock_get_album.return_value = mock_album
2376
2377 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
2378
2379 mock_get_collection.assert_called_once_with(CollectionType.COLLECTION, fan_id=42)
2380 assert len(result) == 1
2381 assert result[0] is mock_album
2382
2383
2384async def test_browse_person_content_caches_results(provider: BandcampProvider) -> None:
2385 """Test _browse_person_content caches non-empty results."""
2386 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
2387 mock_album = Mock()
2388
2389 with (
2390 patch.object(
2391 provider, "_get_all_collection_items", new_callable=AsyncMock
2392 ) as mock_get_collection,
2393 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2394 ):
2395 mock_get_collection.return_value = collection_items
2396 mock_get_album.return_value = mock_album
2397
2398 await provider._browse_person_content(42, CollectionType.WISHLIST)
2399
2400 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
2401 mock_cache_set.assert_called_once()
2402 cache_key = mock_cache_set.call_args[0][0]
2403 assert "42" in cache_key
2404 assert CollectionType.WISHLIST.value in cache_key
2405
2406
2407async def test_browse_person_content_cache_hit(provider: BandcampProvider) -> None:
2408 """Test _browse_person_content returns cached result without hitting API."""
2409 cached_items = [
2410 Album(
2411 item_id="1-100",
2412 provider="bandcamp",
2413 name="Cached Album",
2414 provider_mappings=set(),
2415 ).to_dict(),
2416 Track(
2417 item_id="1-100-200",
2418 provider="bandcamp",
2419 name="Cached Track",
2420 provider_mappings=set(),
2421 ).to_dict(),
2422 ]
2423
2424 with (
2425 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached_items),
2426 patch.object(
2427 provider, "_get_all_collection_items", new_callable=AsyncMock
2428 ) as mock_get_collection,
2429 ):
2430 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
2431
2432 mock_get_collection.assert_not_called()
2433 assert len(result) == 2
2434 assert isinstance(result[0], Album)
2435 assert isinstance(result[1], Track)
2436 assert result[0].name == "Cached Album"
2437 assert result[1].name == "Cached Track"
2438
2439
2440async def test_browse_person_content_cache_hit_stale_data(
2441 provider: BandcampProvider, caplog: pytest.LogCaptureFixture
2442) -> None:
2443 """Test _browse_person_content falls through to API on stale/corrupt cache."""
2444 stale_cache = [{"garbage": True}]
2445
2446 with (
2447 patch.object(provider.mass.cache, "get", new_callable=AsyncMock, return_value=stale_cache),
2448 patch.object(
2449 provider, "_get_all_collection_items", new_callable=AsyncMock
2450 ) as mock_get_collection,
2451 ):
2452 mock_get_collection.return_value = []
2453 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
2454
2455 mock_get_collection.assert_called_once()
2456 assert result == []
2457 assert "Stale cache" in caplog.text
2458
2459
2460async def test_browse_person_content_empty_cached_with_short_ttl(
2461 provider: BandcampProvider,
2462) -> None:
2463 """Test empty results are cached with CACHE_EMPTY_RESULTS TTL."""
2464 with patch.object(
2465 provider, "_get_all_collection_items", new_callable=AsyncMock
2466 ) as mock_get_collection:
2467 mock_get_collection.return_value = []
2468
2469 result = await provider._browse_person_content(42, CollectionType.COLLECTION)
2470
2471 assert result == []
2472 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
2473 mock_cache_set.assert_called_once()
2474 assert mock_cache_set.call_args.kwargs["expiration"] == CACHE_EMPTY_RESULTS
2475
2476
2477async def test_browse_person_content_nonempty_cached_with_normal_ttl(
2478 provider: BandcampProvider,
2479) -> None:
2480 """Test non-empty results are cached with CACHE_USER_LISTS TTL."""
2481 collection_items = [Mock(item_type="album", item_id=456, band_id=123)]
2482 mock_album = Mock()
2483
2484 with (
2485 patch.object(
2486 provider, "_get_all_collection_items", new_callable=AsyncMock
2487 ) as mock_get_collection,
2488 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2489 ):
2490 mock_get_collection.return_value = collection_items
2491 mock_get_album.return_value = mock_album
2492
2493 await provider._browse_person_content(42, CollectionType.COLLECTION)
2494
2495 mock_cache_set = cast("AsyncMock", provider.mass.cache.set)
2496 mock_cache_set.assert_called_once()
2497 assert mock_cache_set.call_args.kwargs["expiration"] == CACHE_USER_LISTS
2498
2499
2500async def test_browse_person_content_api_error(provider: BandcampProvider) -> None:
2501 """Test _browse_person_content maps generic API error via _map_api_errors."""
2502 with (
2503 patch.object(
2504 provider,
2505 "_get_all_collection_items",
2506 side_effect=BandcampAPIError("API Error"),
2507 ),
2508 pytest.raises(MediaNotFoundError, match="Failed to get"),
2509 ):
2510 await provider._browse_person_content(42, CollectionType.COLLECTION)
2511
2512
2513# --- _browse_person_following with explicit person_id ---
2514
2515
2516async def test_browse_person_following_with_person_id(provider: BandcampProvider) -> None:
2517 """Test _browse_person_following with explicit person_id."""
2518 collection_items = [Mock(spec=["band_id", "name"], band_id=100, name="Artist1")]
2519 mock_artist = Mock()
2520
2521 with (
2522 patch.object(
2523 provider, "_get_all_collection_items", new_callable=AsyncMock
2524 ) as mock_get_collection,
2525 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
2526 ):
2527 mock_get_collection.return_value = collection_items
2528 mock_get_artist.return_value = mock_artist
2529
2530 result = await provider._browse_person_following(42)
2531
2532 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING, fan_id=42)
2533 assert len(result) == 1
2534 assert result[0] is mock_artist
2535
2536
2537async def test_browse_person_following_cache_hit(provider: BandcampProvider) -> None:
2538 """Test _browse_person_following returns cached Artist objects without calling API."""
2539 cached_artists = [
2540 Artist(
2541 item_id="100",
2542 provider="bandcamp",
2543 name="Cached Artist",
2544 provider_mappings=set(),
2545 ),
2546 ]
2547
2548 with (
2549 patch.object(
2550 provider.mass.cache, "get", new_callable=AsyncMock, return_value=cached_artists
2551 ),
2552 patch.object(
2553 provider, "_get_all_collection_items", new_callable=AsyncMock
2554 ) as mock_get_collection,
2555 ):
2556 result = await provider._browse_person_following(42)
2557
2558 mock_get_collection.assert_not_called()
2559 assert len(result) == 1
2560 assert isinstance(result[0], Artist)
2561 assert result[0].name == "Cached Artist"
2562
2563
2564async def test_browse_person_following_skips_not_found(provider: BandcampProvider) -> None:
2565 """Test _browse_person_following logs warning and skips unfound artists."""
2566 collection_items = [
2567 Mock(spec=["band_id", "name"], band_id=100, name="Found"),
2568 Mock(spec=["band_id", "name"], band_id=200, name="NotFound"),
2569 ]
2570 mock_artist = Mock()
2571
2572 with (
2573 patch.object(
2574 provider, "_get_all_collection_items", new_callable=AsyncMock
2575 ) as mock_get_collection,
2576 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
2577 ):
2578 mock_get_collection.return_value = collection_items
2579 mock_get_artist.side_effect = [mock_artist, MediaNotFoundError("not found")]
2580
2581 result = await provider._browse_person_following(42)
2582
2583 assert len(result) == 1
2584
2585
2586# --- _browse_person_people tests ---
2587
2588
2589async def test_browse_person_people_with_person_id(provider: BandcampProvider) -> None:
2590 """Test _browse_person_people with explicit person_id fetches their fans."""
2591 collection_items = [_fan_mock(10, "Fan", "http://img.jpg", "https://bandcamp.com/thefan")]
2592
2593 with patch.object(
2594 provider, "_get_all_collection_items", new_callable=AsyncMock
2595 ) as mock_get_collection:
2596 mock_get_collection.return_value = collection_items
2597
2598 result = await provider._browse_person_people(
2599 CollectionType.FOLLOWING_FANS, "bandcamp_test://fans/42/fans", person_id=42
2600 )
2601
2602 mock_get_collection.assert_called_once_with(CollectionType.FOLLOWING_FANS, fan_id=42)
2603 assert len(result) == 1
2604 assert isinstance(result[0], BrowseFolder)
2605 assert result[0].path == "bandcamp_test://fans/42/fans/thefan"
2606
2607
2608async def test_browse_person_people_api_error(provider: BandcampProvider) -> None:
2609 """Test _browse_person_people maps API error via _map_api_errors."""
2610 with (
2611 patch.object(
2612 provider,
2613 "_get_all_collection_items",
2614 side_effect=BandcampAPIError("API Error"),
2615 ),
2616 pytest.raises(MediaNotFoundError, match="Failed to get"),
2617 ):
2618 await provider._browse_person_people(
2619 CollectionType.FOLLOWERS, "bandcamp_test://followers", person_id=42
2620 )
2621
2622
2623async def test_browse_person_people_cache_hit_rebuilds_slugs(
2624 provider: BandcampProvider,
2625) -> None:
2626 """Test _browse_person_people deserializes cached dicts and repopulates slugs."""
2627 cached_folders = [
2628 BrowseFolder(
2629 item_id="person_111",
2630 provider="bandcamp_test",
2631 path="bandcamp_test://fans/coolslug",
2632 name="Cool User",
2633 ),
2634 BrowseFolder(
2635 item_id="person_222",
2636 provider="bandcamp_test",
2637 path="bandcamp_test://fans/anotherslug",
2638 name="Another User",
2639 ),
2640 ]
2641
2642 async def fake_cache_get(key: str, **kwargs: object) -> list[BrowseFolder] | None: # noqa: ARG001
2643 if "_browse_person_people_" in key:
2644 return cached_folders
2645 return None
2646
2647 provider._slug_to_fan_id.clear()
2648
2649 with patch.object(provider.mass.cache, "get", new_callable=AsyncMock) as mock_cache_get:
2650 mock_cache_get.side_effect = fake_cache_get
2651
2652 result = await provider._browse_person_people(
2653 CollectionType.FOLLOWING_FANS, "bandcamp_test://fans"
2654 )
2655
2656 assert len(result) == 2
2657 assert all(isinstance(f, BrowseFolder) for f in result)
2658 assert result[0].name == "Cool User"
2659 assert result[1].name == "Another User"
2660 assert provider._slug_to_fan_id["coolslug"] == 111
2661 assert provider._slug_to_fan_id["anotherslug"] == 222
2662
2663
2664async def test_rebuild_slug_cache_calls_browse_person_people(
2665 provider: BandcampProvider,
2666) -> None:
2667 """Test _rebuild_slug_cache fetches both fans and followers lists."""
2668 with patch.object(provider, "_browse_person_people", new_callable=AsyncMock) as mock_browse:
2669 mock_browse.return_value = []
2670
2671 await provider._rebuild_slug_cache()
2672
2673 assert mock_browse.call_count == 2
2674 calls = mock_browse.call_args_list
2675 assert calls[0].args == (CollectionType.FOLLOWING_FANS, "bandcamp_test://fans")
2676 assert calls[1].args == (CollectionType.FOLLOWERS, "bandcamp_test://followers")
2677
2678
2679# --- Pagination tests ---
2680
2681
2682def _make_collection_page(
2683 items: list[Mock],
2684 has_more: bool = False,
2685 last_token: str | None = None,
2686) -> Mock:
2687 """Create a mock CollectionSummary page."""
2688 page = Mock()
2689 page.items = items
2690 page.has_more = has_more
2691 page.last_token = last_token
2692 return page
2693
2694
2695async def test_get_all_collection_items_single_page(provider: BandcampProvider) -> None:
2696 """Test _get_all_collection_items with a single page."""
2697 page = _make_collection_page(
2698 [Mock(item_type="album", item_id=1, band_id=10)],
2699 has_more=False,
2700 )
2701 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2702 mock_get.return_value = page
2703
2704 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
2705
2706 assert len(result) == 1
2707 mock_get.assert_called_once()
2708
2709
2710async def test_get_all_collection_items_multiple_pages(provider: BandcampProvider) -> None:
2711 """Test _get_all_collection_items follows pagination across multiple pages."""
2712 page1 = _make_collection_page(
2713 [Mock(item_type="album", item_id=i, band_id=10) for i in range(50)],
2714 has_more=True,
2715 last_token="token_page2",
2716 )
2717 page2 = _make_collection_page(
2718 [Mock(item_type="album", item_id=i, band_id=10) for i in range(50, 100)],
2719 has_more=True,
2720 last_token="token_page3",
2721 )
2722 page3 = _make_collection_page(
2723 [Mock(item_type="album", item_id=i, band_id=10) for i in range(100, 120)],
2724 has_more=False,
2725 )
2726
2727 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2728 mock_get.side_effect = [page1, page2, page3]
2729
2730 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
2731
2732 assert len(result) == 120
2733 assert mock_get.call_count == 3
2734 # First call has no older_than_token (positional arg index 1)
2735 assert mock_get.call_args_list[0].args[1] is None
2736 # Subsequent calls pass the last_token from the previous page
2737 assert mock_get.call_args_list[1].args[1] == "token_page2"
2738 assert mock_get.call_args_list[2].args[1] == "token_page3"
2739
2740
2741async def test_get_all_collection_items_stops_on_missing_last_token(
2742 provider: BandcampProvider,
2743) -> None:
2744 """Test _get_all_collection_items stops when has_more is True but last_token is None."""
2745 page = _make_collection_page(
2746 [Mock(item_type="album", item_id=1, band_id=10)],
2747 has_more=True,
2748 last_token=None,
2749 )
2750 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2751 mock_get.return_value = page
2752
2753 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
2754
2755 assert len(result) == 1
2756 mock_get.assert_called_once()
2757
2758
2759async def test_get_all_collection_items_passes_fan_id(provider: BandcampProvider) -> None:
2760 """Test _get_all_collection_items forwards fan_id to the API client."""
2761 page = _make_collection_page([], has_more=False)
2762 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2763 mock_get.return_value = page
2764
2765 await provider._get_all_collection_items(CollectionType.WISHLIST, fan_id=42)
2766
2767 mock_get.assert_called_once_with(CollectionType.WISHLIST, None, 42)
2768
2769
2770async def test_get_library_albums_paginates(provider: BandcampProvider) -> None:
2771 """Test get_library_albums yields albums from all pages."""
2772 page1 = _make_collection_page(
2773 [Mock(item_type="album", item_id=i, band_id=10) for i in range(1, 4)],
2774 has_more=True,
2775 last_token="tok2",
2776 )
2777 page2 = _make_collection_page(
2778 [Mock(item_type="album", item_id=i, band_id=10) for i in range(4, 6)],
2779 has_more=False,
2780 )
2781
2782 with (
2783 patch.object(provider._client, "get_collection_items", new_callable=AsyncMock) as mock_get,
2784 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2785 ):
2786 mock_get.side_effect = [page1, page2]
2787 mock_get_album.return_value = Mock()
2788
2789 albums = [album async for album in provider.get_library_albums()]
2790
2791 assert len(albums) == 5
2792 assert mock_get.call_count == 2
2793
2794
2795async def test_get_library_artists_paginates(provider: BandcampProvider) -> None:
2796 """Test get_library_artists yields artists from all pages."""
2797 page1 = _make_collection_page(
2798 [Mock(item_type="album", item_id=1, band_id=100)],
2799 has_more=True,
2800 last_token="tok2",
2801 )
2802 page2 = _make_collection_page(
2803 [Mock(item_type="album", item_id=2, band_id=200)],
2804 has_more=False,
2805 )
2806
2807 with (
2808 patch.object(provider._client, "get_collection_items", new_callable=AsyncMock) as mock_get,
2809 patch.object(provider, "get_artist", new_callable=AsyncMock) as mock_get_artist,
2810 ):
2811 mock_get.side_effect = [page1, page2]
2812 mock_get_artist.return_value = Mock()
2813
2814 artists = [artist async for artist in provider.get_library_artists()]
2815
2816 assert len(artists) == 2
2817 assert mock_get.call_count == 2
2818 # Band IDs 100 and 200 from pages 1 and 2, converted to str per base class contract
2819 called_ids = {call.args[0] for call in mock_get_artist.call_args_list}
2820 assert called_ids == {"100", "200"}
2821
2822
2823async def test_get_all_collection_items_error_mid_pagination(
2824 provider: BandcampProvider,
2825) -> None:
2826 """Test that an API error on page 2 propagates without returning partial results."""
2827 page1 = _make_collection_page(
2828 [Mock(item_type="album", item_id=1, band_id=10)],
2829 has_more=True,
2830 last_token="token_page2",
2831 )
2832
2833 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2834 mock_get.side_effect = [page1, BandcampRateLimitError(retry_after=3)]
2835
2836 with pytest.raises(BandcampRateLimitError):
2837 await provider._get_all_collection_items(CollectionType.COLLECTION)
2838
2839 assert mock_get.call_count == 2
2840
2841
2842async def test_browse_person_content_returns_only_resolved_items(
2843 provider: BandcampProvider,
2844) -> None:
2845 """
2846 Test that _browse_person_content returns only resolved Album/Track objects.
2847
2848 Regression test: a previous version reused the same list variable for both
2849 the raw API items and the resolved results, which mixed CollectionItem
2850 objects into the returned list.
2851 """
2852 raw_items = [
2853 Mock(item_type="album", item_id=456, band_id=123),
2854 Mock(item_type="track", item_id=789, band_id=123),
2855 ]
2856 mock_album = Mock(spec=Album)
2857 mock_track = Mock(spec=Track)
2858
2859 with (
2860 patch.object(
2861 provider, "_get_all_collection_items", new_callable=AsyncMock
2862 ) as mock_get_collection,
2863 patch.object(provider, "get_album", new_callable=AsyncMock) as mock_get_album,
2864 patch.object(provider, "get_track", new_callable=AsyncMock) as mock_get_track,
2865 ):
2866 mock_get_collection.return_value = raw_items
2867 mock_get_album.return_value = mock_album
2868 mock_get_track.return_value = mock_track
2869
2870 result = await provider._browse_person_content(42, CollectionType.WISHLIST)
2871
2872 assert len(result) == 2
2873 assert result[0] is mock_album
2874 assert result[1] is mock_track
2875 # Verify no raw CollectionItem objects leaked into the result
2876 for item in result:
2877 assert item is not raw_items[0]
2878 assert item is not raw_items[1]
2879
2880
2881async def test_get_all_collection_items_detects_token_loop(
2882 provider: BandcampProvider,
2883 caplog: pytest.LogCaptureFixture,
2884) -> None:
2885 """Test _get_all_collection_items stops when the same token repeats."""
2886 stuck_page = _make_collection_page(
2887 [Mock(item_type="album", item_id=1, band_id=10)],
2888 has_more=True,
2889 last_token="same_token_forever",
2890 )
2891
2892 with patch.object(provider, "_fetch_collection_page", new_callable=AsyncMock) as mock_get:
2893 mock_get.return_value = stuck_page
2894
2895 result = await provider._get_all_collection_items(CollectionType.COLLECTION)
2896
2897 # Should fetch page 1 (token not yet seen), then page 2 (token repeated â stop)
2898 assert mock_get.call_count == 2
2899 assert len(result) == 2
2900 assert "Pagination loop detected" in caplog.text
2901