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