/
/
/
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
117class MockConfig:
118 """Mock config for testing CoreController."""
119
120 def get_raw_core_config_value(self, domain: str, key: str, default: str = "GLOBAL") -> str:
121 """Return a mock log level."""
122 return "INFO"
123
124
125@pytest.fixture
126def mock_mass() -> MockMass:
127 """Create a mock MusicAssistant instance."""
128 mass = MockMass()
129 mass.config = MockConfig() # type: ignore[attr-defined]
130 return mass
131
132
133@pytest.fixture
134def test_controller(mock_mass: MockMass) -> CoreController:
135 """Create a test CoreController instance."""
136
137 class TestController(CoreController):
138 domain = "test"
139
140 return TestController(mock_mass) # type: ignore[arg-type]
141
142
143@pytest.fixture
144def entry_with_reload() -> ConfigEntry:
145 """Create a ConfigEntry that requires reload."""
146 return ConfigEntry(
147 key="needs_reload",
148 type=ConfigEntryType.STRING,
149 label="Needs Reload",
150 default_value="default",
151 requires_reload=True,
152 )
153
154
155@pytest.fixture
156def entry_without_reload() -> ConfigEntry:
157 """Create a ConfigEntry that does not require reload."""
158 return ConfigEntry(
159 key="no_reload",
160 type=ConfigEntryType.STRING,
161 label="No Reload",
162 default_value="default",
163 requires_reload=False,
164 )
165
166
167@pytest.mark.asyncio
168async def test_core_controller_update_config_triggers_reload_when_required(
169 mock_mass: MockMass,
170 test_controller: CoreController,
171 entry_with_reload: ConfigEntry,
172) -> None:
173 """Test that CoreController.update_config triggers reload for requires_reload=True."""
174 config = CoreConfig(
175 values={"needs_reload": entry_with_reload},
176 domain="test",
177 )
178 entry_with_reload.value = "new_value"
179
180 await test_controller.update_config(config, {"values/needs_reload"})
181
182 # Verify call_later was called (which schedules the reload)
183 assert len(mock_mass.call_later_calls) == 1
184 args, kwargs = mock_mass.call_later_calls[0]
185 assert "reload" in str(args) or "reload" in str(kwargs)
186
187
188@pytest.mark.asyncio
189async def test_core_controller_update_config_skips_reload_when_not_required(
190 mock_mass: MockMass,
191 test_controller: CoreController,
192 entry_without_reload: ConfigEntry,
193) -> None:
194 """Test that CoreController.update_config skips reload for requires_reload=False."""
195 config = CoreConfig(
196 values={"no_reload": entry_without_reload},
197 domain="test",
198 )
199 entry_without_reload.value = "new_value"
200
201 await test_controller.update_config(config, {"values/no_reload"})
202
203 # Verify call_later was NOT called
204 assert len(mock_mass.call_later_calls) == 0
205
206
207@pytest.mark.asyncio
208async def test_core_controller_reload_runs_post_setup(mock_mass: MockMass) -> None:
209 """Test that CoreController.reload also reruns post-setup logic."""
210
211 class TestController(CoreController):
212 domain = "test"
213
214 def __init__(self, mass: MockMass) -> None:
215 """Initialize test controller."""
216 super().__init__(mass) # type: ignore[arg-type]
217 self.setup_calls = 0
218 self.post_setup_calls = 0
219
220 async def setup(self, config: CoreConfig) -> None:
221 """Record setup invocations."""
222 self.setup_calls += 1
223 self.config = config
224
225 async def post_setup(self) -> None:
226 """Record post-setup invocations."""
227 self.post_setup_calls += 1
228
229 controller = TestController(mock_mass)
230 config = CoreConfig(
231 domain="test",
232 values={
233 CONF_LOG_LEVEL: ConfigEntry(
234 key=CONF_LOG_LEVEL,
235 type=ConfigEntryType.STRING,
236 label="Log level",
237 default_value="INFO",
238 value="INFO",
239 )
240 },
241 )
242
243 await controller.reload(config)
244
245 assert controller.setup_calls == 1
246 assert controller.post_setup_calls == 1
247
248
249def test_config_entry_default_requires_reload_is_false() -> None:
250 """
251 Test that ConfigEntry defaults requires_reload to False.
252
253 This documents the expected default behavior from the models package.
254 Config entries must explicitly set requires_reload=True if they need it.
255 """
256 entry = ConfigEntry(
257 key="test",
258 type=ConfigEntryType.STRING,
259 label="Test Entry",
260 default_value="default",
261 )
262
263 assert entry.requires_reload is False, (
264 "ConfigEntry should default requires_reload to False. "
265 "Entries that need reload must explicitly set requires_reload=True."
266 )
267