/
/
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 an expanded backend choice, defaulting to soloist
132 assert step.step_id == "backend"
133 assert [entry.key for entry in step.entries] == [CONF_BACKEND]
134 backend_entry = _entry(step, CONF_BACKEND)
135 assert [option.value for option in backend_entry.options] == [
136 BACKEND_SOLOIST,
137 BACKEND_GO_LIBRESPOT,
138 ]
139 assert backend_entry.expanded_options is True
140 assert backend_entry.default_value == BACKEND_SOLOIST
141 assert backend_entry.value == BACKEND_SOLOIST
142
143 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
144 assert step.step_id == "user"
145 player_entry = _entry(step, CONF_MASS_PLAYER_ID)
146 assert [option.value for option in player_entry.options] == [
147 "__auto__",
148 "kitchen",
149 "living-room",
150 ]
151
152 session.handle_submit(
153 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
154 )
155 await _wait_finished(session)
156 await task
157
158 assert collected == {
159 CONF_BACKEND: BACKEND_GO_LIBRESPOT,
160 CONF_MASS_PLAYER_ID: "living-room",
161 CONF_PUBLISH_NAME: "Living Room Spotify",
162 }
163
164
165async def test_new_setup_soloist_path() -> None:
166 """The soloist branch collects consent, the API key and the volume mode."""
167 session, collected = _make_session()
168 with mock.patch.object(spotify_flow, "verify_platform_supported"):
169 task, step = await _start_flow(session)
170
171 # choosing soloist leads to the warning/consent step
172 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
173 assert step.step_id == "soloist_terms"
174
175 # refusing consent blocks the branch: back to the choice with an error
176 step = await _submit(session, {CONF_SOLOIST_CONSENT: False})
177 assert step.step_id == "backend"
178 assert step.errors == {"base": "soloist_consent_required"}
179 assert not session.finished
180
181 # pick soloist again and give consent this time
182 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
183 assert step.step_id == "soloist_terms"
184 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
185 assert step.step_id == "soloist_api_key"
186 key_entry = _entry(step, CONF_API_KEY)
187 assert key_entry.type == ConfigEntryType.SECURE_STRING
188
189 # an empty and a too-short key are both rejected
190 step = await _submit(session, {CONF_API_KEY: ""})
191 assert step.step_id == "soloist_api_key"
192 assert step.errors == {CONF_API_KEY: "soloist_api_key_invalid"}
193 step = await _submit(session, {CONF_API_KEY: "too-short"})
194 assert step.step_id == "soloist_api_key"
195 assert step.errors == {CONF_API_KEY: "soloist_api_key_invalid"}
196
197 # a valid key advances to the player/name step
198 step = await _submit(session, {CONF_API_KEY: _VALID_API_KEY})
199 assert step.step_id == "user"
200
201 session.handle_submit(
202 {CONF_MASS_PLAYER_ID: "kitchen", CONF_PUBLISH_NAME: "Kitchen Spotify"}
203 )
204 await _wait_finished(session)
205 await task
206
207 assert collected == {
208 CONF_BACKEND: BACKEND_SOLOIST,
209 CONF_SOLOIST_CONSENT: True,
210 CONF_API_KEY: _VALID_API_KEY,
211 CONF_MASS_PLAYER_ID: "kitchen",
212 CONF_PUBLISH_NAME: "Kitchen Spotify",
213 }
214
215
216async def test_api_key_never_echoed_on_published_step() -> None:
217 """The published API key step never carries a (typed) secret value."""
218 session, _collected = _make_session()
219 with mock.patch.object(spotify_flow, "verify_platform_supported"):
220 task, _step = await _start_flow(session)
221 await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
222 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
223
224 # after a (failed) submit carrying the secret, the stored step is clean
225 step = await _submit(session, {CONF_API_KEY: "too-short"})
226 assert _entry(step, CONF_API_KEY).value is None
227
228 await _cancel(task)
229
230
231async def test_reconfigure_preselects_current_backend() -> None:
232 """Reconfiguring an existing soloist config preselects soloist on the choice step."""
233 session, _collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
234 task, step = await _start_flow(session)
235
236 assert step.step_id == "backend"
237 assert _entry(step, CONF_BACKEND).value == BACKEND_SOLOIST
238
239 await _cancel(task)
240
241
242async def test_reconfigure_soloist_keeps_stored_key_on_empty_input() -> None:
243 """An existing API key survives the key step when the field is left empty."""
244 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
245 with mock.patch.object(spotify_flow, "verify_platform_supported"):
246 task, _step = await _start_flow(session)
247 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
248
249 # consent given earlier is prefilled
250 assert step.step_id == "soloist_terms"
251 assert _entry(step, CONF_SOLOIST_CONSENT).value is True
252 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
253
254 # with a stored key the field is optional and a hint label is shown
255 assert step.step_id == "soloist_api_key"
256 assert _entry(step, CONF_API_KEY).required is False
257 assert any(entry.key == "soloist_api_key_hint" for entry in step.entries)
258 step = await _submit(session, {CONF_API_KEY: ""})
259 assert step.step_id == "user"
260
261 session.handle_submit(
262 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
263 )
264 await _wait_finished(session)
265 await task
266
267 assert collected[CONF_API_KEY] == _VALID_API_KEY
268
269
270async def test_switch_soloist_to_go_librespot_clears_secrets_on_finish() -> None:
271 """Switching to go-librespot wipes the soloist secrets, but only when finish succeeds."""
272 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
273 task, _step = await _start_flow(session)
274
275 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
276 assert step.step_id == "user"
277
278 session.handle_submit(
279 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Living Room Spotify"}
280 )
281 await _wait_finished(session)
282 await task
283
284 assert collected[CONF_BACKEND] == BACKEND_GO_LIBRESPOT
285 assert collected[CONF_API_KEY] == ""
286 assert collected[CONF_SOLOIST_CONSENT] is False
287
288
289async def test_switch_aborted_before_finish_keeps_soloist_secrets() -> None:
290 """Aborting a backend switch before finish leaves the stored soloist secrets alone."""
291 session, collected = _make_session(kind="reconfigure", setup_data=dict(_SOLOIST_SETUP_DATA))
292 task, _step = await _start_flow(session)
293
294 step = await _submit(session, {CONF_BACKEND: BACKEND_GO_LIBRESPOT})
295 assert step.step_id == "user"
296 await _cancel(task)
297
298 # finish never ran, so nothing was persisted (the stored setup_data is untouched)
299 assert collected == {}
300 assert not session.finished
301
302
303async def test_switch_go_librespot_to_soloist_keeps_existing_values() -> None:
304 """Switching to soloist adds the soloist values without touching the go-librespot ones."""
305 session, collected = _make_session(
306 kind="reconfigure",
307 setup_data={
308 CONF_BACKEND: BACKEND_GO_LIBRESPOT,
309 CONF_MASS_PLAYER_ID: "living-room",
310 CONF_PUBLISH_NAME: "Legacy Speaker",
311 },
312 )
313 with mock.patch.object(spotify_flow, "verify_platform_supported"):
314 task, _step = await _start_flow(session)
315 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
316 assert step.step_id == "soloist_terms"
317 step = await _submit(session, {CONF_SOLOIST_CONSENT: True})
318 step = await _submit(session, {CONF_API_KEY: _VALID_API_KEY})
319 assert step.step_id == "user"
320
321 # the previously configured player and name are prefilled
322 assert _entry(step, CONF_MASS_PLAYER_ID).value == "living-room"
323 assert _entry(step, CONF_PUBLISH_NAME).value == "Legacy Speaker"
324
325 session.handle_submit(
326 {CONF_MASS_PLAYER_ID: "living-room", CONF_PUBLISH_NAME: "Legacy Speaker"}
327 )
328 await _wait_finished(session)
329 await task
330
331 assert collected == {
332 CONF_BACKEND: BACKEND_SOLOIST,
333 CONF_SOLOIST_CONSENT: True,
334 CONF_API_KEY: _VALID_API_KEY,
335 CONF_MASS_PLAYER_ID: "living-room",
336 CONF_PUBLISH_NAME: "Legacy Speaker",
337 }
338
339
340async def test_unsupported_platform_bounces_back_to_choice() -> None:
341 """Choosing soloist on an unsupported platform re-renders the choice with an error."""
342 session, _collected = _make_session()
343 task, _step = await _start_flow(session)
344
345 # no platform patch: the real check refuses on non-Linux; force it for Linux CI
346 with mock.patch.object(
347 spotify_flow,
348 "verify_platform_supported",
349 side_effect=UnsupportedPlatformError("unsupported"),
350 ):
351 step = await _submit(session, {CONF_BACKEND: BACKEND_SOLOIST})
352
353 assert step.step_id == "backend"
354 assert step.errors == {"base": "soloist_unsupported_platform"}
355 assert _entry(step, CONF_BACKEND).value == BACKEND_GO_LIBRESPOT
356
357 await _cancel(task)
358