/
/
/
1"""Tests for the Sonos player connection/reconnect handling."""
2
3import asyncio
4import logging
5from unittest.mock import AsyncMock, MagicMock
6
7import pytest
8from aiohttp import ConnectionTimeoutError
9from aiosonos.exceptions import CannotConnect
10
11from music_assistant.mass import MusicAssistant
12from music_assistant.providers.sonos.player import SonosPlayer
13
14
15def _bind_player(mass: MusicAssistant | MagicMock) -> tuple[SonosPlayer, MagicMock]:
16 """Create a SonosPlayer bound to the given MusicAssistant, with a mocked client."""
17 player = SonosPlayer.__new__(SonosPlayer)
18 client = MagicMock()
19 client.disconnect = AsyncMock()
20 player.mass = mass
21 player.logger = logging.getLogger("test.sonos.player")
22 player._player_id = "sonos_player"
23 player._listen_task = None
24 player.connected = False
25 player.client = client
26 player._on_unload_callbacks = []
27 player.update_state = MagicMock() # type: ignore[misc, method-assign]
28 return player, client
29
30
31def _make_player() -> tuple[SonosPlayer, MagicMock]:
32 """Create a SonosPlayer with mocked connection dependencies."""
33 mass = MagicMock()
34 mass.closing = False
35 mass.players.get_player.return_value = MagicMock()
36 player, _ = _bind_player(mass)
37 return player, mass
38
39
40async def _connect_player(player: SonosPlayer, client: MagicMock) -> None:
41 """Connect the player to a listener that stays alive until it is cancelled."""
42
43 async def _start_listening(init_ready: asyncio.Event) -> None:
44 init_ready.set()
45 await asyncio.sleep(3600)
46
47 client.connect = AsyncMock()
48 client.start_listening = _start_listening
49 await player._connect()
50
51
52@pytest.mark.asyncio
53async def test_connect_timeout_reschedules_reconnect() -> None:
54 """Test a blackholed connection (timeout, not refused) still schedules a retry."""
55 player, mass = _make_player()
56 player.client.connect = AsyncMock( # type: ignore[method-assign]
57 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
58 )
59
60 await player._connect(retry_on_fail=30)
61
62 assert player._attr_available is False
63 mass.call_later.assert_called_once()
64 args, _ = mass.call_later.call_args
65 assert args[0] == min(30 + 30, 3600)
66 assert args[1] == player._connect
67
68
69@pytest.mark.asyncio
70async def test_connect_timeout_without_retry_raises() -> None:
71 """Test a connection failure without retry_on_fail still propagates."""
72 player, mass = _make_player()
73 player.client.connect = AsyncMock( # type: ignore[method-assign]
74 side_effect=ConnectionTimeoutError("Connection timeout to host https://x:1443")
75 )
76
77 with pytest.raises(ConnectionTimeoutError):
78 await player._connect(retry_on_fail=0)
79
80 mass.call_later.assert_not_called()
81
82
83@pytest.mark.asyncio
84async def test_connect_websocket_handshake_failure_reschedules_reconnect() -> None:
85 """Test a websocket handshake failure also reschedules a retry."""
86 player, mass = _make_player()
87 player.client.connect = AsyncMock( # type: ignore[method-assign]
88 side_effect=CannotConnect(OSError("handshake failed"))
89 )
90
91 await player._connect(retry_on_fail=30)
92
93 assert player._attr_available is False
94 mass.call_later.assert_called_once()
95
96
97@pytest.mark.asyncio
98async def test_on_unload_disconnects_without_reconnecting(timer_mass: MusicAssistant) -> None:
99 """Test an unloaded player disconnects and its aborted listener does not reconnect."""
100 player, client = _bind_player(timer_mass)
101 await _connect_player(player, client)
102 listener = player._listen_task
103 assert listener is not None
104
105 await player.on_unload()
106
107 assert player.connected is False
108 client.disconnect.assert_awaited_once()
109 # let the aborted listener run its cleanup
110 with pytest.raises(asyncio.CancelledError):
111 await listener
112 assert timer_mass._tracked_timers == {}
113
114
115@pytest.mark.asyncio
116async def test_on_unload_cancels_a_pending_reconnect(timer_mass: MusicAssistant) -> None:
117 """Test a reconnect that is still waiting to fire does not connect after the unload."""
118 player, _ = _bind_player(timer_mass)
119 connect_attempts: list[int] = []
120
121 async def _connect(retry_on_fail: int = 0) -> None:
122 connect_attempts.append(retry_on_fail)
123
124 player._connect = _connect # type: ignore[method-assign]
125 player.reconnect(0)
126 handle = timer_mass._tracked_timers[f"sonos_reconnect_{player.player_id}"]
127
128 await player.on_unload()
129 await asyncio.sleep(0.05)
130
131 assert handle.cancelled()
132 assert connect_attempts == []
133
134
135@pytest.mark.asyncio
136async def test_on_unload_cancels_a_scheduled_airplay_group_restore(
137 timer_mass: MusicAssistant,
138) -> None:
139 """Test the AirPlay group restore scheduled for a player does not run after the unload."""
140 player, client = _bind_player(timer_mass)
141 player._attr_name = "Sonos Player"
142 client.player.is_coordinator = True
143 client.player.group_members = [player.player_id, "sonos_player_2"]
144 output_protocol = MagicMock()
145 output_protocol.protocol_domain = "airplay"
146
147 await player.on_protocol_playback(output_protocol)
148 handle = timer_mass._tracked_timers[f"restore_airplay_group_{player.player_id}"]
149
150 await player.on_unload()
151
152 assert handle.cancelled()
153 assert timer_mass._tracked_timers == {}
154
155
156@pytest.mark.asyncio
157async def test_on_unload_cancels_an_airplay_group_restore_that_already_started(
158 timer_mass: MusicAssistant,
159) -> None:
160 """Test a group restore that already started is aborted when the player is unloaded."""
161 player, _ = _bind_player(timer_mass)
162 restoring = asyncio.Event()
163
164 async def _restore_airplay_group() -> None:
165 restoring.set()
166 await asyncio.sleep(5)
167
168 task_id = f"restore_airplay_group_{player.player_id}"
169 timer_mass.call_later(0, _restore_airplay_group, task_id=task_id)
170 await restoring.wait()
171 task = timer_mass._tracked_tasks[task_id]
172
173 await player.on_unload()
174
175 with pytest.raises(asyncio.CancelledError):
176 await task
177
178
179@pytest.mark.asyncio
180async def test_on_unload_unsubscribes_before_disconnecting(timer_mass: MusicAssistant) -> None:
181 """Test the registered unload callbacks run before the client is disconnected."""
182 player, client = _bind_player(timer_mass)
183 calls: list[str] = []
184 player._on_unload_callbacks.append(lambda: calls.append("unsubscribe"))
185 client.disconnect = AsyncMock(side_effect=lambda: calls.append("disconnect"))
186
187 await player.on_unload()
188
189 assert calls == ["unsubscribe", "disconnect"]
190
191
192@pytest.mark.asyncio
193async def test_a_listener_ending_during_the_unload_cannot_rearm_a_reconnect(
194 timer_mass: MusicAssistant,
195) -> None:
196 """Test a listener that ends while the player is unloading does not schedule a reconnect."""
197 player, client = _bind_player(timer_mass)
198 socket_drops = asyncio.Event()
199
200 async def _start_listening(init_ready: asyncio.Event) -> None:
201 init_ready.set()
202 await socket_drops.wait()
203 raise ConnectionResetError("socket dropped")
204
205 client.connect = AsyncMock()
206 client.start_listening = _start_listening
207 await player._connect()
208 listener = player._listen_task
209 assert listener is not None
210
211 # release the listener so it is queued to run its finally, then unload without
212 # yielding in between: the cancellations and connected=False must be indivisible
213 socket_drops.set()
214 await player.on_unload()
215
216 with pytest.raises(asyncio.CancelledError):
217 await listener
218 assert timer_mass._tracked_timers == {}
219 assert timer_mass._tracked_tasks == {}
220
221
222@pytest.mark.asyncio
223async def test_on_unload_survives_a_failing_disconnect(timer_mass: MusicAssistant) -> None:
224 """Test a speaker that cannot be disconnected does not abort the unload."""
225 player, client = _bind_player(timer_mass)
226 client.disconnect = AsyncMock(side_effect=OSError("speaker unreachable"))
227 player.reconnect(0)
228
229 await player.on_unload()
230
231 assert player.connected is False
232 assert timer_mass._tracked_timers == {}
233