/
/
/
1"""Tests for MSXBridgeProvider lifecycle."""
2
3from __future__ import annotations
4
5from unittest.mock import AsyncMock, Mock, patch
6
7from music_assistant.providers.msx_bridge.provider import MSXBridgeProvider
8
9
10async def test_init_without_sendspin_provider_module(
11 provider: MSXBridgeProvider,
12) -> None:
13 """
14 The provider must load even when the Sendspin provider module is absent.
15
16 In a Music Assistant install that ships no Sendspin provider, importing
17 the bridge manager fails; the MSX provider must degrade to "no bridge"
18 instead of failing to load.
19 """
20 provider.sendspin_bridge_enabled = True
21 with (
22 patch("music_assistant.providers.msx_bridge.provider.MSXHTTPServer") as mock_server_cls,
23 patch.object(
24 MSXBridgeProvider,
25 "_make_bridge_manager",
26 side_effect=ImportError("No module named 'music_assistant.providers.sendspin'"),
27 ),
28 ):
29 mock_server_cls.return_value = AsyncMock()
30 await provider.handle_async_init()
31
32 assert provider.bridge_manager is None
33
34
35async def test_handle_async_init(provider: MSXBridgeProvider) -> None:
36 """handle_async_init should create an MSXHTTPServer and start it."""
37 with patch("music_assistant.providers.msx_bridge.provider.MSXHTTPServer") as mock_server_cls:
38 mock_server = AsyncMock()
39 mock_server_cls.return_value = mock_server
40
41 await provider.handle_async_init()
42
43 mock_server_cls.assert_called_once_with(provider, 8099)
44 mock_server.start.assert_awaited_once()
45 assert provider.http_server is mock_server
46
47
48async def test_handle_async_init_default_port(mass_mock: Mock, manifest_mock: Mock) -> None:
49 """handle_async_init should use DEFAULT_HTTP_PORT when config returns None."""
50 config = Mock()
51 config.name = "MSX Bridge"
52 config.instance_id = "msx_bridge_test"
53 config.enabled = True
54 # Return None for http_port â provider should fall back to default
55 config.get_value = Mock(
56 side_effect=lambda key, default=None: {
57 "log_level": "GLOBAL",
58 }.get(key, default)
59 )
60
61 prov = MSXBridgeProvider(mass_mock, manifest_mock, config, set())
62
63 with patch("music_assistant.providers.msx_bridge.provider.MSXHTTPServer") as mock_server_cls:
64 mock_server = AsyncMock()
65 mock_server_cls.return_value = mock_server
66
67 await prov.handle_async_init()
68
69 # config.get_value() returns DEFAULT_HTTP_PORT when the key has a default_value
70 mock_server_cls.assert_called_once()
71 mock_server.start.assert_awaited_once()
72
73
74async def test_get_ma_stream_url_uses_streamserver(
75 provider: MSXBridgeProvider, mass_mock: Mock
76) -> None:
77 """get_ma_stream_url must resolve the URL via the MA streamserver API."""
78 media = Mock()
79 mass_mock.streams.resolve_stream_url = AsyncMock(
80 return_value="http://ma:8097/single/s1/q1/i1/msx_test.mp3"
81 )
82
83 url = await provider.get_ma_stream_url("msx_test", media)
84
85 assert url == "http://ma:8097/single/s1/q1/i1/msx_test.mp3"
86 mass_mock.streams.resolve_stream_url.assert_awaited_once_with("msx_test", media)
87
88
89async def test_get_ma_stream_url_rejects_flow_urls(
90 provider: MSXBridgeProvider, mass_mock: Mock
91) -> None:
92 """
93 A flow-mode URL must be rejected (None -> proxy fallback).
94
95 MA forces flow mode when e.g. crossfade is enabled and the player lacks
96 gapless support. A flow URL streams the whole queue continuously, which
97 breaks the MSX per-track model (progress display, auto-advance).
98 """
99 mass_mock.streams.resolve_stream_url = AsyncMock(
100 return_value="http://ma:8097/flow/s1/q1/i1/msx_test.mp3"
101 )
102
103 url = await provider.get_ma_stream_url("msx_test", Mock())
104
105 assert url is None
106
107
108async def test_get_ma_stream_url_returns_none_on_error(
109 provider: MSXBridgeProvider, mass_mock: Mock
110) -> None:
111 """get_ma_stream_url must degrade to None (proxy fallback) when resolution fails."""
112 mass_mock.streams.resolve_stream_url = AsyncMock(side_effect=RuntimeError("no session"))
113
114 url = await provider.get_ma_stream_url("msx_test", Mock())
115
116 assert url is None
117 mass_mock.streams.resolve_stream_url.assert_awaited_once()
118
119
120def test_on_player_activity_uses_monotonic_clock(provider: MSXBridgeProvider) -> None:
121 """
122 The idle-activity ledger must use the monotonic clock.
123
124 With wall-clock timestamps, an NTP step forward (common on RTC-less hosts
125 right after boot) instantly ages every player past the idle cutoff and
126 mass-unregisters them mid-session.
127 """
128 with patch("music_assistant.providers.msx_bridge.provider.time") as mock_time:
129 mock_time.monotonic.return_value = 1234.0
130 provider.on_player_activity("msx_x")
131
132 assert provider._player_last_activity["msx_x"] == 1234.0
133
134
135async def test_loaded_in_mass_starts_timeout_task(provider: MSXBridgeProvider) -> None:
136 """loaded_in_mass should start idle timeout task and/or register default player."""
137 mock_task = Mock()
138 provider.mass.create_task = Mock(return_value=mock_task) # type: ignore[method-assign]
139
140 await provider.loaded_in_mass()
141
142 # Our impl: starts timeout task. MA-server bundled: may register default player.
143 assert provider.mass.create_task.called or provider.mass.players.register.called # type: ignore[attr-defined]
144 if provider.mass.create_task.called:
145 assert provider._timeout_task is mock_task
146
147
148async def test_unload_stops_server_first(provider: MSXBridgeProvider) -> None:
149 """Unload should stop the HTTP server and unregister all players."""
150 mock_server = AsyncMock()
151 provider.http_server = mock_server
152
153 mock_player = Mock()
154 mock_player.display_name = "Test TV"
155 mock_player.player_id = "msx_test"
156 provider.mass.players.all.return_value = [mock_player] # type: ignore[attr-defined]
157 provider.mass.players.iter_players.return_value = [mock_player] # type: ignore[attr-defined]
158
159 await provider.unload()
160
161 mock_server.stop.assert_awaited_once()
162 provider.mass.players.unregister.assert_awaited_once_with("msx_test") # type: ignore[attr-defined]
163
164
165async def test_unload_no_server(provider: MSXBridgeProvider) -> None:
166 """Unload should not crash when http_server is None."""
167 provider.http_server = None
168 provider.mass.players.all.return_value = [] # type: ignore[attr-defined]
169 provider.mass.players.iter_players.return_value = [] # type: ignore[attr-defined]
170
171 await provider.unload() # should not raise
172
173
174async def test_discover_players_noop(provider: MSXBridgeProvider) -> None:
175 """discover_players should complete without error."""
176 await provider.discover_players()
177
178
179async def test_on_player_disabled_does_not_unregister(
180 provider: MSXBridgeProvider,
181) -> None:
182 """on_player_disabled should broadcast stop and cancel streams, but NOT unregister."""
183 mock_server = Mock()
184 mock_server.broadcast_stop = Mock()
185 mock_server.cancel_streams_for_player = Mock()
186 provider.http_server = mock_server
187
188 provider.on_player_disabled("msx_test")
189
190 mock_server.broadcast_stop.assert_called_once_with("msx_test")
191 mock_server.cancel_streams_for_player.assert_called_once_with("msx_test")
192 provider.mass.players.unregister.assert_not_called() # type: ignore[attr-defined]
193
194
195async def test_on_player_disabled_noop_when_no_server(
196 provider: MSXBridgeProvider,
197) -> None:
198 """on_player_disabled should not crash when http_server is None."""
199 provider.http_server = None
200 provider.on_player_disabled("msx_test") # should not raise
201
202
203async def test_on_player_enabled_noop(provider: MSXBridgeProvider) -> None:
204 """on_player_enabled should complete without error (player stays registered)."""
205 provider.on_player_enabled("msx_test") # should not raise
206