/
/
1"""Tests for the Spotify Connect setup flow (backend choice, soloist branch, reconfigure)."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any
8from unittest import mock
9
10import pytest
11from music_assistant_models.enums import ConfigEntryType, FlowStepType
12
13from music_assistant.models.setup_flow import SetupFlowContext, SetupSession
14from music_assistant.providers.spotify_connect import (
15 BACKEND_GO_LIBRESPOT,
16 BACKEND_SOLOIST,
17 CONF_API_KEY,
18 CONF_BACKEND,
19 CONF_MASS_PLAYER_ID,
20 CONF_PUBLISH_NAME,
21 CONF_SOLOIST_CONSENT,
22)
23from music_assistant.providers.spotify_connect import setup_flow as spotify_flow
24from music_assistant.providers.spotify_connect.soloist.runtime import UnsupportedPlatformError
25
26if TYPE_CHECKING:
27 from music_assistant_models.setup_flow import SetupFlowStep
28
29_VALID_API_KEY = "soloist-api-key-0123456789abcdef"
30
31_SOLOIST_SETUP_DATA = {
32 CONF_BACKEND: BACKEND_SOLOIST,
33 CONF_API_KEY: _VALID_API_KEY,
34 CONF_SOLOIST_CONSENT: True,
35 CONF_MASS_PLAYER_ID: "living-room",
36 CONF_PUBLISH_NAME: "Living Room Spotify",
37}
38
39
40def _player(player_id: str, display_name: str) -> mock.Mock:
41 """Return a minimal player for setup-flow option generation."""
42 player = mock.Mock()
43 player.player_id = player_id
44 player.display_name = display_name
45 return player
46
47
48def _make_session(
49 *,
50 kind: str = "setup",
51 setup_data: dict[str, Any] | None = None,
52 values: dict[str, Any] | None = None,
53) -> tuple[SetupSession, dict[str, Any]]:
54 """Return a real setup session and the values collected by its finish handler."""
55 mass = mock.Mock()
56 mass.players.all_players.return_value = [
57 _player("living-room", "Living Room"),
58 _player("kitchen", "Kitchen"),
59 ]
60 collected: dict[str, Any] = {}
61
62 async def finish(_session: SetupSession, submitted: dict[str, Any]) -> dict[str, str]:
63 collected.update(submitted)
64 return {"instance_id": "spotify_connect--test"}
65
66 context = SetupFlowContext(
67 kind="reconfigure" if kind == "reconfigure" else "setup",
68 reason="user",
69 domain="spotify_connect",
70 instance_id="spotify_connect--test" if kind == "reconfigure" else None,
71 setup_data=setup_data or {},
72 values=values or {},
73 )
74 return SetupSession(mass, "flow-test", context, finish), collected
75
76
77async def _start_flow(session: SetupSession) -> tuple[asyncio.Task[None], SetupFlowStep]:
78 """Start the setup flow and wait for its first form step."""
79 task = asyncio.create_task(spotify_flow.run_setup(session))
80 return task, await _wait_for_form(session)
81
82
83async def _wait_for_form(
84 session: SetupSession, previous: SetupFlowStep | None = None
85) -> SetupFlowStep:
86 """Wait until a (new) form step is published and return it."""
87 deadline = time.monotonic() + 5
88 while time.monotonic() < deadline:
89 step = session.current_step
90 if step is not None and step.type == FlowStepType.FORM and step is not previous:
91 return step
92 await asyncio.sleep(0.01)
93 raise AssertionError("form step not published")
94
95
96async def _submit(session: SetupSession, values: dict[str, Any]) -> SetupFlowStep:
97 """Submit form values (which must validate) and return the next published step."""
98 previous = session.current_step
99 assert previous is not None
100 assert session.handle_submit(values) is None
101 return await _wait_for_form(session, previous)
102
103
104async def _wait_finished(session: SetupSession) -> None:
105 """Wait for a setup session to finish."""
106 deadline = time.monotonic() + 5
107 while time.monotonic() < deadline:
108 if session.finished:
109 return
110 await asyncio.sleep(0.01)
111 raise AssertionError("setup flow did not finish")
112
113
114async def _cancel(task: asyncio.Task[None]) -> None:
115 """Cancel a still-running flow task."""
116 task.cancel()
117 with pytest.raises(asyncio.CancelledError):
118 await task
119
120
121def _entry(step: SetupFlowStep, key: str) -> Any:
122 """Return the form entry with the given key from a step."""
123 return next(entry for entry in step.entries if entry.key == key)
124
125
126async def test_new_setup_go_librespot_path() -> None:
127 """The go-librespot branch keeps the original player/name step and its values."""
128 session, collected = _make_session()
129 task, step = await _start_flow(session)
130
131 # the flow opens with the backend choice, defaulting to go-librespot
132 assert step.step_id == "backend"
133 backend_entry = _entry(step, CONF_BACKEND)
134 assert [option.value for option in backend_entry.options] == [
135 BACKEND_SOLOIST,
136 BACKEND_GO_LIBRESPOT,
137 ]
138 assert backend_entry.value == BACKEND_GO_LIBRESPOT
139
140 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
141 assert step.step_id == "user"
142 player_entry = _entry(step, CONF_MASS_PLAYER_ID)
143 assert [option.value for option in player_entry.options] == [
144 "__auto__",
145 "kitchen",
146 "living-room",
147 ]
148
149 session.handle_submit(
150 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
151 )
152 await _wait_finished(session)
153 await task
154
155 assert collected == {
156 CONF_BACKEND: BACKEND_GO_LIBRESPOT,
157 CONF_MASS_PLAYER_ID: "living-room",
158 CONF_PUBLISH_NAME: "Living Room Spotify",
159 }
160
161
162async def test_new_setup_soloist_path() -> None:
163 """The soloist branch collects consent, the API key and the volume mode."""
164 session, collected = _make_session()
165 with mock.patch.object(spotify_flow, "verify_platform_supported"):
166 task, step = await _start_flow(session)
167
168 # choosing soloist leads to the warning/consent step
169 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
170 assert step.step_id == "soloist_terms"
171
172 # refusing consent blocks the branch: back to the choice with an error
173 step = await _submit(session, {CONF_SOLOIST_CONSENT: False})
174 assert step.step_id == "backend"
175 assert step.errors == {"base": "soloist_consent_required"}
176 assert not session.finished
177
178 # pick soloist again and give consent this time
179 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
180 assert step.step_id == "soloist_terms"
181 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
182 assert step.step_id == "soloist_api_key"
183 key_entry = _entry(step, CONF_API_KEY)
184 assert key_entry.type == ConfigEntryType.SECURE_STRING
185
186 # an empty and a too-short key are both rejected
187 step = await _submit(session, {CONF_API_KEY: ""})
188 assert step.step_id == "soloist_api_key"
189 assert step.errors == {CONF_API_KEY: "soloist_api_key_invalid"}
190 step = await _submit(session, {CONF_API_KEY: "too-short"})
191 assert step.step_id == "soloist_api_key"
192 assert step.errors == {CONF_API_KEY: "soloist_api_key_invalid"}
193
194 # a valid key advances to the player/name step
195 step = await _submit(session, {CONF_API_KEY: _VALID_API_KEY})
196 assert step.step_id == "user"
197
198 session.handle_submit(
199 {CONF_MASS_PLAYER_ID: "kitchen", CONF_PUBLISH_NAME: "Kitchen Spotify"}
200 )
201 await _wait_finished(session)
202 await task
203
204 assert collected == {
205 CONF_BACKEND: BACKEND_SOLOIST,
206 CONF_SOLOIST_CONSENT: True,
207 CONF_API_KEY: _VALID_API_KEY,
208 CONF_MASS_PLAYER_ID: "kitchen",
209 CONF_PUBLISH_NAME: "Kitchen Spotify",
210 }
211
212
213async def test_api_key_never_echoed_on_published_step() -> None:
214 """The published API key step never carries a (typed) secret value."""
215 session, _collected = _make_session()
216 with mock.patch.object(spotify_flow, "verify_platform_supported"):
217 task, _step = await _start_flow(session)
218 await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
219 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
220
221 # after a (failed) submit carrying the secret, the stored step is clean
222 step = await _submit(session, {CONF_API_KEY: "too-short"})
223 assert _entry(step, CONF_API_KEY).value is None
224
225 await _cancel(task)
226
227
228async def test_reconfigure_preselects_current_backend() -> None:
229 """Reconfiguring an existing soloist config preselects soloist on the choice step."""
230 session, _collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
231 task, step = await _start_flow(session)
232
233 assert step.step_id == "backend"
234 assert _entry(step, CONF_BACKEND).value == BACKEND_SOLOIST
235
236 await _cancel(task)
237
238
239async def test_reconfigure_soloist_keeps_stored_key_on_empty_input() -> None:
240 """An existing API key survives the key step when the field is left empty."""
241 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
242 with mock.patch.object(spotify_flow, "verify_platform_supported"):
243 task, _step = await _start_flow(session)
244 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
245
246 # consent given earlier is prefilled
247 assert step.step_id == "soloist_terms"
248 assert _entry(step, CONF_SOLOIST_CONSENT).value is True
249 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
250
251 # with a stored key the field is optional and a hint label is shown
252 assert step.step_id == "soloist_api_key"
253 assert _entry(step, CONF_API_KEY).required is False
254 assert any(entry.key == "soloist_api_key_hint" for entry in step.entries)
255 step = await _submit(session, {CONF_API_KEY: ""})
256 assert step.step_id == "user"
257
258 session.handle_submit(
259 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
260 )
261 await _wait_finished(session)
262 await task
263
264 assert collected[CONF_API_KEY] == _VALID_API_KEY
265
266
267async def test_switch_soloist_to_go_librespot_clears_secrets_on_finish() -> None:
268 """Switching to go-librespot wipes the soloist secrets, but only when finish succeeds."""
269 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
270 task, _step = await _start_flow(session)
271
272 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
273 assert step.step_id == "user"
274
275 session.handle_submit(
276 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
277 )
278 await _wait_finished(session)
279 await task
280
281 assert collected[CONF_BACKEND] == BACKEND_GO_LIBRESPOT
282 assert collected[CONF_API_KEY] == ""
283 assert collected[CONF_SOLOIST_CONSENT] is False
284
285
286async def test_switch_aborted_before_finish_keeps_soloist_secrets() -> None:
287 """Aborting a backend switch before finish leaves the stored soloist secrets alone."""
288 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
289 task, _step = await _start_flow(session)
290
291 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
292 assert step.step_id == "user"
293 await _cancel(task)
294
295 # finish never ran, so nothing was persisted (the stored setup_data is untouched)
296 assert collected == {}
297 assert not session.finished
298
299
300async def test_switch_go_librespot_to_soloist_keeps_existing_values() -> None:
301 """Switching to soloist adds the soloist values without touching the go-librespot ones."""
302 session, collected = _make_session(
303 kind="reconfigure",
304 setup_data={
305 CONF_BACKEND: BACKEND_GO_LIBRESPOT,
306 CONF_MASS_PLAYER_ID: "living-room",
307 CONF_PUBLISH_NAME: "Legacy Speaker",
308 },
309 )
310 with mock.patch.object(spotify_flow, "verify_platform_supported"):
311 task, _step = await _start_flow(session)
312 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
313 assert step.step_id == "soloist_terms"
314 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
315 step = await _submit(session, {CONF_API_KEY: _VALID_API_KEY})
316 assert step.step_id == "user"
317
318 # the previously configured player and name are prefilled
319 assert _entry(step, CONF_MASS_PLAYER_ID).value == "living-room"
320 assert _entry(step, CONF_PUBLISH_NAME).value == "Legacy Speaker"
321
322 session.handle_submit(
323 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Legacy Speaker"}
324 )
325 await _wait_finished(session)
326 await task
327
328 assert collected == {
329 CONF_BACKEND: BACKEND_SOLOIST,
330 CONF_SOLOIST_CONSENT: True,
331 CONF_API_KEY: _VALID_API_KEY,
332 CONF_MASS_PLAYER_ID: "living-room",
333 CONF_PUBLISH_NAME: "Legacy Speaker",
334 }
335
336
337async def test_unsupported_platform_bounces_back_to_choice() -> None:
338 """Choosing soloist on an unsupported platform re-renders the choice with an error."""
339 session, _collected = _make_session()
340 task, _step = await _start_flow(session)
341
342 # no platform patch: the real check refuses on non-Linux; force it for Linux CI
343 with mock.patch.object(
344 spotify_flow,
345 "verify_platform_supported",
346 side_effect=UnsupportedPlatformError("unsupported"),
347 ):
348 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
349
350 assert step.step_id == "backend"
351 assert step.errors == {"base": "soloist_unsupported_platform"}
352 assert _entry(step, CONF_BACKEND).value == BACKEND_GO_LIBRESPOT
353
354 await _cancel(task)
355