/
/
1"""Test Tidal API Client."""
2
3from typing import Any
4from unittest.mock import AsyncMock, MagicMock, Mock
5
6import pytest
7from aiohttp import ClientResponse
8from music_assistant_models.errors import (
9 LoginFailed,
10 MediaNotFoundError,
11 RetriesExhausted,
12)
13
14from music_assistant.providers.tidal.api_client import TidalAPIClient
15
16
17@pytest.fixture
18def api_client(provider_mock: Mock) -> TidalAPIClient:
19 """Return a TidalAPIClient instance."""
20 return TidalAPIClient(provider_mock)
21
22
23async def test_get_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
24 """Test successful GET request."""
25 response = AsyncMock(spec=ClientResponse)
26 response.status = 200
27 response.json.return_value = {"data": "test"}
28
29 # Create a mock that acts as an async context manager
30 request_ctx = AsyncMock()
31 request_ctx.__aenter__.return_value = response
32
33 # The request method itself should be a MagicMock (not AsyncMock)
34 # that returns the context manager
35 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
36
37 result = await api_client.get("test/endpoint")
38 assert result == {"data": "test"}
39
40
41async def test_get_401_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
42 """Test GET request with 401 error and a failing token refresh."""
43 response = AsyncMock(spec=ClientResponse)
44 response.status = 401
45
46 request_ctx = AsyncMock()
47 request_ctx.__aenter__.return_value = response
48 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
49 provider_mock.auth.refresh_token.return_value = False
50
51 with pytest.raises(LoginFailed):
52 await api_client.get("test/endpoint")
53
54 provider_mock.auth.refresh_token.assert_called_once()
55
56
57async def test_get_401_refreshes_token_and_retries(
58 api_client: TidalAPIClient, provider_mock: Mock
59) -> None:
60 """Test that a 401 response forces a token refresh and retries the request once."""
61 response_401 = AsyncMock(spec=ClientResponse)
62 response_401.status = 401
63
64 response_ok = AsyncMock(spec=ClientResponse)
65 response_ok.status = 200
66 response_ok.json.return_value = {"data": "test"}
67
68 ctx1 = AsyncMock()
69 ctx1.__aenter__.return_value = response_401
70 ctx2 = AsyncMock()
71 ctx2.__aenter__.return_value = response_ok
72 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2])
73 provider_mock.auth.refresh_token.return_value = True
74
75 result = await api_client.get("test/endpoint")
76
77 assert result == {"data": "test"}
78 provider_mock.auth.refresh_token.assert_called_once()
79 assert provider_mock.mass.http_session.request.call_count == 2
80
81
82async def test_get_404_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
83 """Test GET request with 404 error."""
84 response = AsyncMock(spec=ClientResponse)
85 response.status = 404
86 response.url = "http://test/endpoint"
87
88 request_ctx = AsyncMock()
89 request_ctx.__aenter__.return_value = response
90 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
91
92 with pytest.raises(MediaNotFoundError):
93 await api_client.get("test/endpoint")
94
95
96async def test_get_429_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
97 """Test GET request with 429 error."""
98 response = AsyncMock(spec=ClientResponse)
99 response.status = 429
100 response.headers = {"Retry-After": "10"}
101
102 request_ctx = AsyncMock()
103 request_ctx.__aenter__.return_value = response
104 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
105
106 with pytest.raises(RetriesExhausted):
107 await api_client.get("test/endpoint")
108
109
110async def test_post_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
111 """Test successful POST request."""
112 response = AsyncMock(spec=ClientResponse)
113 response.status = 200
114 response.json.return_value = {"success": True}
115
116 request_ctx = AsyncMock()
117 request_ctx.__aenter__.return_value = response
118 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
119
120 result = await api_client.post("test/endpoint", data={"key": "value"})
121 assert result == {"success": True}
122
123
124async def test_paginate(api_client: TidalAPIClient, provider_mock: Mock) -> None:
125 """Test pagination."""
126 # Mock first page response
127 response1 = AsyncMock(spec=ClientResponse)
128 response1.status = 200
129 response1.json.return_value = {"items": [{"id": 1}, {"id": 2}], "totalNumberOfItems": 4}
130
131 # Mock second page response
132 response2 = AsyncMock(spec=ClientResponse)
133 response2.status = 200
134 response2.json.return_value = {"items": [{"id": 3}, {"id": 4}], "totalNumberOfItems": 4}
135
136 # Mock empty response to stop iteration
137 response3 = AsyncMock(spec=ClientResponse)
138 response3.status = 200
139 response3.json.return_value = {"items": []}
140
141 ctx1 = AsyncMock()
142 ctx1.__aenter__.return_value = response1
143
144 ctx2 = AsyncMock()
145 ctx2.__aenter__.return_value = response2
146
147 ctx3 = AsyncMock()
148 ctx3.__aenter__.return_value = response3
149
150 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2, ctx3])
151
152 items: list[dict[str, Any]] = []
153 async for item in api_client.paginate("test/endpoint", limit=2):
154 items.append(item)
155
156 assert len(items) == 4
157 assert items[0]["id"] == 1
158 assert items[3]["id"] == 4
159
160
161async def test_paginate_preserves_params_across_pages(
162 api_client: TidalAPIClient, provider_mock: Mock
163) -> None:
164 """Test that caller-supplied params are sent on every page, not just the first."""
165 response1 = AsyncMock(spec=ClientResponse)
166 response1.status = 200
167 response1.json.return_value = {"items": [{"id": 1}, {"id": 2}]}
168
169 response2 = AsyncMock(spec=ClientResponse)
170 response2.status = 200
171 response2.json.return_value = {"items": []}
172
173 ctx1 = AsyncMock()
174 ctx1.__aenter__.return_value = response1
175 ctx2 = AsyncMock()
176 ctx2.__aenter__.return_value = response2
177 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2])
178
179 items: list[dict[str, Any]] = []
180 async for item in api_client.paginate("test/endpoint", limit=2, params={"order": "DATE"}):
181 items.append(item)
182
183 assert len(items) == 2
184 for call in provider_mock.mass.http_session.request.call_args_list:
185 assert call[1]["params"]["order"] == "DATE"
186
187
188async def test_delete_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
189 """Test successful DELETE request."""
190 response = AsyncMock(spec=ClientResponse)
191 response.status = 204
192
193 request_ctx = AsyncMock()
194 request_ctx.__aenter__.return_value = response
195 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
196
197 await api_client.delete("test/endpoint/123")
198
199 # Verify DELETE was called
200 provider_mock.mass.http_session.request.assert_called_once()
201 call_args = provider_mock.mass.http_session.request.call_args
202 assert call_args[0][0] == "DELETE"
203
204
205async def test_delete_with_headers(api_client: TidalAPIClient, provider_mock: Mock) -> None:
206 """Test DELETE request with custom headers."""
207 response = AsyncMock(spec=ClientResponse)
208 response.status = 204
209
210 request_ctx = AsyncMock()
211 request_ctx.__aenter__.return_value = response
212 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
213
214 await api_client.delete("test/endpoint/123", headers={"If-Match": "etag123"})
215
216 # Verify headers were passed
217 call_args = provider_mock.mass.http_session.request.call_args
218 assert "If-Match" in call_args[1]["headers"]
219 assert call_args[1]["headers"]["If-Match"] == "etag123"
220
221
222async def test_put_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
223 """Test successful PUT request."""
224 response = AsyncMock(spec=ClientResponse)
225 response.status = 200
226 response.json.return_value = {"updated": True}
227
228 request_ctx = AsyncMock()
229 request_ctx.__aenter__.return_value = response
230 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
231
232 result = await api_client.put("test/endpoint", data={"key": "value"})
233 assert result == {"updated": True}
234
235 # Verify PUT was called
236 call_args = provider_mock.mass.http_session.request.call_args
237 assert call_args[0][0] == "PUT"
238
239
240async def test_put_with_form_data(api_client: TidalAPIClient, provider_mock: Mock) -> None:
241 """Test PUT request with form data."""
242 response = AsyncMock(spec=ClientResponse)
243 response.status = 200
244 response.json.return_value = {"success": True}
245
246 request_ctx = AsyncMock()
247 request_ctx.__aenter__.return_value = response
248 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
249
250 result = await api_client.put("test/endpoint", data={"key": "value"}, as_form=True)
251 assert result == {"success": True}
252
253 # Verify form data was used
254 call_args = provider_mock.mass.http_session.request.call_args
255 assert "data" in call_args[1]
256