/
/
/
1"""Test Tidal API Client."""
2
3from typing import Any
4from unittest.mock import AsyncMock, MagicMock, Mock, patch
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 provider_mock() -> Mock:
19 """Return a mock provider."""
20 provider = Mock()
21 provider.auth = AsyncMock()
22 provider.auth.ensure_valid_token.return_value = True
23 provider.auth.access_token = "token"
24 provider.auth.session_id = "session"
25 provider.auth.country_code = "US"
26 provider.mass = Mock()
27 provider.mass.http_session = AsyncMock()
28 provider.mass.metadata.locale = "en_US"
29 provider.logger = Mock()
30 return provider
31
32
33@pytest.fixture
34def api_client(provider_mock: Mock) -> TidalAPIClient:
35 """Return a TidalAPIClient instance."""
36 return TidalAPIClient(provider_mock)
37
38
39async def test_get_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
40 """Test successful GET request."""
41 response = AsyncMock(spec=ClientResponse)
42 response.status = 200
43 response.json.return_value = {"data": "test"}
44
45 # Create a mock that acts as an async context manager
46 request_ctx = AsyncMock()
47 request_ctx.__aenter__.return_value = response
48
49 # The request method itself should be a MagicMock (not AsyncMock)
50 # that returns the context manager
51 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
52
53 result = await api_client.get("test/endpoint")
54 assert result == {"data": "test"}
55
56
57async def test_get_401_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
58 """Test GET request with 401 error."""
59 response = AsyncMock(spec=ClientResponse)
60 response.status = 401
61
62 request_ctx = AsyncMock()
63 request_ctx.__aenter__.return_value = response
64 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
65
66 with pytest.raises(LoginFailed):
67 await api_client.get("test/endpoint")
68
69
70async def test_get_404_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
71 """Test GET request with 404 error."""
72 response = AsyncMock(spec=ClientResponse)
73 response.status = 404
74 response.url = "http://test/endpoint"
75
76 request_ctx = AsyncMock()
77 request_ctx.__aenter__.return_value = response
78 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
79
80 with pytest.raises(MediaNotFoundError):
81 await api_client.get("test/endpoint")
82
83
84async def test_get_429_error(api_client: TidalAPIClient, provider_mock: Mock) -> None:
85 """Test GET request with 429 error."""
86 with patch("asyncio.sleep"):
87 response = AsyncMock(spec=ClientResponse)
88 response.status = 429
89 response.headers = {"Retry-After": "10"}
90
91 request_ctx = AsyncMock()
92 request_ctx.__aenter__.return_value = response
93 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
94
95 with pytest.raises(RetriesExhausted):
96 await api_client.get("test/endpoint")
97
98
99async def test_post_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
100 """Test successful POST request."""
101 response = AsyncMock(spec=ClientResponse)
102 response.status = 200
103 response.json.return_value = {"success": True}
104
105 request_ctx = AsyncMock()
106 request_ctx.__aenter__.return_value = response
107 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
108
109 result = await api_client.post("test/endpoint", data={"key": "value"})
110 assert result == {"success": True}
111
112
113async def test_paginate(api_client: TidalAPIClient, provider_mock: Mock) -> None:
114 """Test pagination."""
115 # Mock first page response
116 response1 = AsyncMock(spec=ClientResponse)
117 response1.status = 200
118 response1.json.return_value = {"items": [{"id": 1}, {"id": 2}], "totalNumberOfItems": 4}
119
120 # Mock second page response
121 response2 = AsyncMock(spec=ClientResponse)
122 response2.status = 200
123 response2.json.return_value = {"items": [{"id": 3}, {"id": 4}], "totalNumberOfItems": 4}
124
125 # Mock empty response to stop iteration
126 response3 = AsyncMock(spec=ClientResponse)
127 response3.status = 200
128 response3.json.return_value = {"items": []}
129
130 ctx1 = AsyncMock()
131 ctx1.__aenter__.return_value = response1
132
133 ctx2 = AsyncMock()
134 ctx2.__aenter__.return_value = response2
135
136 ctx3 = AsyncMock()
137 ctx3.__aenter__.return_value = response3
138
139 provider_mock.mass.http_session.request = MagicMock(side_effect=[ctx1, ctx2, ctx3])
140
141 items: list[dict[str, Any]] = []
142 async for item in api_client.paginate("test/endpoint", limit=2):
143 items.append(item)
144
145 assert len(items) == 4
146 assert items[0]["id"] == 1
147 assert items[3]["id"] == 4
148
149
150async def test_delete_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
151 """Test successful DELETE request."""
152 response = AsyncMock(spec=ClientResponse)
153 response.status = 204
154
155 request_ctx = AsyncMock()
156 request_ctx.__aenter__.return_value = response
157 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
158
159 await api_client.delete("test/endpoint/123")
160
161 # Verify DELETE was called
162 provider_mock.mass.http_session.request.assert_called_once()
163 call_args = provider_mock.mass.http_session.request.call_args
164 assert call_args[0][0] == "DELETE"
165
166
167async def test_delete_with_headers(api_client: TidalAPIClient, provider_mock: Mock) -> None:
168 """Test DELETE request with custom headers."""
169 response = AsyncMock(spec=ClientResponse)
170 response.status = 204
171
172 request_ctx = AsyncMock()
173 request_ctx.__aenter__.return_value = response
174 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
175
176 await api_client.delete("test/endpoint/123", headers={"If-Match": "etag123"})
177
178 # Verify headers were passed
179 call_args = provider_mock.mass.http_session.request.call_args
180 assert "If-Match" in call_args[1]["headers"]
181 assert call_args[1]["headers"]["If-Match"] == "etag123"
182
183
184async def test_put_success(api_client: TidalAPIClient, provider_mock: Mock) -> None:
185 """Test successful PUT request."""
186 response = AsyncMock(spec=ClientResponse)
187 response.status = 200
188 response.json.return_value = {"updated": True}
189
190 request_ctx = AsyncMock()
191 request_ctx.__aenter__.return_value = response
192 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
193
194 result = await api_client.put("test/endpoint", data={"key": "value"})
195 assert result == {"updated": True}
196
197 # Verify PUT was called
198 call_args = provider_mock.mass.http_session.request.call_args
199 assert call_args[0][0] == "PUT"
200
201
202async def test_put_with_form_data(api_client: TidalAPIClient, provider_mock: Mock) -> None:
203 """Test PUT request with form data."""
204 response = AsyncMock(spec=ClientResponse)
205 response.status = 200
206 response.json.return_value = {"success": True}
207
208 request_ctx = AsyncMock()
209 request_ctx.__aenter__.return_value = response
210 provider_mock.mass.http_session.request = MagicMock(return_value=request_ctx)
211
212 result = await api_client.put("test/endpoint", data={"key": "value"}, as_form=True)
213 assert result == {"success": True}
214
215 # Verify form data was used
216 call_args = provider_mock.mass.http_session.request.call_args
217 assert "data" in call_args[1]
218