/
/
/
1"""Tests for config entries and requires_reload settings."""
2
3from typing import Any
4
5import pytest
6from music_assistant_models.config_entries import ConfigEntry, CoreConfig
7from music_assistant_models.enums import ConfigEntryType
8
9from music_assistant.constants import (
10 CONF_BIND_IP,
11 CONF_BIND_PORT,
12 CONF_ENTRY_ZEROCONF_INTERFACES,
13 CONF_LOG_LEVEL,
14 CONF_PUBLISH_IP,
15 CONF_ZEROCONF_INTERFACES,
16)
17from music_assistant.controllers.discovery import DiscoveryController
18from music_assistant.controllers.players import PlayerController
19from music_assistant.models.core_controller import CoreController
20
21
22class TestRequiresReload:
23 """Tests to verify requires_reload is set correctly on config entries."""
24
25 def test_zeroconf_interfaces_requires_reload(self) -> None:
26 """
27 Test that CONF_ENTRY_ZEROCONF_INTERFACES has requires_reload=True.
28
29 This entry is read at MusicAssistant startup to configure the zeroconf instance,
30 so changes require a reload.
31 """
32 assert CONF_ENTRY_ZEROCONF_INTERFACES.requires_reload is True, (
33 f"CONF_ENTRY_ZEROCONF_INTERFACES ({CONF_ZEROCONF_INTERFACES}) should have "
34 "requires_reload=True because it's read at startup time"
35 )
36
37 @pytest.mark.asyncio
38 async def test_zeroconf_interfaces_entry_lives_on_discovery_controller(
39 self, mock_mass: MockMass
40 ) -> None:
41 """Test that zeroconf interface selection belongs to the discovery controller."""
42 discovery_controller = DiscoveryController(mock_mass)
43 player_controller = PlayerController(mock_mass) # type: ignore[arg-type]
44
45 discovery_entries = await discovery_controller.get_config_entries()
46 player_entries = await player_controller.get_config_entries()
47
48 assert any(entry.key == CONF_ZEROCONF_INTERFACES for entry in discovery_entries)
49 assert all(entry.key != CONF_ZEROCONF_INTERFACES for entry in player_entries)
50
51
52class TestStreamsControllerConfigEntries:
53 """Tests for streams controller config entries."""
54
55 def test_streams_bind_port_requires_reload(self) -> None:
56 """
57 Test that CONF_BIND_PORT in streams controller has requires_reload=True.
58
59 The bind port is used when starting the webserver in setup(),
60 so changes require a reload.
61 """
62 # We verify by checking that the key is in the list of entries
63 # that should require reload
64 entries_requiring_reload = {
65 CONF_BIND_PORT,
66 CONF_BIND_IP,
67 CONF_PUBLISH_IP,
68 }
69
70 # This test documents that these entries need requires_reload=True
71 assert len(entries_requiring_reload) == 3
72
73
74class TestWebserverControllerConfigEntries:
75 """Tests for webserver controller config entries."""
76
77 def test_webserver_bind_entries_require_reload(self) -> None:
78 """
79 Test that webserver bind/SSL entries have requires_reload=True.
80
81 Entries that affect the webserver's network binding or SSL configuration
82 must trigger a reload when changed.
83 """
84 # These are the keys that should have requires_reload=True in the
85 # webserver controller
86 entries_requiring_reload = {
87 CONF_BIND_PORT,
88 CONF_BIND_IP,
89 "enable_ssl",
90 "ssl_certificate",
91 "ssl_private_key",
92 }
93
94 # These keys should have requires_reload=False (read dynamically)
95 entries_not_requiring_reload = {
96 "base_url",
97 "auth_allow_self_registration",
98 }
99
100 # This test documents the expected behavior
101 assert len(entries_requiring_reload) == 5
102 assert len(entries_not_requiring_reload) == 2
103
104
105class MockMass:
106 """Mock MusicAssistant instance for testing CoreController."""
107
108 def __init__(self) -> None:
109 """Initialize mock."""
110 self.call_later_calls: list[tuple[Any, ...]] = []
111
112 def call_later(self, *args: Any, **kwargs: Any) -> None:
113 """Record call_later invocations."""
114 self.call_later_calls.append((args, kwargs))
115
116 def get_providers_supporting_feature(self, *args: Any, **kwargs: Any) -> list[Any]:
117 """Report no loaded providers, so engine-backed config entries stay empty."""
118 return []
119
120
121class MockConfig:
122 """Mock config for testing CoreController."""
123
124 def get_raw_core_config_value(self, domain: str, key: str, default: str = "GLOBAL") -> str:
125 """Return a mock log level."""
126 return "INFO"
127
128
129@pytest.fixture
130def mock_mass() -> MockMass:
131 """Create a mock MusicAssistant instance."""
132 mass = MockMass()
133 mass.config = MockConfig() # type: ignore[attr-defined]
134 return mass
135
136
137@pytest.fixture
138def test_controller(mock_mass: MockMass) -> CoreController:
139 """Create a test CoreController instance."""
140
141 class TestController(CoreController):
142 domain = "test"
143
144 return TestController(mock_mass) # type: ignore[arg-type]
145
146
147@pytest.fixture
148def entry_with_reload() -> ConfigEntry:
149 """Create a ConfigEntry that requires reload."""
150 return ConfigEntry(
151 key="needs_reload",
152 type=ConfigEntryType.STRING,
153 label="Needs Reload",
154 default_value="default",
155 requires_reload=True,
156 )
157
158
159@pytest.fixture
160def entry_without_reload() -> ConfigEntry:
161 """Create a ConfigEntry that does not require reload."""
162 return ConfigEntry(
163 key="no_reload",
164 type=ConfigEntryType.STRING,
165 label="No Reload",
166 default_value="default",
167 requires_reload=False,
168 )
169
170
171@pytest.mark.asyncio
172async def test_core_controller_update_config_triggers_reload_when_required(
173 mock_mass: MockMass,
174 test_controller: CoreController,
175 entry_with_reload: ConfigEntry,
176) -> None:
177 """Test that CoreController.update_config triggers reload for requires_reload=True."""
178 config = CoreConfig(
179 values={"needs_reload": entry_with_reload},
180 domain="test",
181 )
182 entry_with_reload.value = "new_value"
183
184 await test_controller.update_config(config, {"values/needs_reload"})
185
186 # Verify call_later was called (which schedules the reload)
187 assert len(mock_mass.call_later_calls) == 1
188 args, kwargs = mock_mass.call_later_calls[0]
189 assert "reload" in str(args) or "reload" in str(kwargs)
190
191
192@pytest.mark.asyncio
193async def test_core_controller_update_config_skips_reload_when_not_required(
194 mock_mass: MockMass,
195 test_controller: CoreController,
196 entry_without_reload: ConfigEntry,
197) -> None:
198 """Test that CoreController.update_config skips reload for requires_reload=False."""
199 config = CoreConfig(
200 values={"no_reload": entry_without_reload},
201 domain="test",
202 )
203 entry_without_reload.value = "new_value"
204
205 await test_controller.update_config(config, {"values/no_reload"})
206
207 # Verify call_later was NOT called
208 assert len(mock_mass.call_later_calls) == 0
209
210
211@pytest.mark.asyncio
212async def test_core_controller_reload_runs_post_setup(mock_mass: MockMass) -> None:
213 """Test that CoreController.reload also reruns post-setup logic."""
214
215 class TestController(CoreController):
216 domain = "test"
217
218 def __init__(self, mass: MockMass) -> None:
219 """Initialize test controller."""
220 super().__init__(mass) # type: ignore[arg-type]
221 self.setup_calls = 0
222 self.post_setup_calls = 0
223
224 async def setup(self, config: CoreConfig) -> None:
225 """Record setup invocations."""
226 self.setup_calls += 1
227 self.config = config
228
229 async def post_setup(self) -> None:
230 """Record post-setup invocations."""
231 self.post_setup_calls += 1
232
233 controller = TestController(mock_mass)
234 config = CoreConfig(
235 domain="test",
236 values={
237 CONF_LOG_LEVEL: ConfigEntry(
238 key=CONF_LOG_LEVEL,
239 type=ConfigEntryType.STRING,
240 label="Log level",
241 default_value="INFO",
242 value="INFO",
243 )
244 },
245 )
246
247 await controller.reload(config)
248
249 assert controller.setup_calls == 1
250 assert controller.post_setup_calls == 1
251
252
253def test_config_entry_default_requires_reload_is_false() -> None:
254 """
255 Test that ConfigEntry defaults requires_reload to False.
256
257 This documents the expected default behavior from the models package.
258 Config entries must explicitly set requires_reload=True if they need it.
259 """
260 entry = ConfigEntry(
261 key="test",
262 type=ConfigEntryType.STRING,
263 label="Test Entry",
264 default_value="default",
265 )
266
267 assert entry.requires_reload is False, (
268 "ConfigEntry should default requires_reload to False. "
269 "Entries that need reload must explicitly set requires_reload=True."
270 )
271