/
/
/
1"""Test Bandcamp's two-method recommendations contract."""
2
3from unittest.mock import AsyncMock, Mock, patch
4
5import pytest
6from bandcamp_async_api.models import CollectionType
7
8from music_assistant.providers.bandcamp import BandcampProvider
9from music_assistant.providers.bandcamp.constants import SUPPORTED_FEATURES
10
11
12@pytest.fixture
13def mass_mock() -> Mock:
14 """Return a mock MusicAssistant instance."""
15 mass = Mock()
16 mass.http_session = AsyncMock()
17 mass.metadata.locale = "en_US"
18 mass.cache.get = AsyncMock(return_value=None)
19 mass.cache.get_with_freshness = AsyncMock(return_value=(None, False, False))
20 mass.cache.set = AsyncMock()
21 mass.cache.delete = AsyncMock()
22 # setup_data is unset in these unit tests, so get_setup_value falls through to
23 # the provider config's get_value (which the config mock stubs)
24 mass.config.get = Mock(return_value=None)
25 mass.config.get_raw_provider_config_value = Mock(return_value=None)
26 return mass
27
28
29@pytest.fixture
30def config_mock() -> Mock:
31 """Return a mock provider config."""
32 config = Mock()
33 config.name = "Bandcamp Test"
34 config.instance_id = "bandcamp_test"
35 config.enabled = True
36 config.values = {}
37 config.get_value.side_effect = lambda key, default=None: {
38 "identity": "mock_identity_token",
39 "search_limit": 10,
40 "top_tracks_limit": 50,
41 "log_level": "INFO",
42 }.get(key, default)
43 return config
44
45
46@pytest.fixture
47async def provider(mass_mock: Mock, manifest_mock: Mock, config_mock: Mock) -> BandcampProvider:
48 """Return a BandcampProvider instance with a reset (call-free) mock client."""
49 provider = BandcampProvider(mass_mock, manifest_mock, config_mock, SUPPORTED_FEATURES)
50
51 with patch("music_assistant.providers.bandcamp.BandcampAPIClient") as mock_client_class:
52 mock_client_class.return_value = AsyncMock()
53 await provider.handle_async_init()
54
55 # Discard the login-validation call from init so tests can assert zero backend calls,
56 # and use a real string for identity so the auth gate does not register a mock call.
57 provider._client.reset_mock()
58 provider._client.identity = "mock_identity_token"
59 return provider
60
61
62async def test_get_recommendations_static_rows_without_backend_calls(
63 provider: BandcampProvider,
64) -> None:
65 """get_recommendations returns the feed and wishlist rows without items or backend I/O."""
66 with (
67 patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed,
68 patch.object(provider, "_browse_person_content", new_callable=AsyncMock) as mock_wishlist,
69 ):
70 rows = await provider.get_recommendations()
71
72 mock_feed.assert_not_called()
73 mock_wishlist.assert_not_called()
74 assert provider._client.mock_calls == []
75 assert [f.item_id for f in rows] == ["feed", "wishlist"]
76 feed, wishlist = rows
77 assert feed.name == "Bandcamp Feed"
78 assert feed.translation_key == "feed"
79 assert feed.icon == "mdi-rss"
80 assert wishlist.name == "Wishlist"
81 assert wishlist.translation_key == "wishlist"
82 assert wishlist.icon == "mdi-heart"
83 assert all(f.provider == "bandcamp_test" for f in rows)
84 assert all(f.is_playable for f in rows)
85 assert all(not f.items for f in rows)
86
87
88async def test_get_recommendations_no_identity(provider: BandcampProvider) -> None:
89 """get_recommendations returns no rows without an identity token."""
90 provider._client.identity = None
91 assert await provider.get_recommendations() == []
92
93
94async def test_get_recommendation_items_feed_fetches_only_feed(
95 provider: BandcampProvider,
96) -> None:
97 """Items for the feed row trigger only the feed fetch."""
98 feed_track = Mock()
99
100 with (
101 patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed,
102 patch.object(provider, "_browse_person_content", new_callable=AsyncMock) as mock_wishlist,
103 ):
104 mock_feed.return_value = [feed_track]
105
106 result = await provider.get_recommendation_items("feed")
107
108 mock_feed.assert_awaited_once_with()
109 mock_wishlist.assert_not_called()
110 assert list(result) == [feed_track]
111
112
113async def test_get_recommendation_items_wishlist_fetches_only_wishlist(
114 provider: BandcampProvider,
115) -> None:
116 """Items for the wishlist row trigger only the wishlist fetch."""
117 wishlist_album = Mock()
118
119 with (
120 patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed,
121 patch.object(provider, "_browse_person_content", new_callable=AsyncMock) as mock_wishlist,
122 ):
123 mock_wishlist.return_value = [wishlist_album]
124
125 result = await provider.get_recommendation_items("wishlist")
126
127 mock_feed.assert_not_called()
128 mock_wishlist.assert_awaited_once_with(None, CollectionType.WISHLIST)
129 assert list(result) == [wishlist_album]
130
131
132async def test_get_recommendation_items_unknown_id_returns_empty(
133 provider: BandcampProvider,
134) -> None:
135 """An unknown row item_id returns an empty list without any fetch."""
136 with (
137 patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed,
138 patch.object(provider, "_browse_person_content", new_callable=AsyncMock) as mock_wishlist,
139 ):
140 result = await provider.get_recommendation_items("unknown")
141
142 mock_feed.assert_not_called()
143 mock_wishlist.assert_not_called()
144 assert list(result) == []
145
146
147async def test_get_recommendation_items_no_identity(provider: BandcampProvider) -> None:
148 """Items return empty without an identity token, without any fetch."""
149 provider._client.identity = None
150
151 with patch.object(provider, "_get_feed_tracks", new_callable=AsyncMock) as mock_feed:
152 result = await provider.get_recommendation_items("feed")
153
154 mock_feed.assert_not_called()
155 assert list(result) == []
156