/
/
/
1"""Test Tidal Playlist Manager."""
2
3from unittest.mock import AsyncMock, Mock
4
5import pytest
6from aiohttp.client_exceptions import ClientError
7from music_assistant_models.errors import ResourceTemporarilyUnavailable
8
9from music_assistant.providers.tidal.playlist import TidalPlaylistManager
10
11
12@pytest.fixture
13def playlist_manager(provider_mock: Mock) -> TidalPlaylistManager:
14 """Return a TidalPlaylistManager instance."""
15 return TidalPlaylistManager(provider_mock)
16
17
18async def test_create_playlist(playlist_manager: TidalPlaylistManager, provider_mock: Mock) -> None:
19 """Test create posts to the v1 user playlists endpoint and is marked editable."""
20 provider_mock.api.post.return_value = {"uuid": "pl1", "title": "Test Playlist"}
21
22 playlist = await playlist_manager.create("Test Playlist")
23
24 assert playlist.item_id == "pl1"
25 # A freshly created playlist is forced editable/owned regardless of the echo.
26 assert playlist.is_editable is True
27 assert playlist.owner == "Test User"
28 assert next(iter(playlist.provider_mappings)).is_unique is True
29 provider_mock.api.post.assert_called_with(
30 "users/12345/playlists",
31 data={"title": "Test Playlist", "description": ""},
32 as_form=True,
33 )
34
35
36async def test_create_playlist_failure(
37 playlist_manager: TidalPlaylistManager, provider_mock: Mock
38) -> None:
39 """Test create raises ResourceTemporarilyUnavailable on a client error."""
40 provider_mock.api.post.side_effect = ClientError()
41
42 with pytest.raises(ResourceTemporarilyUnavailable):
43 await playlist_manager.create("Test Playlist")
44
45
46async def test_add_playlist_tracks_single(
47 playlist_manager: TidalPlaylistManager, provider_mock: Mock
48) -> None:
49 """Test add_tracks appends via v1 items with the playlist ETag."""
50 provider_mock.api.get_with_etag.return_value = ({"numberOfTracks": 3}, "etag-1")
51
52 await playlist_manager.add_tracks("1", ["track_1"])
53
54 provider_mock.api.get_with_etag.assert_called_once_with("playlists/1")
55 provider_mock.api.post.assert_called_once_with(
56 "playlists/1/items",
57 data={
58 "onArtifactNotFound": "SKIP",
59 "trackIds": "track_1",
60 "toIndex": 3,
61 "onDupes": "SKIP",
62 },
63 as_form=True,
64 headers={"If-None-Match": "etag-1"},
65 )
66
67
68async def test_add_playlist_tracks_multiple(
69 playlist_manager: TidalPlaylistManager, provider_mock: Mock
70) -> None:
71 """Test add_tracks joins track ids and appends at the current end index."""
72 provider_mock.api.get_with_etag.return_value = ({"numberOfTracks": 10}, "etag-2")
73
74 await playlist_manager.add_tracks("1", ["track_1", "track_2"])
75
76 provider_mock.api.post.assert_called_once_with(
77 "playlists/1/items",
78 data={
79 "onArtifactNotFound": "SKIP",
80 "trackIds": "track_1,track_2",
81 "toIndex": 10,
82 "onDupes": "SKIP",
83 },
84 as_form=True,
85 headers={"If-None-Match": "etag-2"},
86 )
87
88
89async def test_add_playlist_tracks_redirects_cached_stale_ids(
90 playlist_manager: TidalPlaylistManager, provider_mock: Mock
91) -> None:
92 """
93 Test a cache-known stale id is redirected to its live id before the v1 add.
94
95 v1 silently skips dead ids (onArtifactNotFound=SKIP), so without the cache-only
96 redirect a churned id would be dropped; with it, the live id is sent instead.
97 """
98
99 async def _redirect(item_id: str) -> str:
100 return "track_1_live" if item_id == "track_1" else item_id
101
102 provider_mock.redirect_cached_id = AsyncMock(side_effect=_redirect)
103 provider_mock.api.get_with_etag.return_value = ({"numberOfTracks": 0}, "etag-3")
104
105 await playlist_manager.add_tracks("1", ["track_1", "track_2"])
106
107 assert provider_mock.api.post.call_args.kwargs["data"]["trackIds"] == "track_1_live,track_2"
108
109
110async def test_add_playlist_tracks_without_etag(
111 playlist_manager: TidalPlaylistManager, provider_mock: Mock
112) -> None:
113 """Test add_tracks omits the If-None-Match header when no ETag is returned."""
114 provider_mock.api.get_with_etag.return_value = ({"numberOfTracks": 0}, "")
115
116 await playlist_manager.add_tracks("1", ["track_1"])
117
118 assert provider_mock.api.post.call_args.kwargs["headers"] == {}
119
120
121async def test_add_playlist_tracks_failure(
122 playlist_manager: TidalPlaylistManager, provider_mock: Mock
123) -> None:
124 """Test add_tracks raises ResourceTemporarilyUnavailable on a client error."""
125 provider_mock.api.get_with_etag.side_effect = ClientError()
126
127 with pytest.raises(ResourceTemporarilyUnavailable):
128 await playlist_manager.add_tracks("1", ["track_1"])
129
130
131async def test_remove_playlist_tracks(
132 playlist_manager: TidalPlaylistManager, provider_mock: Mock
133) -> None:
134 """Test remove_tracks deletes v1 items by 0-based index with the playlist ETag."""
135 provider_mock.api.get_with_etag.return_value = ({}, "etag-r")
136
137 await playlist_manager.remove_tracks("1", (1, 3))
138
139 provider_mock.api.get_with_etag.assert_called_once_with("playlists/1")
140 provider_mock.api.delete.assert_called_once_with(
141 "playlists/1/items/0,2",
142 headers={"If-None-Match": "etag-r"},
143 )
144
145
146async def test_remove_playlist_tracks_single(
147 playlist_manager: TidalPlaylistManager, provider_mock: Mock
148) -> None:
149 """Test a single 1-based position maps to its 0-based index."""
150 provider_mock.api.get_with_etag.return_value = ({}, "etag-r")
151
152 await playlist_manager.remove_tracks("1", (2,))
153
154 provider_mock.api.delete.assert_called_once_with(
155 "playlists/1/items/1",
156 headers={"If-None-Match": "etag-r"},
157 )
158
159
160async def test_remove_playlist_tracks_without_etag(
161 playlist_manager: TidalPlaylistManager, provider_mock: Mock
162) -> None:
163 """Test remove_tracks omits the If-None-Match header when no ETag is returned."""
164 provider_mock.api.get_with_etag.return_value = ({}, "")
165
166 await playlist_manager.remove_tracks("1", (1,))
167
168 assert provider_mock.api.delete.call_args.kwargs["headers"] == {}
169
170
171async def test_remove_playlist_tracks_empty_positions_skips_calls(
172 playlist_manager: TidalPlaylistManager, provider_mock: Mock
173) -> None:
174 """Test remove_tracks with no positions returns before fetching the ETag or deleting."""
175 await playlist_manager.remove_tracks("1", ())
176
177 provider_mock.api.get_with_etag.assert_not_called()
178 provider_mock.api.delete.assert_not_called()
179
180
181async def test_remove_playlist_tracks_failure(
182 playlist_manager: TidalPlaylistManager, provider_mock: Mock
183) -> None:
184 """Test remove_tracks raises ResourceTemporarilyUnavailable on a client error."""
185 provider_mock.api.get_with_etag.return_value = ({}, "etag-r")
186 provider_mock.api.delete.side_effect = ClientError()
187
188 with pytest.raises(ResourceTemporarilyUnavailable):
189 await playlist_manager.remove_tracks("1", (1,))
190