/
/
/
1"""Tests for the Sendspin WebSocket proxy handler."""
2
3import asyncio
4from unittest.mock import AsyncMock, MagicMock, patch
5
6import aiohttp
7import pytest
8from aiohttp import ClientConnectorError, web
9from aiohttp.test_utils import make_mocked_request
10
11from music_assistant.controllers.webserver.sendspin_proxy import SendspinProxyHandler
12
13
14@pytest.fixture
15def mock_webserver() -> MagicMock:
16 """Create a mock webserver controller."""
17 webserver = MagicMock()
18 webserver.mass = MagicMock()
19 webserver.mass.http_session = MagicMock()
20 return webserver
21
22
23@pytest.fixture
24def handler(mock_webserver: MagicMock) -> SendspinProxyHandler:
25 """Create a SendspinProxyHandler with mocked dependencies."""
26 return SendspinProxyHandler(mock_webserver)
27
28
29def _make_connector_error() -> ClientConnectorError:
30 """Create a realistic ClientConnectorError for testing."""
31 connection_key = MagicMock()
32 return ClientConnectorError(connection_key, OSError(111, "Connection refused"))
33
34
35async def test_proxy_dials_the_webserver_url(
36 handler: SendspinProxyHandler, mock_webserver: MagicMock
37) -> None:
38 """Verify the proxy connects to the URL resolved by the webserver controller."""
39 internal_url = "ws://127.0.0.1:8927/sendspin"
40 mock_ws_response = AsyncMock(spec=web.WebSocketResponse)
41 mock_ws_response.closed = False
42 mock_webserver.internal_sendspin_url = internal_url
43
44 mock_internal_ws = AsyncMock()
45 mock_internal_ws.closed = False
46 mock_ws_connect = AsyncMock(return_value=mock_internal_ws)
47
48 with (
49 patch.object(handler, "_authenticate", return_value=MagicMock()),
50 patch.object(handler, "_proxy_messages", new_callable=AsyncMock),
51 patch("aiohttp.web.WebSocketResponse", return_value=mock_ws_response),
52 patch.object(handler.mass, "http_session", create=True) as mock_session,
53 patch(
54 "music_assistant.controllers.webserver.sendspin_proxy.is_request_from_ingress",
55 return_value=False,
56 ),
57 ):
58 mock_session.ws_connect = mock_ws_connect
59 request = make_mocked_request("GET", "/sendspin")
60 await handler.handle_sendspin_proxy(request)
61
62 mock_ws_connect.assert_awaited_once_with(internal_url)
63
64
65class TestSendspinProxyRetry:
66 """Tests for the retry logic when connecting to the internal Sendspin server."""
67
68 async def test_retries_on_connection_refused(self, handler: SendspinProxyHandler) -> None:
69 """Verify the proxy retries on ClientConnectorError before giving up."""
70 mock_ws_response = AsyncMock(spec=web.WebSocketResponse)
71 mock_ws_response.closed = False
72
73 connector_error = _make_connector_error()
74
75 mock_internal_ws = AsyncMock()
76 mock_internal_ws.closed = False
77 mock_ws_connect = AsyncMock(
78 side_effect=[connector_error, connector_error, mock_internal_ws]
79 )
80
81 with (
82 patch.object(handler, "_authenticate", return_value=MagicMock()),
83 patch.object(handler, "_proxy_messages", new_callable=AsyncMock),
84 patch("aiohttp.web.WebSocketResponse", return_value=mock_ws_response),
85 patch.object(handler.mass, "http_session", create=True) as mock_session,
86 patch(
87 "music_assistant.controllers.webserver.sendspin_proxy.asyncio.sleep",
88 new_callable=AsyncMock,
89 ) as mock_sleep,
90 patch(
91 "music_assistant.controllers.webserver.sendspin_proxy.is_request_from_ingress",
92 return_value=False,
93 ),
94 ):
95 mock_session.ws_connect = mock_ws_connect
96 request = make_mocked_request("GET", "/sendspin")
97 await handler.handle_sendspin_proxy(request)
98
99 assert mock_ws_connect.call_count == 3
100 assert mock_sleep.call_count == 2
101 # Verify backoff: 0.5s, then 1.0s
102 mock_sleep.assert_any_call(0.5)
103 mock_sleep.assert_any_call(1.0)
104
105 async def test_gives_up_after_max_retries(self, handler: SendspinProxyHandler) -> None:
106 """Verify the proxy closes the client websocket after exhausting retries."""
107 mock_ws_response = AsyncMock(spec=web.WebSocketResponse)
108 mock_ws_response.closed = False
109
110 connector_error = _make_connector_error()
111 mock_ws_connect = AsyncMock(side_effect=connector_error)
112
113 with (
114 patch.object(handler, "_authenticate", return_value=MagicMock()),
115 patch("aiohttp.web.WebSocketResponse", return_value=mock_ws_response),
116 patch.object(handler.mass, "http_session", create=True) as mock_session,
117 patch(
118 "music_assistant.controllers.webserver.sendspin_proxy.asyncio.sleep",
119 new_callable=AsyncMock,
120 ),
121 patch(
122 "music_assistant.controllers.webserver.sendspin_proxy.is_request_from_ingress",
123 return_value=False,
124 ),
125 ):
126 mock_session.ws_connect = mock_ws_connect
127 request = make_mocked_request("GET", "/sendspin")
128 result = await handler.handle_sendspin_proxy(request)
129
130 assert mock_ws_connect.call_count == 5
131 mock_ws_response.close.assert_called_once_with(code=1011, message=b"Internal server error")
132 assert result is mock_ws_response
133
134 async def test_does_not_retry_on_other_exceptions(self, handler: SendspinProxyHandler) -> None:
135 """Verify non-connection errors are not retried and websocket is closed cleanly."""
136 mock_ws_response = AsyncMock(spec=web.WebSocketResponse)
137 mock_ws_response.closed = False
138
139 mock_ws_connect = AsyncMock(side_effect=TypeError("unexpected error"))
140
141 with (
142 patch.object(handler, "_authenticate", return_value=MagicMock()),
143 patch("aiohttp.web.WebSocketResponse", return_value=mock_ws_response),
144 patch.object(handler.mass, "http_session", create=True) as mock_session,
145 patch(
146 "music_assistant.controllers.webserver.sendspin_proxy.is_request_from_ingress",
147 return_value=False,
148 ),
149 ):
150 mock_session.ws_connect = mock_ws_connect
151 request = make_mocked_request("GET", "/sendspin")
152 result = await handler.handle_sendspin_proxy(request)
153
154 assert mock_ws_connect.call_count == 1
155 mock_ws_response.close.assert_called_once_with(code=1011, message=b"Internal server error")
156 assert result is mock_ws_response
157
158
159class TestSendspinProxyMessages:
160 """Tests for bidirectional proxy task handling."""
161
162 async def test_expected_disconnect_is_consumed(self, handler: SendspinProxyHandler) -> None:
163 """Verify normal client disconnects do not leak as unretrieved task exceptions."""
164
165 async def raise_disconnect(*_: object) -> None:
166 raise ConnectionError("Connection lost")
167
168 async def wait_forever(*_: object) -> None:
169 await asyncio.Event().wait()
170
171 with (
172 patch.object(handler, "_forward_internal_to_client", new=raise_disconnect),
173 patch.object(handler, "_forward_client_to_internal", new=wait_forever),
174 ):
175 await handler._proxy_messages(MagicMock(), MagicMock())
176
177 async def test_unexpected_forwarding_error_is_raised(
178 self, handler: SendspinProxyHandler
179 ) -> None:
180 """Verify unexpected proxy task errors are still surfaced."""
181
182 async def raise_unexpected(*_: object) -> None:
183 raise RuntimeError("boom")
184
185 async def wait_forever(*_: object) -> None:
186 await asyncio.Event().wait()
187
188 with (
189 patch.object(handler, "_forward_internal_to_client", new=raise_unexpected),
190 patch.object(handler, "_forward_client_to_internal", new=wait_forever),
191 pytest.raises(RuntimeError, match="boom"),
192 ):
193 await handler._proxy_messages(MagicMock(), MagicMock())
194
195 async def test_primary_error_is_not_masked_by_peer_cleanup_failure(
196 self, handler: SendspinProxyHandler
197 ) -> None:
198 """The first real failure must survive even if the peer task also errors."""
199
200 async def raise_primary(*_: object) -> None:
201 raise RuntimeError("primary failure")
202
203 async def raise_on_cancel(*_: object) -> None:
204 try:
205 await asyncio.Event().wait()
206 except asyncio.CancelledError:
207 raise RuntimeError("secondary cleanup failure") from None
208
209 with (
210 patch.object(handler, "_forward_internal_to_client", new=raise_primary),
211 patch.object(handler, "_forward_client_to_internal", new=raise_on_cancel),
212 pytest.raises(RuntimeError, match="primary failure"),
213 ):
214 await handler._proxy_messages(MagicMock(), MagicMock())
215
216 @pytest.mark.parametrize(
217 "exc",
218 [
219 aiohttp.ClientError("handshake failed"),
220 asyncio.IncompleteReadError(b"", 1),
221 ],
222 )
223 async def test_expected_transport_errors_are_consumed(
224 self, handler: SendspinProxyHandler, exc: BaseException
225 ) -> None:
226 """Normal aiohttp/stream disconnects must not bubble up as 500s."""
227
228 async def raise_disconnect(*_: object) -> None:
229 raise exc
230
231 async def wait_forever(*_: object) -> None:
232 await asyncio.Event().wait()
233
234 with (
235 patch.object(handler, "_forward_internal_to_client", new=raise_disconnect),
236 patch.object(handler, "_forward_client_to_internal", new=wait_forever),
237 ):
238 await handler._proxy_messages(MagicMock(), MagicMock())
239