/
/
/
1"""Tests for the in-place config copies staying in sync with raw config writes."""
2
3from __future__ import annotations
4
5from types import SimpleNamespace
6from typing import TYPE_CHECKING, cast
7from unittest.mock import AsyncMock, MagicMock, patch
8
9from music_assistant_models.config_entries import (
10 ConfigEntry,
11 ConfigValueType,
12 CoreConfig,
13 PlayerConfig,
14 ProviderConfig,
15)
16from music_assistant_models.enums import ConfigEntryType, PlayerType, ProviderType
17
18from music_assistant.constants import (
19 CONF_ENTRY_LOG_LEVEL,
20 CONF_PROVIDERS,
21 CONF_VOLUME_NORMALIZATION_TARGET,
22)
23from music_assistant.models.provider import Provider
24
25if TYPE_CHECKING:
26 from music_assistant.mass import MusicAssistant
27
28CORE_DOMAIN = "faketestcore"
29PLAYER_ID = "test_copy_player"
30PROVIDER_INSTANCE = "test_copy_provider"
31
32
33def _entry(key: str, entry_type: ConfigEntryType, default: ConfigValueType) -> ConfigEntry:
34 return ConfigEntry(key=key, type=entry_type, default_value=default, required=False)
35
36
37async def test_core_controllers_hold_active_config(mass: MusicAssistant) -> None:
38 """At startup every core controller holds its active (parsed) config."""
39 assert mass.streams.config.get_value(CONF_VOLUME_NORMALIZATION_TARGET) == -14
40 assert mass.webserver.config.domain == "webserver"
41 assert mass.player_queues.config.domain == "player_queues"
42
43
44async def test_set_raw_core_config_value_updates_controller_copy(
45 mass_minimal: MusicAssistant,
46) -> None:
47 """A raw core config write is reflected in the controller's in-place config copy."""
48 controller = SimpleNamespace(
49 config=cast(
50 "CoreConfig",
51 CoreConfig.parse(
52 [_entry("magic_number", ConfigEntryType.INTEGER, 42)], {"domain": CORE_DOMAIN}
53 ),
54 )
55 )
56 setattr(mass_minimal, CORE_DOMAIN, controller)
57 mass_minimal.config.set_raw_core_config_value(CORE_DOMAIN, "magic_number", 5)
58 assert controller.config.get_value("magic_number") == 5
59 assert mass_minimal.config.get_raw_core_config_value(CORE_DOMAIN, "magic_number") == 5
60
61
62async def test_set_raw_core_config_value_without_controller_copy(
63 mass_minimal: MusicAssistant,
64) -> None:
65 """Raw core config writes for a module without an (initialized) controller still work."""
66 mass_minimal.config.set_raw_core_config_value("nonexistent_module", "some_key", 1)
67 setattr(mass_minimal, CORE_DOMAIN, SimpleNamespace()) # controller without config (pre-setup)
68 mass_minimal.config.set_raw_core_config_value(CORE_DOMAIN, "some_key", 2)
69 assert mass_minimal.config.get_raw_core_config_value(CORE_DOMAIN, "some_key") == 2
70
71
72async def test_set_raw_player_config_value_updates_player_copy(
73 mass_minimal: MusicAssistant,
74) -> None:
75 """A raw player config write is reflected in the player's in-place config copy."""
76 mass_minimal.config.create_default_player_config(PLAYER_ID, "test_instance", PlayerType.PLAYER)
77 player = MagicMock()
78 player.config = cast(
79 "PlayerConfig",
80 PlayerConfig.parse(
81 [_entry("fancy_option", ConfigEntryType.STRING, "foo")],
82 {"player_id": PLAYER_ID, "provider": "test_instance"},
83 ),
84 )
85 mass_minimal.players = MagicMock()
86 mass_minimal.players.get_player.return_value = player
87 mass_minimal.config.set_raw_player_config_value(PLAYER_ID, "fancy_option", "bar")
88 assert player.config.get_value("fancy_option") == "bar"
89 # a write for a key without config entry only lands in storage
90 mass_minimal.config.set_raw_player_config_value(PLAYER_ID, "unknown_key", "baz")
91 assert mass_minimal.config.get_raw_player_config_value(PLAYER_ID, "unknown_key") == "baz"
92
93
94async def test_set_raw_provider_config_value_updates_provider_copy(
95 mass_minimal: MusicAssistant,
96) -> None:
97 """A raw provider config write is reflected in the provider's in-place config copy."""
98 provider = MagicMock()
99 provider.config = cast(
100 "ProviderConfig",
101 ProviderConfig.parse(
102 [
103 _entry("api_token", ConfigEntryType.STRING, None),
104 _entry("api_secret", ConfigEntryType.SECURE_STRING, None),
105 ],
106 {
107 "type": ProviderType.MUSIC,
108 "domain": "test",
109 "instance_id": PROVIDER_INSTANCE,
110 },
111 ),
112 )
113 mass_minimal.get_provider = MagicMock(return_value=provider) # type: ignore[method-assign]
114 mass_minimal.config.set(
115 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}", {"instance_id": PROVIDER_INSTANCE}
116 )
117 mass_minimal.config.set_raw_provider_config_value(PROVIDER_INSTANCE, "api_token", "abc")
118 assert provider.config.get_value("api_token") == "abc"
119 # encrypted writes store the encrypted value; get_value transparently decrypts
120 mass_minimal.config.set_raw_provider_config_value(
121 PROVIDER_INSTANCE, "api_secret", "s3cret", encrypted=True
122 )
123 assert provider.config.get_value("api_secret") == "s3cret"
124
125
126async def test_set_raw_provider_config_value_syncs_unavailable_provider(
127 mass_minimal: MusicAssistant,
128) -> None:
129 """A raw write syncs the in-place copy even when the provider is currently unavailable."""
130 provider = MagicMock()
131 provider.config = cast(
132 "ProviderConfig",
133 ProviderConfig.parse(
134 [_entry("api_token", ConfigEntryType.STRING, None)],
135 {
136 "type": ProviderType.MUSIC,
137 "domain": "test",
138 "instance_id": PROVIDER_INSTANCE,
139 },
140 ),
141 )
142
143 # mimic get_provider: an unavailable instance is only returned with return_unavailable=True
144 def _get_provider(
145 _instance: str, return_unavailable: bool = False, **_kwargs: object
146 ) -> object | None:
147 return provider if return_unavailable else None
148
149 mass_minimal.get_provider = MagicMock(side_effect=_get_provider) # type: ignore[method-assign]
150 mass_minimal.config.set(
151 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}", {"instance_id": PROVIDER_INSTANCE}
152 )
153 mass_minimal.config.set_raw_provider_config_value(PROVIDER_INSTANCE, "api_token", "abc")
154 assert provider.config.get_value("api_token") == "abc"
155
156
157async def test_provider_get_config_value_prefers_persisted_value(
158 mass_minimal: MusicAssistant,
159) -> None:
160 """A provider reads a persisted value instead of its stale config snapshot."""
161 entries = _provider_config_entries()
162 mass_minimal.config.set(
163 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}",
164 _raw_provider_conf(PROVIDER_INSTANCE, "persisted"),
165 )
166 provider = _create_provider(mass_minimal, entries, api_token="stale")
167
168 assert provider.get_config_value("api_token") == "persisted"
169
170
171async def test_provider_get_config_value_decrypts_persisted_secure_value(
172 mass_minimal: MusicAssistant,
173) -> None:
174 """A provider transparently decrypts a persisted secure config value."""
175 entries = [
176 CONF_ENTRY_LOG_LEVEL,
177 _entry("api_secret", ConfigEntryType.SECURE_STRING, None),
178 ]
179 mass_minimal.config.set(
180 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}",
181 _raw_provider_conf(PROVIDER_INSTANCE),
182 )
183 mass_minimal.config.set_raw_provider_config_value(
184 PROVIDER_INSTANCE, "api_secret", "persisted-secret", encrypted=True
185 )
186 provider = _create_provider(mass_minimal, entries, api_secret="stale-secret")
187
188 assert provider.get_config_value("api_secret") == "persisted-secret"
189
190
191async def test_provider_get_config_value_falls_back_to_active_config(
192 mass_minimal: MusicAssistant,
193) -> None:
194 """A provider uses active values and defaults when no persisted value exists."""
195 entries = [
196 CONF_ENTRY_LOG_LEVEL,
197 _entry("active_value", ConfigEntryType.STRING, None),
198 _entry("active_default", ConfigEntryType.STRING, "entry-default"),
199 ]
200 mass_minimal.config.set(
201 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}",
202 _raw_provider_conf(PROVIDER_INSTANCE, unknown="store-only"),
203 )
204 provider = _create_provider(mass_minimal, entries, active_value="snapshot-value")
205
206 assert provider.get_config_value("active_value") == "snapshot-value"
207 assert provider.get_config_value("active_default") == "entry-default"
208 assert provider.get_config_value("unknown", "explicit-default") == "explicit-default"
209
210
211async def test_provider_update_config_value_syncs_initializing_snapshot(
212 mass_minimal: MusicAssistant,
213) -> None:
214 """A provider-originated write updates its snapshot before registration completes."""
215 entries = _provider_config_entries()
216 mass_minimal.config.set(
217 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}",
218 _raw_provider_conf(PROVIDER_INSTANCE, "old"),
219 )
220 provider = _create_provider(mass_minimal, entries, api_token="old")
221 get_provider = MagicMock(return_value=None)
222 mass_minimal.get_provider = get_provider # type: ignore[method-assign]
223
224 provider._update_config_value("api_token", "new")
225
226 assert (
227 mass_minimal.config.get_raw_provider_config_value(PROVIDER_INSTANCE, "api_token") == "new"
228 )
229 assert provider.config.values["api_token"].value == "new"
230 get_provider.assert_called_once_with(PROVIDER_INSTANCE, return_unavailable=True)
231
232
233async def test_provider_encrypted_update_keeps_raw_snapshot_value(
234 mass_minimal: MusicAssistant,
235) -> None:
236 """An encrypted provider write stores raw ciphertext while reads return plaintext."""
237 entries = [
238 CONF_ENTRY_LOG_LEVEL,
239 _entry("api_secret", ConfigEntryType.SECURE_STRING, None),
240 ]
241 mass_minimal.config.set(
242 f"{CONF_PROVIDERS}/{PROVIDER_INSTANCE}",
243 _raw_provider_conf(PROVIDER_INSTANCE),
244 )
245 provider = _create_provider(mass_minimal, entries)
246 get_provider = MagicMock(return_value=None)
247 mass_minimal.get_provider = get_provider # type: ignore[method-assign]
248
249 provider._update_config_value("api_secret", "new-secret", encrypted=True)
250
251 raw_value = mass_minimal.config.get_raw_provider_config_value(PROVIDER_INSTANCE, "api_secret")
252 assert isinstance(raw_value, str)
253 assert raw_value != "new-secret"
254 assert provider.config.values["api_secret"].value == raw_value
255 assert provider.get_config_value("api_secret") == "new-secret"
256
257
258async def test_save_provider_config_syncs_loaded_available_provider(
259 mass_minimal: MusicAssistant,
260) -> None:
261 """
262 The bulk config-save path keeps a loaded, available provider's copy in sync too.
263
264 Regression test for the store<->snapshot invariant: whichever write path persists a
265 provider config change, a loaded provider's in-place config copy must reflect it.
266 """
267 instance = PROVIDER_INSTANCE
268 entries = _provider_config_entries()
269 mass_minimal.config.set(f"{CONF_PROVIDERS}/{instance}", _raw_provider_conf(instance, "old"))
270 manifest = MagicMock(domain="test", type=ProviderType.MUSIC)
271 provider = Provider(
272 mass_minimal,
273 manifest,
274 cast("ProviderConfig", ProviderConfig.parse(entries, _raw_provider_conf(instance, "old"))),
275 )
276 provider.available = True
277 mass_minimal.get_provider = MagicMock(return_value=provider) # type: ignore[method-assign]
278 mass_minimal.call_later = MagicMock() # type: ignore[method-assign] # don't schedule a real reload
279
280 async def _get_provider_config(_instance_id: str) -> ProviderConfig:
281 raw_conf = mass_minimal.config.get(f"{CONF_PROVIDERS}/{instance}")
282 return cast("ProviderConfig", ProviderConfig.parse(entries, raw_conf))
283
284 with patch.object(mass_minimal.config, "get_provider_config", side_effect=_get_provider_config):
285 await mass_minimal.config.save_provider_config(
286 "test", {"api_token": "new"}, instance_id=instance
287 )
288
289 assert provider.config.get_value("api_token") == "new"
290 assert mass_minimal.config.get_raw_provider_config_value(instance, "api_token") == "new"
291
292
293async def test_save_provider_config_reloads_loaded_unavailable_provider(
294 mass_minimal: MusicAssistant,
295) -> None:
296 """
297 The bulk config-save path reloads (rather than patches) an unavailable loaded provider.
298
299 An unavailable provider instance never receives a direct config-copy sync; instead the
300 save must trigger a full reload with the freshly persisted values, so the replacement
301 provider instance can never end up with a stale copy.
302 """
303 instance = PROVIDER_INSTANCE
304 entries = _provider_config_entries()
305 mass_minimal.config.set(f"{CONF_PROVIDERS}/{instance}", _raw_provider_conf(instance, "old"))
306 manifest = MagicMock(domain="test", type=ProviderType.MUSIC)
307 provider = Provider(
308 mass_minimal,
309 manifest,
310 cast("ProviderConfig", ProviderConfig.parse(entries, _raw_provider_conf(instance, "old"))),
311 )
312 provider.available = False # loaded, but currently unavailable (e.g. failed token refresh)
313
314 def _get_provider(
315 _instance: str, return_unavailable: bool = False, **_kwargs: object
316 ) -> object | None:
317 return provider if return_unavailable else None
318
319 mass_minimal.get_provider = MagicMock(side_effect=_get_provider) # type: ignore[method-assign]
320
321 async def _get_provider_config(_instance_id: str) -> ProviderConfig:
322 raw_conf = mass_minimal.config.get(f"{CONF_PROVIDERS}/{instance}")
323 return cast("ProviderConfig", ProviderConfig.parse(entries, raw_conf))
324
325 with (
326 patch.object(mass_minimal.config, "get_provider_config", side_effect=_get_provider_config),
327 patch.object(mass_minimal, "load_provider_config", AsyncMock()) as mock_load,
328 ):
329 await mass_minimal.config.save_provider_config(
330 "test", {"api_token": "new"}, instance_id=instance
331 )
332
333 # the value is persisted before the reload is triggered...
334 assert mass_minimal.config.get_raw_provider_config_value(instance, "api_token") == "new"
335 # ...and the reload receives that same, up-to-date value, so the replacement
336 # provider instance created by the reload can never end up with a stale copy
337 mock_load.assert_awaited_once()
338 assert mock_load.await_args is not None
339 reloaded_config = mock_load.await_args.args[0]
340 assert reloaded_config.get_value("api_token") == "new"
341 # the stale (unavailable) instance itself is never patched in place
342 assert provider.config.get_value("api_token") == "old"
343
344
345def _provider_config_entries() -> list[ConfigEntry]:
346 return [CONF_ENTRY_LOG_LEVEL, _entry("api_token", ConfigEntryType.STRING, "old")]
347
348
349def _create_provider(
350 mass: MusicAssistant,
351 entries: list[ConfigEntry],
352 **values: ConfigValueType,
353) -> Provider:
354 """Create a provider with an active config snapshot."""
355 manifest = MagicMock(domain="test", type=ProviderType.MUSIC)
356 config = cast(
357 "ProviderConfig",
358 ProviderConfig.parse(entries, _raw_provider_conf(PROVIDER_INSTANCE, **values)),
359 )
360 return Provider(mass, manifest, config)
361
362
363def _raw_provider_conf(
364 instance_id: str, api_token: ConfigValueType = None, **values: ConfigValueType
365) -> dict[str, object]:
366 if api_token is not None:
367 values["api_token"] = api_token
368 return {
369 "type": ProviderType.MUSIC.value,
370 "domain": "test",
371 "instance_id": instance_id,
372 "enabled": True,
373 "values": values,
374 }
375