/
/
1"""Tests for the shared Sendspin bridge manager lifecycle reconciliation."""
2
3import logging
4from typing import Any
5from unittest.mock import AsyncMock, MagicMock
6
7import pytest
8
9from music_assistant.providers.chromecast.sendspin_bridge import (
10 SendspinBridgeManager as CastSendspinBridgeManager,
11)
12from music_assistant.providers.local_audio.sendspin_bridge import (
13 LocalAudioBridgeManager,
14 get_device_uuid,
15)
16from music_assistant.providers.sendspin.bridge_manager import SendspinBridgeManagerBase
17
18
19class FakeBridge:
20 """Minimal Sendspin bridge implementation for testing."""
21
22 def __init__(self, sendspin_server: Any) -> None:
23 """Initialize the fake bridge."""
24 self.sendspin_server = sendspin_server
25 self.started = False
26 self.stopped = False
27
28 @property
29 def is_registered(self) -> bool:
30 """Return whether the bridge is registered with Sendspin."""
31 return self.started and not self.stopped
32
33 async def start(self) -> None:
34 """Register the bridge as an external Sendspin client."""
35 self.started = True
36
37 async def stop(self) -> None:
38 """Stop and unregister the bridge."""
39 self.stopped = True
40
41
42class FakeBridgeManager(SendspinBridgeManagerBase[FakeBridge]):
43 """Concrete bridge manager with controllable policy for testing."""
44
45 policy_result = True
46
47 def _bridge_client_id(self, player: Any) -> str | None:
48 """Return the Sendspin client_id used to bridge the given player."""
49 return f"spb_{player.player_id}"
50
51 def _create_bridge(self, player: Any) -> FakeBridge:
52 """Create a (not yet started) bridge instance for the given player."""
53 return FakeBridge(self.sendspin_server)
54
55 def _should_have_bridge(self, player: Any) -> bool:
56 """Return whether provider policy wants a bridge for this player."""
57 return self.policy_result
58
59
60def _make_environment() -> tuple[
61 FakeBridgeManager, MagicMock, MagicMock, dict[str, Any], dict[str, Any]
62]:
63 """
64 Build a bridge manager with a mocked MusicAssistant environment.
65
66 :return: Tuple of (manager, mass, player, registered_players, player_configs).
67 """
68 registered_players: dict[str, Any] = {}
69 player_configs: dict[str, Any] = {}
70
71 mass = MagicMock()
72 mass.subscribe = MagicMock(return_value=MagicMock())
73 sendspin_provider = MagicMock()
74 sendspin_provider.server_api = MagicMock()
75 mass.get_provider = MagicMock(
76 side_effect=lambda domain: sendspin_provider if domain == "sendspin" else None
77 )
78 mass.players.get_player = MagicMock(side_effect=registered_players.get)
79 mass.config.get = MagicMock(
80 side_effect=lambda key, default=None: player_configs.get(key, default)
81 )
82
83 async def fake_save_player_config(player_id: str, values: dict[str, Any]) -> None:
84 player_configs.setdefault(f"players/{player_id}", {}).update(values)
85
86 mass.config.save_player_config = AsyncMock(side_effect=fake_save_player_config)
87
88 provider = MagicMock()
89 provider.mass = mass
90 provider.logger = logging.getLogger("test.bridge_manager")
91
92 player = MagicMock()
93 player.player_id = "player_1"
94 player.display_name = "Test Player"
95 player.provider = provider
96 registered_players["player_1"] = player
97 provider.players = [player]
98
99 manager = FakeBridgeManager(provider)
100 return manager, mass, player, registered_players, player_configs
101
102
103class TestBridgeLifecycleReconciliation:
104 """Tests for the desired-state reconciliation of Sendspin bridges."""
105
106 @pytest.mark.asyncio
107 async def test_bridge_created_when_all_conditions_met(self) -> None:
108 """Test a bridge is created for a registered, enabled player."""
109 manager, _, player, _, _ = _make_environment()
110
111 await manager.evaluate_bridge(player)
112
113 bridge = manager.get_bridge("player_1")
114 assert bridge is not None
115 assert bridge.is_registered
116
117 @pytest.mark.asyncio
118 async def test_bridge_removed_when_base_player_disabled(self) -> None:
119 """Test the bridge is torn down when the base player gets disabled."""
120 manager, _, player, _, player_configs = _make_environment()
121 await manager.evaluate_bridge(player)
122 bridge = manager.get_bridge("player_1")
123 assert bridge is not None
124
125 player_configs["players/player_1"] = {"enabled": False}
126 await manager.evaluate_bridge(player)
127
128 assert manager.get_bridge("player_1") is None
129 assert bridge.stopped
130
131 @pytest.mark.asyncio
132 async def test_bridge_removed_when_bridge_client_disabled(self) -> None:
133 """Test the bridge is torn down when its own Sendspin client gets disabled."""
134 manager, mass, player, _, player_configs = _make_environment()
135 await manager.evaluate_bridge(player)
136 assert manager.get_bridge("player_1") is not None
137
138 # a user-made disable carries the parent link the toggle was rendered under
139 player_configs["players/player_1"] = {"enabled": True}
140 player_configs["players/spb_player_1"] = {
141 "enabled": False,
142 "values": {"protocol_parent_id": "player_1"},
143 }
144 await manager.evaluate_bridge(player)
145
146 assert manager.get_bridge("player_1") is None
147 mass.config.save_player_config.assert_not_awaited()
148
149 @pytest.mark.asyncio
150 async def test_bridge_not_created_for_unregistered_player(self) -> None:
151 """Test no bridge is created when the player is not the registered instance."""
152 manager, _, player, registered_players, _ = _make_environment()
153 del registered_players["player_1"]
154
155 await manager.evaluate_bridge(player)
156
157 assert manager.get_bridge("player_1") is None
158
159 @pytest.mark.asyncio
160 async def test_policy_denial_removes_bridge_permanently(self) -> None:
161 """Test a policy denial removes the bridge and cleans up the client config."""
162 manager, mass, player, _, player_configs = _make_environment()
163 await manager.evaluate_bridge(player)
164 assert manager.get_bridge("player_1") is not None
165
166 manager.policy_result = False
167 player_configs["players/spb_player_1"] = {"enabled": True}
168 await manager.evaluate_bridge(player)
169
170 assert manager.get_bridge("player_1") is None
171 mass.players.delete_player_config.assert_called_once_with("spb_player_1")
172
173 @pytest.mark.asyncio
174 async def test_config_event_on_bridge_client_recreates_bridge(self) -> None:
175 """Test a config event on the bridge client id re-evaluates the base player."""
176 manager, _, player, _, player_configs = _make_environment()
177 player_configs["players/player_1"] = {"enabled": True}
178 player_configs["players/spb_player_1"] = {
179 "enabled": False,
180 "values": {"protocol_parent_id": "player_1"},
181 }
182 await manager.evaluate_bridge(player)
183 assert manager.get_bridge("player_1") is None
184
185 # Re-enable the bridge client and fire the (mapped) config event
186 player_configs["players/spb_player_1"] = {"enabled": True}
187 event = MagicMock()
188 event.object_id = "spb_player_1"
189 await manager._on_player_config_updated(event)
190
191 assert manager.get_bridge("player_1") is not None
192
193 @pytest.mark.asyncio
194 async def test_stale_disabled_client_reenabled_when_parent_gone(self) -> None:
195 """Test a client disabled under a no-longer-existing parent is re-enabled."""
196 manager, mass, player, _, player_configs = _make_environment()
197 # e.g. left behind by a cascade-disable from a parent player whose
198 # config was removed (device re-setup changed its player id)
199 player_configs["players/spb_player_1"] = {
200 "enabled": False,
201 "values": {"protocol_parent_id": "cc_old_uuid"},
202 }
203
204 await manager.evaluate_bridge(player)
205
206 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
207 assert manager.get_bridge("player_1") is not None
208
209 @pytest.mark.asyncio
210 async def test_stale_disabled_client_without_parent_link_reenabled(self) -> None:
211 """Test a disabled client without any parent link is re-enabled."""
212 manager, mass, player, _, player_configs = _make_environment()
213 player_configs["players/spb_player_1"] = {"enabled": False}
214
215 await manager.evaluate_bridge(player)
216
217 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
218 assert manager.get_bridge("player_1") is not None
219
220 @pytest.mark.asyncio
221 async def test_no_heal_when_base_player_disabled(self) -> None:
222 """Test a stale client disable is left alone while the base player is disabled."""
223 manager, mass, player, _, player_configs = _make_environment()
224 player_configs["players/player_1"] = {"enabled": False}
225 player_configs["players/spb_player_1"] = {"enabled": False}
226
227 await manager.evaluate_bridge(player)
228
229 mass.config.save_player_config.assert_not_awaited()
230 assert manager.get_bridge("player_1") is None
231
232 @pytest.mark.asyncio
233 async def test_stale_bridge_rebuilt_after_sendspin_reload(self) -> None:
234 """Test a bridge bound to a replaced Sendspin server is rebuilt."""
235 manager, mass, player, _, _ = _make_environment()
236 await manager.evaluate_bridge(player)
237 old_bridge = manager.get_bridge("player_1")
238 assert old_bridge is not None
239
240 # Simulate a sendspin provider reload (new server instance)
241 new_provider = MagicMock()
242 new_provider.server_api = MagicMock()
243 mass.get_provider = MagicMock(
244 side_effect=lambda domain: new_provider if domain == "sendspin" else None
245 )
246 await manager.evaluate_bridge(player)
247
248 new_bridge = manager.get_bridge("player_1")
249 assert old_bridge.stopped
250 assert new_bridge is not None
251 assert new_bridge is not old_bridge
252 assert new_bridge.sendspin_server is new_provider.server_api
253
254
255class TestCastBridgePolicy:
256 """Tests for the Chromecast-specific bridge policy."""
257
258 @staticmethod
259 def _make_cast_environment() -> tuple[CastSendspinBridgeManager, MagicMock, MagicMock]:
260 """
261 Build a cast bridge manager with a mocked environment.
262
263 :return: Tuple of (manager, mass, cast_player).
264 """
265 mass = MagicMock()
266 mass.subscribe = MagicMock(return_value=MagicMock())
267 mass.players.subscribe_player_state_update = MagicMock(return_value=MagicMock())
268 mass.create_task = MagicMock()
269 provider = MagicMock()
270 provider.mass = mass
271 provider.logger = logging.getLogger("test.cast_bridge_manager")
272
273 cast_player = MagicMock()
274 cast_player.player_id = "cc_player"
275 cast_player.display_name = "Cast Speaker"
276 cast_player.cast_info.is_audio_group = False
277 cast_player.cast_info.is_multichannel_group = False
278 cast_player.cast_info.mac_address = "AA:BB:CC:DD:EE:FF"
279 cast_player.device_info.manufacturer = "TestCo"
280 cast_player.device_info.model = "TestSpeaker"
281 cast_player.protocol_parent_id = "parent_1"
282 cast_player.provider = provider
283 provider.players = [cast_player]
284
285 manager = CastSendspinBridgeManager(provider)
286 return manager, mass, cast_player
287
288 def test_hard_deny_when_device_has_airplay(self) -> None:
289 """Test the cast bridge is denied when the device has AirPlay at all."""
290 manager, mass, cast_player = self._make_cast_environment()
291
292 parent = MagicMock()
293 airplay_protocol = MagicMock()
294 # AirPlay protocol link present but NOT available (e.g. disabled by user)
295 airplay_protocol.available = False
296 parent.get_output_protocol_by_domain = MagicMock(return_value=airplay_protocol)
297 mass.players.get_player = MagicMock(return_value=parent)
298
299 assert manager._should_have_bridge(cast_player) is False
300
301 def test_hard_deny_when_airplay_provider_not_loaded(self) -> None:
302 """Test the deny also applies while the airplay provider itself is not loaded."""
303 manager, mass, cast_player = self._make_cast_environment()
304
305 parent = MagicMock()
306 # Cached airplay protocol entry from an earlier session
307 parent.get_output_protocol_by_domain = MagicMock(return_value=MagicMock())
308 mass.get_provider = MagicMock(return_value=None)
309 mass.players.get_player = MagicMock(return_value=parent)
310
311 assert manager._should_have_bridge(cast_player) is False
312
313 def test_allowed_when_device_has_no_airplay(self) -> None:
314 """Test the cast bridge is allowed when the device has no AirPlay protocol."""
315 manager, mass, cast_player = self._make_cast_environment()
316
317 parent = MagicMock()
318 parent.get_output_protocol_by_domain = MagicMock(return_value=None)
319 mass.players.get_player = MagicMock(return_value=parent)
320
321 assert manager._should_have_bridge(cast_player) is True
322
323 def test_blocklist_message_only_repeats_after_policy_change(
324 self, caplog: pytest.LogCaptureFixture
325 ) -> None:
326 """Test repeated blocklist checks only log when the policy result changes."""
327 manager, _, cast_player = self._make_cast_environment()
328 cast_player.device_info.manufacturer = "Harman Luxury Audio"
329 cast_player.protocol_parent_id = None
330
331 with caplog.at_level(logging.DEBUG, logger=manager.logger.name):
332 assert manager._should_have_bridge(cast_player) is False
333 assert manager._should_have_bridge(cast_player) is False
334 cast_player.device_info.manufacturer = "TestCo"
335 assert manager._should_have_bridge(cast_player) is True
336 cast_player.device_info.manufacturer = "Harman Luxury Audio"
337 assert manager._should_have_bridge(cast_player) is False
338
339 blocklist_records = [
340 record for record in caplog.records if "device is blocklisted" in record.message
341 ]
342 assert len(blocklist_records) == 2
343
344 def test_irrelevant_state_update_does_not_schedule_evaluation(self) -> None:
345 """Test playback state updates do not re-evaluate bridge policy."""
346 manager, mass, cast_player = self._make_cast_environment()
347
348 manager._on_player_state_updated(cast_player, {"volume_level": (20, 30)})
349
350 mass.create_task.assert_not_called()
351
352 def test_relevant_cast_state_update_schedules_evaluation(self) -> None:
353 """Test device policy changes re-evaluate the affected Cast bridge."""
354 manager, mass, cast_player = self._make_cast_environment()
355
356 manager._on_player_state_updated(
357 cast_player, {"device_info.model": ("Old Model", "New Model")}
358 )
359
360 mass.create_task.assert_called_once_with(
361 manager._process_pending_bridge_evaluations,
362 cast_player.player_id,
363 task_id="evaluate_chromecast_sendspin_bridge_cc_player",
364 )
365
366 def test_parent_protocol_update_schedules_evaluation(self) -> None:
367 """Test protocol changes on a parent re-evaluate its Cast bridge."""
368 manager, mass, _ = self._make_cast_environment()
369 parent = MagicMock()
370 parent.player_id = "parent_1"
371 parent.protocol_parent_id = None
372
373 manager._on_player_state_updated(parent, {"output_protocols": ((), ("airplay",))})
374
375 mass.create_task.assert_called_once()
376
377 def test_unregistered_parent_schedules_evaluation(self) -> None:
378 """Test removal of a protocol parent re-evaluates its Cast bridge."""
379 manager, mass, _ = self._make_cast_environment()
380 mass.players.get_player.return_value = None
381 event = MagicMock()
382 event.object_id = "parent_1"
383
384 manager._on_player_unregistered(event)
385
386 mass.create_task.assert_called_once()
387
388 def test_registered_player_event_does_not_duplicate_evaluation(self) -> None:
389 """Test regular player events remain on the filtered state-update path."""
390 manager, mass, _ = self._make_cast_environment()
391 mass.players.get_player.return_value = MagicMock()
392 event = MagicMock()
393 event.object_id = "parent_1"
394
395 manager._on_player_unregistered(event)
396
397 mass.create_task.assert_not_called()
398
399 @pytest.mark.asyncio
400 async def test_update_during_evaluation_triggers_trailing_reconciliation(self) -> None:
401 """Test a policy update during reconciliation is processed afterward."""
402 manager, mass, cast_player = self._make_cast_environment()
403 mass.players.get_player.return_value = cast_player
404 evaluation_count = 0
405
406 async def evaluate_bridge(player: Any) -> None:
407 nonlocal evaluation_count
408 evaluation_count += 1
409 if evaluation_count == 1:
410 manager._pending_bridge_evaluations.add(player.player_id)
411
412 evaluate_mock = AsyncMock(side_effect=evaluate_bridge)
413 manager.evaluate_bridge = evaluate_mock # type: ignore[method-assign]
414 manager._pending_bridge_evaluations.add(cast_player.player_id)
415
416 await manager._process_pending_bridge_evaluations(cast_player.player_id)
417
418 assert evaluate_mock.await_count == 2
419 assert not manager._pending_bridge_evaluations
420
421
422class TestLocalAudioBridgeManager:
423 """Tests for the local audio specific bridge manager behavior."""
424
425 DEVICE_UUID = "aabbccdd-1122-3344-5566-77889900aabb"
426
427 @staticmethod
428 def _make_local_environment() -> tuple[LocalAudioBridgeManager, MagicMock, MagicMock]:
429 """
430 Build a local audio bridge manager with a mocked environment.
431
432 :return: Tuple of (manager, mass, player).
433 """
434 registered_players: dict[str, Any] = {}
435 mass = MagicMock()
436 mass.subscribe = MagicMock(return_value=MagicMock())
437 sendspin_provider = MagicMock()
438 sendspin_provider.server_api = MagicMock()
439 mass.get_provider = MagicMock(
440 side_effect=lambda domain: sendspin_provider if domain == "sendspin" else None
441 )
442 mass.players.get_player = MagicMock(side_effect=registered_players.get)
443 mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
444
445 provider = MagicMock()
446 provider.mass = mass
447 provider.logger = logging.getLogger("test.local_audio_bridge_manager")
448
449 player = MagicMock()
450 player.player_id = TestLocalAudioBridgeManager.DEVICE_UUID
451 player.display_name = "Dummy Output"
452 player.provider = provider
453 player.available = True
454 registered_players[player.player_id] = player
455 provider.players = [player]
456
457 manager = LocalAudioBridgeManager(provider)
458 device = {"name": "sink1", "description": "Dummy Output"}
459 manager._devices = {TestLocalAudioBridgeManager.DEVICE_UUID: device}
460 return manager, mass, player
461
462 @staticmethod
463 def _make_discover_environment() -> tuple[MagicMock, MagicMock, dict[str, Any]]:
464 """
465 Build the mocked environment for discover_and_register tests.
466
467 :return: Tuple of (mass, provider, registered players dict).
468 """
469 registered: dict[str, Any] = {}
470 mass = MagicMock()
471 mass.subscribe = MagicMock(return_value=MagicMock())
472 sendspin_provider = MagicMock()
473 sendspin_provider.server_api = MagicMock()
474 mass.get_provider = MagicMock(
475 side_effect=lambda domain: sendspin_provider if domain == "sendspin" else None
476 )
477 mass.players.get_player = MagicMock(side_effect=registered.get)
478 mass.config.get = MagicMock(side_effect=lambda _key, default=None: default)
479
480 provider = MagicMock()
481 provider.mass = mass
482 provider.logger = logging.getLogger("test.local_audio_bridge_manager")
483 provider.config.get_value = MagicMock(return_value="auto")
484 return mass, provider, registered
485
486 def test_policy_requires_enumerated_device(self) -> None:
487 """Test a bridge is only wanted for devices present in the last enumeration."""
488 manager, _, player = self._make_local_environment()
489
490 assert manager._should_have_bridge(player) is True
491 manager._devices = {}
492 assert manager._should_have_bridge(player) is False
493
494 def test_bridge_client_id_derived_from_device_uuid(self) -> None:
495 """Test the bridge client_id is the spb-prefixed device uuid."""
496 manager, _, player = self._make_local_environment()
497
498 assert manager._bridge_client_id(player) == f"spb_{self.DEVICE_UUID.replace('-', '')}"
499
500 @pytest.mark.asyncio
501 async def test_failed_bridge_marks_player_unavailable(self) -> None:
502 """Test a bridge that fails to start marks the player unavailable."""
503 manager, _, player = self._make_local_environment()
504 failing_bridge = FakeBridge(manager.sendspin_server)
505
506 async def _fail_start() -> None:
507 raise RuntimeError("no audio device")
508
509 failing_bridge.start = _fail_start # type: ignore[method-assign]
510 manager._create_bridge = MagicMock(return_value=failing_bridge) # type: ignore[method-assign]
511
512 await manager.evaluate_bridge(player)
513
514 assert manager.get_bridge(player.player_id) is None
515 assert player._attr_available is False
516 player.update_state.assert_called()
517
518 @pytest.mark.asyncio
519 async def test_recovered_bridge_restores_player_availability(self) -> None:
520 """Test a successful bridge rebuild restores the player's availability."""
521 manager, _, player = self._make_local_environment()
522 player.available = False
523 manager._create_bridge = MagicMock( # type: ignore[method-assign]
524 return_value=FakeBridge(manager.sendspin_server)
525 )
526
527 await manager.evaluate_bridge(player)
528
529 assert manager.get_bridge(player.player_id) is not None
530 assert player._attr_available is True
531
532 @pytest.mark.asyncio
533 async def test_disabled_player_does_not_touch_availability(self) -> None:
534 """Test a lifecycle-blocked bridge leaves the player's availability alone."""
535 manager, mass, player = self._make_local_environment()
536 player_configs = {f"players/{player.player_id}": {"enabled": False}}
537 mass.config.get = MagicMock(
538 side_effect=lambda key, default=None: player_configs.get(key, default)
539 )
540
541 await manager.evaluate_bridge(player)
542
543 assert manager.get_bridge(player.player_id) is None
544 player.update_state.assert_not_called()
545
546 @pytest.mark.asyncio
547 async def test_discover_rebinds_player_after_provider_reload(self) -> None:
548 """Test discovery re-registers a player left behind by a previous provider instance."""
549 mass, provider, registered = self._make_discover_environment()
550 base_config = MagicMock()
551 base_config.name = None
552 base_config.default_name = "Dummy Output"
553 mass.config.get_base_player_config = MagicMock(return_value=base_config)
554
555 async def fake_register_or_update(player: Any) -> None:
556 registered[player.player_id] = player
557
558 mass.players.register_or_update = AsyncMock(side_effect=fake_register_or_update)
559
560 device = {"name": "sink1", "description": "Dummy Output", "hostapi": 0}
561 device_uuid = get_device_uuid("sink1", 0)
562 mass.loop.run_in_executor = AsyncMock(return_value=("sounddevice", [device]))
563
564 # a player object from before the provider reload is still registered
565 stale_player = MagicMock()
566 stale_player.player_id = device_uuid
567 stale_player.provider = MagicMock()
568 registered[device_uuid] = stale_player
569
570 manager = LocalAudioBridgeManager(provider)
571 manager._create_bridge = MagicMock( # type: ignore[method-assign]
572 side_effect=lambda _player: FakeBridge(manager.sendspin_server)
573 )
574
575 await manager.discover_and_register()
576
577 fresh_player = registered[device_uuid]
578 assert fresh_player is not stale_player
579 assert fresh_player.provider is provider
580 assert manager.get_bridge(device_uuid) is not None
581
582 @pytest.mark.asyncio
583 async def test_discover_handles_enumeration_failure(self) -> None:
584 """Test a failing device enumeration registers nothing and keeps the manager idle."""
585 mass, provider, registered = self._make_discover_environment()
586 mass.loop.run_in_executor = AsyncMock(side_effect=RuntimeError("enumeration failed"))
587 manager = LocalAudioBridgeManager(provider)
588
589 await manager.discover_and_register()
590
591 assert not registered
592 assert manager._devices == {}
593
594 @pytest.mark.asyncio
595 async def test_discover_without_devices(self) -> None:
596 """Test discovery on a host without output devices registers nothing."""
597 mass, provider, registered = self._make_discover_environment()
598 mass.loop.run_in_executor = AsyncMock(return_value=("alsa", []))
599 manager = LocalAudioBridgeManager(provider)
600
601 await manager.discover_and_register()
602
603 assert not registered
604 assert manager._devices == {}
605