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