/
/
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.sendspin.bridge_manager import SendspinBridgeManagerBase
13
14
15class FakeBridge:
16 """Minimal Sendspin bridge implementation for testing."""
17
18 def __init__(self, sendspin_server: Any) -> None:
19 """Initialize the fake bridge."""
20 self.sendspin_server = sendspin_server
21 self.started = False
22 self.stopped = False
23
24 @property
25 def is_registered(self) -> bool:
26 """Return whether the bridge is registered with Sendspin."""
27 return self.started and not self.stopped
28
29 async def start(self) -> None:
30 """Register the bridge as an external Sendspin client."""
31 self.started = True
32
33 async def stop(self) -> None:
34 """Stop and unregister the bridge."""
35 self.stopped = True
36
37
38class FakeBridgeManager(SendspinBridgeManagerBase[FakeBridge]):
39 """Concrete bridge manager with controllable policy for testing."""
40
41 policy_result = True
42
43 def _bridge_client_id(self, player: Any) -> str | None:
44 """Return the Sendspin client_id used to bridge the given player."""
45 return f"spb_{player.player_id}"
46
47 def _create_bridge(self, player: Any) -> FakeBridge:
48 """Create a (not yet started) bridge instance for the given player."""
49 return FakeBridge(self.sendspin_server)
50
51 def _should_have_bridge(self, player: Any) -> bool:
52 """Return whether provider policy wants a bridge for this player."""
53 return self.policy_result
54
55
56def _make_environment() -> tuple[
57 FakeBridgeManager, MagicMock, MagicMock, dict[str, Any], dict[str, Any]
58]:
59 """
60 Build a bridge manager with a mocked MusicAssistant environment.
61
62 :return: Tuple of (manager, mass, player, registered_players, player_configs).
63 """
64 registered_players: dict[str, Any] = {}
65 player_configs: dict[str, Any] = {}
66
67 mass = MagicMock()
68 mass.subscribe = MagicMock(return_value=MagicMock())
69 sendspin_provider = MagicMock()
70 sendspin_provider.server_api = MagicMock()
71 mass.get_provider = MagicMock(
72 side_effect=lambda domain: sendspin_provider if domain == "sendspin" else None
73 )
74 mass.players.get_player = MagicMock(side_effect=registered_players.get)
75 mass.config.get = MagicMock(
76 side_effect=lambda key, default=None: player_configs.get(key, default)
77 )
78
79 async def fake_save_player_config(player_id: str, values: dict[str, Any]) -> None:
80 player_configs.setdefault(f"players/{player_id}", {}).update(values)
81
82 mass.config.save_player_config = AsyncMock(side_effect=fake_save_player_config)
83
84 provider = MagicMock()
85 provider.mass = mass
86 provider.logger = logging.getLogger("test.bridge_manager")
87
88 player = MagicMock()
89 player.player_id = "player_1"
90 player.display_name = "Test Player"
91 player.provider = provider
92 registered_players["player_1"] = player
93 provider.players = [player]
94
95 manager = FakeBridgeManager(provider)
96 return manager, mass, player, registered_players, player_configs
97
98
99class TestBridgeLifecycleReconciliation:
100 """Tests for the desired-state reconciliation of Sendspin bridges."""
101
102 @pytest.mark.asyncio
103 async def test_bridge_created_when_all_conditions_met(self) -> None:
104 """Test a bridge is created for a registered, enabled player."""
105 manager, _, player, _, _ = _make_environment()
106
107 await manager.evaluate_bridge(player)
108
109 bridge = manager.get_bridge("player_1")
110 assert bridge is not None
111 assert bridge.is_registered
112
113 @pytest.mark.asyncio
114 async def test_bridge_removed_when_base_player_disabled(self) -> None:
115 """Test the bridge is torn down when the base player gets disabled."""
116 manager, _, player, _, player_configs = _make_environment()
117 await manager.evaluate_bridge(player)
118 bridge = manager.get_bridge("player_1")
119 assert bridge is not None
120
121 player_configs["players/player_1"] = {"enabled": False}
122 await manager.evaluate_bridge(player)
123
124 assert manager.get_bridge("player_1") is None
125 assert bridge.stopped
126
127 @pytest.mark.asyncio
128 async def test_bridge_removed_when_bridge_client_disabled(self) -> None:
129 """Test the bridge is torn down when its own Sendspin client gets disabled."""
130 manager, mass, player, _, player_configs = _make_environment()
131 await manager.evaluate_bridge(player)
132 assert manager.get_bridge("player_1") is not None
133
134 # a user-made disable carries the parent link the toggle was rendered under
135 player_configs["players/player_1"] = {"enabled": True}
136 player_configs["players/spb_player_1"] = {
137 "enabled": False,
138 "values": {"protocol_parent_id": "player_1"},
139 }
140 await manager.evaluate_bridge(player)
141
142 assert manager.get_bridge("player_1") is None
143 mass.config.save_player_config.assert_not_awaited()
144
145 @pytest.mark.asyncio
146 async def test_bridge_not_created_for_unregistered_player(self) -> None:
147 """Test no bridge is created when the player is not the registered instance."""
148 manager, _, player, registered_players, _ = _make_environment()
149 del registered_players["player_1"]
150
151 await manager.evaluate_bridge(player)
152
153 assert manager.get_bridge("player_1") is None
154
155 @pytest.mark.asyncio
156 async def test_policy_denial_removes_bridge_permanently(self) -> None:
157 """Test a policy denial removes the bridge and cleans up the client config."""
158 manager, mass, player, _, player_configs = _make_environment()
159 await manager.evaluate_bridge(player)
160 assert manager.get_bridge("player_1") is not None
161
162 manager.policy_result = False
163 player_configs["players/spb_player_1"] = {"enabled": True}
164 await manager.evaluate_bridge(player)
165
166 assert manager.get_bridge("player_1") is None
167 mass.players.delete_player_config.assert_called_once_with("spb_player_1")
168
169 @pytest.mark.asyncio
170 async def test_config_event_on_bridge_client_recreates_bridge(self) -> None:
171 """Test a config event on the bridge client id re-evaluates the base player."""
172 manager, _, player, _, player_configs = _make_environment()
173 player_configs["players/player_1"] = {"enabled": True}
174 player_configs["players/spb_player_1"] = {
175 "enabled": False,
176 "values": {"protocol_parent_id": "player_1"},
177 }
178 await manager.evaluate_bridge(player)
179 assert manager.get_bridge("player_1") is None
180
181 # Re-enable the bridge client and fire the (mapped) config event
182 player_configs["players/spb_player_1"] = {"enabled": True}
183 event = MagicMock()
184 event.object_id = "spb_player_1"
185 await manager._on_player_config_updated(event)
186
187 assert manager.get_bridge("player_1") is not None
188
189 @pytest.mark.asyncio
190 async def test_stale_disabled_client_reenabled_when_parent_gone(self) -> None:
191 """Test a client disabled under a no-longer-existing parent is re-enabled."""
192 manager, mass, player, _, player_configs = _make_environment()
193 # e.g. left behind by a cascade-disable from a parent player whose
194 # config was removed (device re-setup changed its player id)
195 player_configs["players/spb_player_1"] = {
196 "enabled": False,
197 "values": {"protocol_parent_id": "cc_old_uuid"},
198 }
199
200 await manager.evaluate_bridge(player)
201
202 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
203 assert manager.get_bridge("player_1") is not None
204
205 @pytest.mark.asyncio
206 async def test_stale_disabled_client_without_parent_link_reenabled(self) -> None:
207 """Test a disabled client without any parent link is re-enabled."""
208 manager, mass, player, _, player_configs = _make_environment()
209 player_configs["players/spb_player_1"] = {"enabled": False}
210
211 await manager.evaluate_bridge(player)
212
213 mass.config.save_player_config.assert_awaited_once_with("spb_player_1", {"enabled": True})
214 assert manager.get_bridge("player_1") is not None
215
216 @pytest.mark.asyncio
217 async def test_no_heal_when_base_player_disabled(self) -> None:
218 """Test a stale client disable is left alone while the base player is disabled."""
219 manager, mass, player, _, player_configs = _make_environment()
220 player_configs["players/player_1"] = {"enabled": False}
221 player_configs["players/spb_player_1"] = {"enabled": False}
222
223 await manager.evaluate_bridge(player)
224
225 mass.config.save_player_config.assert_not_awaited()
226 assert manager.get_bridge("player_1") is None
227
228 @pytest.mark.asyncio
229 async def test_stale_bridge_rebuilt_after_sendspin_reload(self) -> None:
230 """Test a bridge bound to a replaced Sendspin server is rebuilt."""
231 manager, mass, player, _, _ = _make_environment()
232 await manager.evaluate_bridge(player)
233 old_bridge = manager.get_bridge("player_1")
234 assert old_bridge is not None
235
236 # Simulate a sendspin provider reload (new server instance)
237 new_provider = MagicMock()
238 new_provider.server_api = MagicMock()
239 mass.get_provider = MagicMock(
240 side_effect=lambda domain: new_provider if domain == "sendspin" else None
241 )
242 await manager.evaluate_bridge(player)
243
244 new_bridge = manager.get_bridge("player_1")
245 assert old_bridge.stopped
246 assert new_bridge is not None
247 assert new_bridge is not old_bridge
248 assert new_bridge.sendspin_server is new_provider.server_api
249
250
251class TestCastBridgePolicy:
252 """Tests for the Chromecast-specific bridge policy."""
253
254 @staticmethod
255 def _make_cast_environment() -> tuple[CastSendspinBridgeManager, MagicMock, MagicMock]:
256 """
257 Build a cast bridge manager with a mocked environment.
258
259 :return: Tuple of (manager, mass, cast_player).
260 """
261 mass = MagicMock()
262 mass.subscribe = MagicMock(return_value=MagicMock())
263 mass.players.subscribe_player_state_update = MagicMock(return_value=MagicMock())
264 mass.create_task = MagicMock()
265 provider = MagicMock()
266 provider.mass = mass
267 provider.logger = logging.getLogger("test.cast_bridge_manager")
268
269 cast_player = MagicMock()
270 cast_player.player_id = "cc_player"
271 cast_player.display_name = "Cast Speaker"
272 cast_player.cast_info.is_audio_group = False
273 cast_player.cast_info.is_multichannel_group = False
274 cast_player.cast_info.mac_address = "AA:BB:CC:DD:EE:FF"
275 cast_player.device_info.manufacturer = "TestCo"
276 cast_player.device_info.model = "TestSpeaker"
277 cast_player.protocol_parent_id = "parent_1"
278 cast_player.provider = provider
279 provider.players = [cast_player]
280
281 manager = CastSendspinBridgeManager(provider)
282 return manager, mass, cast_player
283
284 def test_hard_deny_when_device_has_airplay(self) -> None:
285 """Test the cast bridge is denied when the device has AirPlay at all."""
286 manager, mass, cast_player = self._make_cast_environment()
287
288 parent = MagicMock()
289 airplay_protocol = MagicMock()
290 # AirPlay protocol link present but NOT available (e.g. disabled by user)
291 airplay_protocol.available = False
292 parent.get_output_protocol_by_domain = MagicMock(return_value=airplay_protocol)
293 mass.players.get_player = MagicMock(return_value=parent)
294
295 assert manager._should_have_bridge(cast_player) is False
296
297 def test_hard_deny_when_airplay_provider_not_loaded(self) -> None:
298 """Test the deny also applies while the airplay provider itself is not loaded."""
299 manager, mass, cast_player = self._make_cast_environment()
300
301 parent = MagicMock()
302 # Cached airplay protocol entry from an earlier session
303 parent.get_output_protocol_by_domain = MagicMock(return_value=MagicMock())
304 mass.get_provider = MagicMock(return_value=None)
305 mass.players.get_player = MagicMock(return_value=parent)
306
307 assert manager._should_have_bridge(cast_player) is False
308
309 def test_allowed_when_device_has_no_airplay(self) -> None:
310 """Test the cast bridge is allowed when the device has no AirPlay protocol."""
311 manager, mass, cast_player = self._make_cast_environment()
312
313 parent = MagicMock()
314 parent.get_output_protocol_by_domain = MagicMock(return_value=None)
315 mass.players.get_player = MagicMock(return_value=parent)
316
317 assert manager._should_have_bridge(cast_player) is True
318
319 def test_blocklist_message_only_repeats_after_policy_change(
320 self, caplog: pytest.LogCaptureFixture
321 ) -> None:
322 """Test repeated blocklist checks only log when the policy result changes."""
323 manager, _, cast_player = self._make_cast_environment()
324 cast_player.device_info.manufacturer = "Harman Luxury Audio"
325 cast_player.protocol_parent_id = None
326
327 with caplog.at_level(logging.DEBUG, logger=manager.logger.name):
328 assert manager._should_have_bridge(cast_player) is False
329 assert manager._should_have_bridge(cast_player) is False
330 cast_player.device_info.manufacturer = "TestCo"
331 assert manager._should_have_bridge(cast_player) is True
332 cast_player.device_info.manufacturer = "Harman Luxury Audio"
333 assert manager._should_have_bridge(cast_player) is False
334
335 blocklist_records = [
336 record for record in caplog.records if "device is blocklisted" in record.message
337 ]
338 assert len(blocklist_records) == 2
339
340 def test_irrelevant_state_update_does_not_schedule_evaluation(self) -> None:
341 """Test playback state updates do not re-evaluate bridge policy."""
342 manager, mass, cast_player = self._make_cast_environment()
343
344 manager._on_player_state_updated(cast_player, {"volume_level": (20, 30)})
345
346 mass.create_task.assert_not_called()
347
348 def test_relevant_cast_state_update_schedules_evaluation(self) -> None:
349 """Test device policy changes re-evaluate the affected Cast bridge."""
350 manager, mass, cast_player = self._make_cast_environment()
351
352 manager._on_player_state_updated(
353 cast_player, {"device_info.model": ("Old Model", "New Model")}
354 )
355
356 mass.create_task.assert_called_once_with(
357 manager._process_pending_bridge_evaluations,
358 cast_player.player_id,
359 task_id="evaluate_chromecast_sendspin_bridge_cc_player",
360 )
361
362 def test_parent_protocol_update_schedules_evaluation(self) -> None:
363 """Test protocol changes on a parent re-evaluate its Cast bridge."""
364 manager, mass, _ = self._make_cast_environment()
365 parent = MagicMock()
366 parent.player_id = "parent_1"
367 parent.protocol_parent_id = None
368
369 manager._on_player_state_updated(parent, {"output_protocols": ((), ("airplay",))})
370
371 mass.create_task.assert_called_once()
372
373 def test_unregistered_parent_schedules_evaluation(self) -> None:
374 """Test removal of a protocol parent re-evaluates its Cast bridge."""
375 manager, mass, _ = self._make_cast_environment()
376 mass.players.get_player.return_value = None
377 event = MagicMock()
378 event.object_id = "parent_1"
379
380 manager._on_player_unregistered(event)
381
382 mass.create_task.assert_called_once()
383
384 def test_registered_player_event_does_not_duplicate_evaluation(self) -> None:
385 """Test regular player events remain on the filtered state-update path."""
386 manager, mass, _ = self._make_cast_environment()
387 mass.players.get_player.return_value = MagicMock()
388 event = MagicMock()
389 event.object_id = "parent_1"
390
391 manager._on_player_unregistered(event)
392
393 mass.create_task.assert_not_called()
394
395 @pytest.mark.asyncio
396 async def test_update_during_evaluation_triggers_trailing_reconciliation(self) -> None:
397 """Test a policy update during reconciliation is processed afterward."""
398 manager, mass, cast_player = self._make_cast_environment()
399 mass.players.get_player.return_value = cast_player
400 evaluation_count = 0
401
402 async def evaluate_bridge(player: Any) -> None:
403 nonlocal evaluation_count
404 evaluation_count += 1
405 if evaluation_count == 1:
406 manager._pending_bridge_evaluations.add(player.player_id)
407
408 evaluate_mock = AsyncMock(side_effect=evaluate_bridge)
409 manager.evaluate_bridge = evaluate_mock # type: ignore[method-assign]
410 manager._pending_bridge_evaluations.add(cast_player.player_id)
411
412 await manager._process_pending_bridge_evaluations(cast_player.player_id)
413
414 assert evaluate_mock.await_count == 2
415 assert not manager._pending_bridge_evaluations
416