/
/
1"""Tests for (settings.json) config migrations."""
2
3from __future__ import annotations
4
5from copy import deepcopy
6from types import SimpleNamespace
7from typing import TYPE_CHECKING, Any
8from uuid import uuid4
9
10from music_assistant_models.errors import InvalidDataError
11
12from music_assistant.constants import (
13 CONF_NFS_SUBFOLDER_MIGRATED,
14 CONF_SERVER_ID,
15 ENCRYPT_SUFFIX,
16)
17from music_assistant.controllers.config.controller import ConfigController
18from music_assistant.controllers.config.migrations import (
19 PROVIDER_SETUP_FLOW_KEYS,
20 _migrate_airplay_apple_power_control,
21 _migrate_airplay_receiver_ghost_players,
22 _migrate_bluesound_http_profile,
23 _migrate_bose_soundtouch_presets,
24 _migrate_orphaned_disabled_protocol_configs,
25 _migrate_output_limiter,
26 _migrate_player_icons,
27 _migrate_player_setup_data,
28 _migrate_unrenamed_player_names,
29 migrate_hass_engine_selection,
30 migrate_nfs_subfolder_into_export_path,
31 migrate_provider_setup_data,
32)
33
34if TYPE_CHECKING:
35 from pathlib import Path
36
37 import pytest
38
39
40def _fake_encrypt(value: str) -> str:
41 """Mirror ConfigController.encrypt_string: prefix once, idempotent for encrypted values."""
42 return value if value.startswith(ENCRYPT_SUFFIX) else ENCRYPT_SUFFIX + value
43
44
45def _fake_decrypt(value: str) -> str:
46 """Mirror ConfigController.decrypt_string: strip the prefix, pass plain values through."""
47 return value.removeprefix(ENCRYPT_SUFFIX)
48
49
50def test_migrate_output_limiter_drops_stored_values() -> None:
51 """The removed output limiter setting is dropped, other player values are kept."""
52 data: dict[str, Any] = {
53 "players": {
54 "p1": {"player_id": "p1", "values": {"output_limiter": False, "flow_mode": True}},
55 "p2": {"player_id": "p2", "values": {"output_limiter": True}},
56 "p3": {"player_id": "p3", "values": {}},
57 }
58 }
59 assert _migrate_output_limiter(data) is True
60 assert data["players"]["p1"]["values"] == {"flow_mode": True}
61 assert data["players"]["p2"]["values"] == {}
62 assert data["players"]["p3"]["values"] == {}
63
64
65def test_migrate_output_limiter_noop_when_absent() -> None:
66 """Migration reports no change when no player stored the setting."""
67 data: dict[str, Any] = {"players": {"p1": {"player_id": "p1", "values": {"flow_mode": True}}}}
68 assert _migrate_output_limiter(data) is False
69
70
71def test_migrate_bluesound_http_profile_drops_stored_pick() -> None:
72 """A Bluesound player left on another HTTP profile is returned to the required one."""
73 data: dict[str, Any] = {
74 "players": {
75 "b1": {
76 "provider": "bluesound",
77 "values": {"http_profile": "chunked", "flow_mode": True},
78 },
79 "b2": {"provider": "bluesound--abc1", "values": {"http_profile": "no_content_length"}},
80 "c1": {"provider": "chromecast", "values": {"http_profile": "chunked"}},
81 }
82 }
83 assert _migrate_bluesound_http_profile(data) is True
84 assert data["players"]["b1"]["values"] == {"flow_mode": True}
85 assert data["players"]["b2"]["values"] == {}
86 # other providers still offer the setting, so their pick is left alone
87 assert data["players"]["c1"]["values"] == {"http_profile": "chunked"}
88
89
90def test_migrate_bluesound_http_profile_noop_when_absent() -> None:
91 """Migration reports no change when no Bluesound player stored a profile."""
92 data: dict[str, Any] = {"players": {"b1": {"provider": "bluesound", "values": {}}}}
93 assert _migrate_bluesound_http_profile(data) is False
94
95
96def test_migrate_bluesound_http_profile_noop_when_already_required() -> None:
97 """A player already on the required profile needs no rewrite of the settings."""
98 data: dict[str, Any] = {
99 "players": {
100 "b1": {"provider": "bluesound", "values": {"http_profile": "forced_content_length"}}
101 }
102 }
103 assert _migrate_bluesound_http_profile(data) is False
104
105
106def _airplay_receiver_ghost_data() -> dict[str, Any]:
107 """Build a config store with AirPlay Receiver ghost players next to real players."""
108 return {
109 "providers": {
110 "airplay_receiver--abc1": {
111 "domain": "airplay_receiver",
112 "instance_id": "airplay_receiver--abc1",
113 "values": {"airplay_name": "Garage [AirPlay]"},
114 },
115 "airplay": {"domain": "airplay", "instance_id": "airplay", "values": {}},
116 },
117 "players": {
118 # ghosts of the receiver: airplay player + sendspin bridge + universal wrapper
119 "ap41cf0e23916f": {
120 "player_id": "ap41cf0e23916f",
121 "provider": "airplay",
122 "default_name": "Garage [AirPlay]",
123 "values": {},
124 },
125 "spb_41cf0e23916f": {
126 "player_id": "spb_41cf0e23916f",
127 "provider": "sendspin",
128 "default_name": "Garage [AirPlay] (AirPlay)",
129 "values": {},
130 },
131 "up41cf0e23916f": {
132 "player_id": "up41cf0e23916f",
133 "provider": "universal_player",
134 "default_name": "Garage [AirPlay]",
135 "values": {"linked_protocol_ids": ["ap41cf0e23916f", "spb_41cf0e23916f"]},
136 },
137 # a real airplay player with another name must be kept
138 "apaabbccddeeff": {
139 "player_id": "apaabbccddeeff",
140 "provider": "airplay",
141 "default_name": "Kitchen",
142 "values": {},
143 },
144 # a universal player with a matching name wrapping a native (non-ghost)
145 # protocol player must be kept
146 "up10b41dc887f8": {
147 "player_id": "up10b41dc887f8",
148 "provider": "universal_player",
149 "default_name": "Garage [AirPlay]",
150 "values": {"linked_protocol_ids": ["10:B4:1D:C8:87:F8", "spb_10b41dc887f8"]},
151 },
152 # a group referencing a ghost keeps its other members
153 "syncgroup1": {
154 "player_id": "syncgroup1",
155 "provider": "sync_group",
156 "default_name": "All Speakers",
157 "values": {"group_members": ["up41cf0e23916f", "apaabbccddeeff"]},
158 },
159 },
160 "player_queues": {"up41cf0e23916f": {"queue_id": "up41cf0e23916f"}},
161 "player_dsp": {"up41cf0e23916f": {"enabled": True}},
162 }
163
164
165def test_migrate_airplay_receiver_ghosts_removes_matching_players() -> None:
166 """Ghost ap/spb/up players of an own receiver are removed with their leftover state."""
167 data = _airplay_receiver_ghost_data()
168
169 assert _migrate_airplay_receiver_ghost_players(data) is True
170
171 players = data["players"]
172 assert "ap41cf0e23916f" not in players
173 assert "spb_41cf0e23916f" not in players
174 assert "up41cf0e23916f" not in players
175 # real players survive, including the same-name wrapper of a native player
176 assert "apaabbccddeeff" in players
177 assert "up10b41dc887f8" in players
178 # ghost references are stripped from group membership and per-player state trees
179 assert players["syncgroup1"]["values"]["group_members"] == ["apaabbccddeeff"]
180 assert data["player_queues"] == {}
181 assert data["player_dsp"] == {}
182
183
184def test_migrate_airplay_receiver_ghosts_uses_default_name() -> None:
185 """A receiver without an explicit airplay_name matches ghosts of the default name."""
186 data: dict[str, Any] = {
187 "providers": {
188 "airplay_receiver--abc1": {
189 "domain": "airplay_receiver",
190 "instance_id": "airplay_receiver--abc1",
191 "values": {},
192 },
193 },
194 "players": {
195 "apdb1ff0aae80e": {
196 "player_id": "apdb1ff0aae80e",
197 "provider": "airplay",
198 "default_name": "Music Assistant",
199 "values": {},
200 },
201 },
202 }
203
204 assert _migrate_airplay_receiver_ghost_players(data) is True
205 assert data["players"] == {}
206
207
208def test_migrate_airplay_receiver_ghosts_ignores_setup_flow_instances() -> None:
209 """Setup-flow receivers are skipped because they cannot have produced legacy ghosts."""
210 data: dict[str, Any] = {
211 "providers": {
212 "airplay_receiver--abc1": {
213 "domain": "airplay_receiver",
214 "instance_id": "airplay_receiver--abc1",
215 "values": {},
216 "setup_data": {"airplay_name": ENCRYPT_SUFFIX + "Garage [AirPlay]"},
217 },
218 },
219 "players": {
220 "apdb1ff0aae80e": {
221 "player_id": "apdb1ff0aae80e",
222 "provider": "airplay",
223 "default_name": "Music Assistant",
224 "values": {},
225 },
226 },
227 }
228
229 assert _migrate_airplay_receiver_ghost_players(data) is False
230 assert "apdb1ff0aae80e" in data["players"]
231
232
233def test_migrate_airplay_receiver_ghosts_keeps_same_name_wrapper_without_ghost_links() -> None:
234 """A wrapper sharing the receiver name is kept unless it links only ghost endpoints."""
235 data: dict[str, Any] = {
236 "providers": {
237 "airplay_receiver--abc1": {
238 "domain": "airplay_receiver",
239 "instance_id": "airplay_receiver--abc1",
240 "values": {"airplay_name": "Garage [AirPlay]"},
241 },
242 },
243 "players": {
244 # empty linked list: must NOT be deleted (all([]) is True)
245 "upemptylinks": {
246 "player_id": "upemptylinks",
247 "provider": "universal_player",
248 "default_name": "Garage [AirPlay]",
249 "values": {"linked_protocol_ids": []},
250 },
251 # no linked_protocol_ids at all: must NOT be deleted
252 "upnolinks": {
253 "player_id": "upnolinks",
254 "provider": "universal_player",
255 "default_name": "Garage [AirPlay]",
256 "values": {},
257 },
258 # links a native (non-ghost) protocol player: must NOT be deleted
259 "upnative": {
260 "player_id": "upnative",
261 "provider": "universal_player",
262 "default_name": "Garage [AirPlay]",
263 "values": {"linked_protocol_ids": ["spb_10b41dc887f8"]},
264 },
265 },
266 }
267
268 assert _migrate_airplay_receiver_ghost_players(data) is False
269 assert set(data["players"]) == {"upemptylinks", "upnolinks", "upnative"}
270
271
272def test_migrate_airplay_receiver_ghosts_ignores_disabled_receiver() -> None:
273 """A disabled receiver's name is not used, so same-named players are kept."""
274 data: dict[str, Any] = {
275 "providers": {
276 "airplay_receiver--abc1": {
277 "domain": "airplay_receiver",
278 "instance_id": "airplay_receiver--abc1",
279 "enabled": False,
280 "values": {"airplay_name": "Garage [AirPlay]"},
281 },
282 },
283 "players": {
284 "ap41cf0e23916f": {
285 "player_id": "ap41cf0e23916f",
286 "provider": "airplay",
287 "default_name": "Garage [AirPlay]",
288 "values": {},
289 },
290 },
291 }
292
293 assert _migrate_airplay_receiver_ghost_players(data) is False
294 assert "ap41cf0e23916f" in data["players"]
295
296
297def test_migrate_airplay_receiver_ghosts_noop_without_receivers() -> None:
298 """Without configured receiver instances no player config is touched."""
299 data: dict[str, Any] = {
300 "providers": {"airplay": {"domain": "airplay", "instance_id": "airplay", "values": {}}},
301 "players": {
302 "apdb1ff0aae80e": {
303 "player_id": "apdb1ff0aae80e",
304 "provider": "airplay",
305 "default_name": "Music Assistant",
306 "values": {},
307 },
308 },
309 }
310
311 assert _migrate_airplay_receiver_ghost_players(data) is False
312 assert "apdb1ff0aae80e" in data["players"]
313
314
315def test_migrate_airplay_apple_power_control_flips_stale_default() -> None:
316 """Paired Apple TVs stuck on the old 'none' default get native power control."""
317 data: dict[str, Any] = {
318 "players": {
319 "ap_paired_stale": {
320 "provider": "airplay--x",
321 "values": {"companion_credentials": "enc", "power_control": "none"},
322 },
323 "ap_unpaired": {"provider": "airplay--x", "values": {"power_control": "none"}},
324 "ap_already_native": {
325 "provider": "airplay--x",
326 "values": {"companion_credentials": "enc", "power_control": "native"},
327 },
328 "cast_x": {
329 "provider": "chromecast",
330 "values": {"companion_credentials": "enc", "power_control": "none"},
331 },
332 }
333 }
334 assert _migrate_airplay_apple_power_control(data) is True
335 values = data["players"]
336 # only the paired Apple device on the stale default is changed
337 assert values["ap_paired_stale"]["values"]["power_control"] == "native"
338 assert values["ap_unpaired"]["values"]["power_control"] == "none"
339 assert values["ap_already_native"]["values"]["power_control"] == "native"
340 assert values["cast_x"]["values"]["power_control"] == "none"
341 # idempotent: nothing left to migrate on a second pass
342 assert _migrate_airplay_apple_power_control(data) is False
343
344
345def test_migrate_provider_setup_data_moves_and_encrypts(monkeypatch: pytest.MonkeyPatch) -> None:
346 """Owned string keys move to setup_data encrypted; non-strings move raw; options stay."""
347 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("username", "password", "port"))
348 data: dict[str, Any] = {
349 "providers": {
350 "demo": {
351 "domain": "demo",
352 "values": {
353 "username": "bob",
354 "password": "sekret",
355 "port": 8096,
356 "quality": "high",
357 },
358 }
359 }
360 }
361 assert migrate_provider_setup_data(data, _fake_encrypt) is True
362 cfg = data["providers"]["demo"]
363 # the (non-owned) option key stays untouched in values
364 assert cfg["values"] == {"quality": "high"}
365 # owned string values are encrypted at rest, non-string values move as-is
366 assert cfg["setup_data"]["username"] == ENCRYPT_SUFFIX + "bob"
367 assert cfg["setup_data"]["password"] == ENCRYPT_SUFFIX + "sekret"
368 assert cfg["setup_data"]["port"] == 8096
369
370
371def test_migrate_receiver_and_connect_setup_values() -> None:
372 """New setup-flow fields move to setup_data without losing typed values."""
373 data: dict[str, Any] = {
374 "providers": {
375 "airplay_receiver--1": {
376 "domain": "airplay_receiver",
377 "values": {
378 "mass_player_id": "kitchen",
379 "airplay_name": "Kitchen AirPlay",
380 "log_level": "INFO",
381 },
382 },
383 "ariacast_receiver": {
384 "domain": "ariacast_receiver",
385 "values": {"mass_player_id": "office"},
386 },
387 "spotify_connect--1": {
388 "domain": "spotify_connect",
389 "values": {
390 "mass_player_id": "living-room",
391 "publish_name": "Living Room Spotify",
392 },
393 },
394 "vban_receiver--1": {
395 "domain": "vban_receiver",
396 "values": {
397 "bind_ip": "192.0.2.2",
398 "bind_port": 6981,
399 "sender_host": "192.0.2.10",
400 "vban_stream_name": "Studio",
401 "audio_format": "S16LE",
402 "sample_rate": 48000,
403 "audio_channels": 2,
404 "vban_queue_size": 100,
405 },
406 },
407 "yandex_ynison--1": {
408 "domain": "yandex_ynison",
409 "values": {
410 "mass_player_id": "office",
411 "publish_name": "Office Yandex",
412 "allow_player_switch": False,
413 },
414 },
415 }
416 }
417
418 assert migrate_provider_setup_data(data, _fake_encrypt) is True
419
420 providers = data["providers"]
421 assert providers["airplay_receiver--1"]["values"] == {"log_level": "INFO"}
422 assert providers["airplay_receiver--1"]["setup_data"] == {
423 "mass_player_id": ENCRYPT_SUFFIX + "kitchen",
424 "airplay_name": ENCRYPT_SUFFIX + "Kitchen AirPlay",
425 }
426 assert providers["ariacast_receiver"]["values"] == {}
427 assert providers["ariacast_receiver"]["setup_data"] == {
428 "mass_player_id": ENCRYPT_SUFFIX + "office"
429 }
430 assert providers["spotify_connect--1"]["values"] == {}
431 assert providers["spotify_connect--1"]["setup_data"] == {
432 "mass_player_id": ENCRYPT_SUFFIX + "living-room",
433 "publish_name": ENCRYPT_SUFFIX + "Living Room Spotify",
434 }
435 assert providers["vban_receiver--1"]["values"] == {"vban_queue_size": 100}
436 assert providers["vban_receiver--1"]["setup_data"] == {
437 "bind_ip": ENCRYPT_SUFFIX + "192.0.2.2",
438 "bind_port": 6981,
439 "sender_host": ENCRYPT_SUFFIX + "192.0.2.10",
440 "vban_stream_name": ENCRYPT_SUFFIX + "Studio",
441 "audio_format": ENCRYPT_SUFFIX + "S16LE",
442 "sample_rate": 48000,
443 "audio_channels": 2,
444 }
445 assert providers["yandex_ynison--1"]["values"] == {"allow_player_switch": False}
446 assert providers["yandex_ynison--1"]["setup_data"] == {
447 "mass_player_id": ENCRYPT_SUFFIX + "office",
448 "publish_name": ENCRYPT_SUFFIX + "Office Yandex",
449 }
450
451
452def test_migrate_default_airplay_receiver_name_once() -> None:
453 """The implicit receiver name is persisted so ghost cleanup cannot rerun later."""
454 data: dict[str, Any] = {
455 "providers": {
456 "airplay_receiver--1": {
457 "domain": "airplay_receiver",
458 "values": {"mass_player_id": "__auto__"},
459 }
460 },
461 "players": {
462 "aplegitimate": {
463 "player_id": "aplegitimate",
464 "provider": "airplay",
465 "default_name": "Music Assistant",
466 "values": {},
467 }
468 },
469 }
470
471 assert migrate_provider_setup_data(data, _fake_encrypt) is True
472 assert data["providers"]["airplay_receiver--1"]["setup_data"] == {
473 "mass_player_id": ENCRYPT_SUFFIX + "__auto__",
474 "airplay_name": ENCRYPT_SUFFIX + "Music Assistant",
475 }
476 assert _migrate_airplay_receiver_ghost_players(data) is False
477 assert "aplegitimate" in data["players"]
478 assert migrate_provider_setup_data(data, _fake_encrypt) is False
479
480
481def test_migrate_provider_setup_data_idempotent_and_preserves_existing(
482 monkeypatch: pytest.MonkeyPatch,
483) -> None:
484 """Already-encrypted values move unchanged, existing setup_data wins, second run is a no-op."""
485 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("token", "secret"))
486 data: dict[str, Any] = {
487 "providers": {
488 "demo": {
489 "domain": "demo",
490 "values": {"token": ENCRYPT_SUFFIX + "abc", "secret": "raw"},
491 # a value already collected into setup_data must not be clobbered
492 "setup_data": {"secret": ENCRYPT_SUFFIX + "kept"},
493 }
494 }
495 }
496 assert migrate_provider_setup_data(data, _fake_encrypt) is True
497 cfg = data["providers"]["demo"]
498 # an already-encrypted value is moved without re-encrypting (no double prefix)
499 assert cfg["setup_data"]["token"] == ENCRYPT_SUFFIX + "abc"
500 # the pre-existing setup_data value survives; the stale values copy is dropped
501 assert cfg["setup_data"]["secret"] == ENCRYPT_SUFFIX + "kept"
502 assert cfg["values"] == {}
503 # a second pass finds nothing left to move
504 assert migrate_provider_setup_data(data, _fake_encrypt) is False
505
506
507def test_migrate_provider_setup_data_multi_instance_and_scope(
508 monkeypatch: pytest.MonkeyPatch,
509) -> None:
510 """All instances of a mapped domain migrate; unmapped domains are left untouched."""
511 monkeypatch.setitem(PROVIDER_SETUP_FLOW_KEYS, "demo", ("host",))
512 data: dict[str, Any] = {
513 "providers": {
514 "demo--a": {"domain": "demo", "values": {"host": "h1"}},
515 "demo--b": {"domain": "demo", "values": {"host": "h2", "quality": "x"}},
516 "other": {"domain": "other", "values": {"host": "keep"}},
517 }
518 }
519 assert migrate_provider_setup_data(data, _fake_encrypt) is True
520 prov = data["providers"]
521 assert prov["demo--a"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h1"
522 assert prov["demo--a"]["values"] == {}
523 assert prov["demo--b"]["setup_data"]["host"] == ENCRYPT_SUFFIX + "h2"
524 assert prov["demo--b"]["values"] == {"quality": "x"}
525 # a provider whose domain is not in the map is never touched
526 assert prov["other"]["values"] == {"host": "keep"}
527 assert "setup_data" not in prov["other"]
528
529
530def test_migrate_provider_setup_data_noop() -> None:
531 """Missing or empty provider config store reports no change."""
532 assert migrate_provider_setup_data({}, _fake_encrypt) is False
533 assert migrate_provider_setup_data({"providers": {}}, _fake_encrypt) is False
534
535
536def test_migrate_provider_setup_data_real_domain_opensubsonic() -> None:
537 """A real mapped domain moves its setup keys (incl. the redefined baseURL literal)."""
538 data: dict[str, Any] = {
539 "providers": {
540 "opensubsonic--x": {
541 "domain": "opensubsonic",
542 "values": {
543 "username": "alice",
544 "password": "pw",
545 "baseURL": "https://music.example",
546 "port": 4533,
547 "enable_podcasts": True,
548 },
549 }
550 }
551 }
552 assert migrate_provider_setup_data(data, _fake_encrypt) is True
553 cfg = data["providers"]["opensubsonic--x"]
554 assert cfg["setup_data"]["username"] == ENCRYPT_SUFFIX + "alice"
555 assert cfg["setup_data"]["baseURL"] == ENCRYPT_SUFFIX + "https://music.example"
556 assert cfg["setup_data"]["port"] == 4533
557 # a genuine provider option is not part of the setup-flow key set and stays put
558 assert cfg["values"] == {"enable_podcasts": True}
559
560
561def test_migrate_player_setup_data_moves_credentials() -> None:
562 """Player-owned credential/pairing keys move from values into setup_data."""
563 data: dict[str, Any] = {
564 "players": {
565 "ap1": {
566 "player_id": "ap1",
567 "provider": "airplay",
568 "values": {
569 "airplay_credentials": "ENC_ap2creds",
570 "companion_credentials": "ENC_companion",
571 "ap2password": "ENC_dead",
572 "password": "ENC_devpw",
573 "ignore_volume": True,
574 },
575 },
576 "fk1": {
577 "player_id": "fk1",
578 "provider": "fully_kiosk",
579 "values": {"password": "ENC_fk", "use_ssl": True},
580 },
581 "mpd1": {"player_id": "mpd1", "provider": "mpd", "values": {}},
582 "sonos1": {
583 "player_id": "sonos1",
584 "provider": "sonos",
585 "values": {"password": "keepme"},
586 },
587 }
588 }
589 assert _migrate_player_setup_data(data) is True
590 ap1 = data["players"]["ap1"]
591 # credentials moved to setup_data (already-encrypted, moved as-is)
592 assert ap1["setup_data"] == {
593 "airplay_credentials": "ENC_ap2creds",
594 "companion_credentials": "ENC_companion",
595 }
596 # the RAOP device password (a genuine user option) and other options stay in values;
597 # the vestigial ap2password is dropped entirely
598 assert ap1["values"] == {"password": "ENC_devpw", "ignore_volume": True}
599 # fully_kiosk password moves, unrelated option stays
600 assert data["players"]["fk1"]["setup_data"] == {"password": "ENC_fk"}
601 assert data["players"]["fk1"]["values"] == {"use_ssl": True}
602 # a provider not in the map is untouched
603 assert data["players"]["sonos1"]["values"] == {"password": "keepme"}
604 assert "setup_data" not in data["players"]["sonos1"]
605 # idempotent second run
606 assert _migrate_player_setup_data(data) is False
607
608
609def test_migrate_player_setup_data_preserves_existing_and_drops_null() -> None:
610 """An existing setup_data value is never clobbered and stored nulls are dropped."""
611 data: dict[str, Any] = {
612 "players": {
613 "ap1": {
614 "player_id": "ap1",
615 "provider": "airplay",
616 "setup_data": {"airplay_credentials": "ENC_existing"},
617 "values": {"airplay_credentials": "ENC_stale", "raop_credentials": None},
618 }
619 }
620 }
621 assert _migrate_player_setup_data(data) is True
622 ap1 = data["players"]["ap1"]
623 # the pre-existing setup_data value wins; the null raop value is just dropped
624 assert ap1["setup_data"] == {"airplay_credentials": "ENC_existing"}
625 assert ap1["values"] == {}
626
627
628def test_migrate_player_setup_data_multi_instance_domain() -> None:
629 """Domain matching handles multi-instance provider ids (<domain>--<id>)."""
630 data: dict[str, Any] = {
631 "players": {
632 "ap1": {
633 "player_id": "ap1",
634 "provider": "airplay--2",
635 "values": {"raop_credentials": "ENC_raop"},
636 }
637 }
638 }
639 assert _migrate_player_setup_data(data) is True
640 assert data["players"]["ap1"]["setup_data"] == {"raop_credentials": "ENC_raop"}
641 assert data["players"]["ap1"]["values"] == {}
642
643
644def test_migrate_bose_soundtouch_presets_drops_player_values() -> None:
645 """The per-player preset mappings are removed, other player values are kept."""
646 data: dict[str, Any] = {
647 "players": {
648 "bose_soundtouch_a": {
649 "player_id": "bose_soundtouch_a",
650 "provider": "bose_soundtouch",
651 "values": {
652 "preset_1_media": "library://playlist/12",
653 "preset_1_media_type": "playlist",
654 "preset_1_search": "morning",
655 "preset_1_selected_media": "library://playlist/12",
656 "volume_normalization": True,
657 },
658 },
659 "bose_soundtouch_b": {
660 "player_id": "bose_soundtouch_b",
661 "provider": "bose_soundtouch--2",
662 "values": {"preset_6_media": "library://radio/3"},
663 },
664 }
665 }
666 assert _migrate_bose_soundtouch_presets(data) is True
667 assert data["players"]["bose_soundtouch_a"]["values"] == {"volume_normalization": True}
668 assert data["players"]["bose_soundtouch_b"]["values"] == {}
669
670
671def test_migrate_bose_soundtouch_presets_scoped_to_provider() -> None:
672 """Players of other providers keep any similarly named values."""
673 data: dict[str, Any] = {
674 "players": {
675 "other": {
676 "player_id": "other",
677 "provider": "sonos",
678 "values": {"preset_1_media": "library://playlist/12"},
679 }
680 }
681 }
682 assert _migrate_bose_soundtouch_presets(data) is False
683 assert data["players"]["other"]["values"] == {"preset_1_media": "library://playlist/12"}
684
685
686def test_migrate_bose_soundtouch_presets_noop_when_absent() -> None:
687 """Migration reports no change when no SoundTouch player stored presets."""
688 data: dict[str, Any] = {
689 "players": {
690 "bose_soundtouch_a": {
691 "player_id": "bose_soundtouch_a",
692 "provider": "bose_soundtouch",
693 "values": {"volume_normalization": True},
694 }
695 }
696 }
697 assert _migrate_bose_soundtouch_presets(data) is False
698
699
700def test_migrate_nfs_subfolder_folds_into_export_path() -> None:
701 """The subfolder is appended to the export path and the now-obsolete key is dropped."""
702 data: dict[str, Any] = {
703 "providers": {
704 "filesystem_nfs--1": {
705 "domain": "filesystem_nfs",
706 "setup_data": {
707 "host": ENCRYPT_SUFFIX + "nas.local",
708 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
709 "subfolder": ENCRYPT_SUFFIX + "Music",
710 "nfs_version": ENCRYPT_SUFFIX + "3",
711 },
712 "values": {"sync_tracks": True},
713 }
714 }
715 }
716
717 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
718
719 setup_data = data["providers"]["filesystem_nfs--1"]["setup_data"]
720 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
721 assert "subfolder" not in setup_data
722 assert setup_data["host"] == ENCRYPT_SUFFIX + "nas.local"
723 assert setup_data["nfs_version"] == ENCRYPT_SUFFIX + "3"
724 assert data["providers"]["filesystem_nfs--1"]["values"] == {"sync_tracks": True}
725 # the marker stops a second pass
726 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
727 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
728 assert setup_data["export_path"] == ENCRYPT_SUFFIX + "/mnt/vault/Music"
729
730
731def test_migrate_nfs_subfolder_normalizes_leading_slash_and_nesting() -> None:
732 """A leading slash is tolerated and a nested subfolder keeps its full depth."""
733 data: dict[str, Any] = {
734 "providers": {
735 "rooted": {
736 "domain": "filesystem_nfs",
737 "setup_data": {
738 "export_path": ENCRYPT_SUFFIX + "/volume1",
739 "subfolder": ENCRYPT_SUFFIX + "/music",
740 },
741 },
742 "nested": {
743 "domain": "filesystem_nfs",
744 "setup_data": {
745 "export_path": ENCRYPT_SUFFIX + "/exports/media/",
746 "subfolder": ENCRYPT_SUFFIX + " albums/A-K ",
747 },
748 },
749 }
750 }
751
752 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
753
754 providers = data["providers"]
755 assert providers["rooted"]["setup_data"]["export_path"] == ENCRYPT_SUFFIX + "/volume1/music"
756 assert (
757 providers["nested"]["setup_data"]["export_path"]
758 == ENCRYPT_SUFFIX + "/exports/media/albums/A-K"
759 )
760
761
762def test_migrate_nfs_subfolder_noop_cases() -> None:
763 """Instances without a usable subfolder, and other providers, are left untouched."""
764 data: dict[str, Any] = {
765 "providers": {
766 "empty_subfolder": {
767 "domain": "filesystem_nfs",
768 "setup_data": {
769 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
770 "subfolder": ENCRYPT_SUFFIX + "",
771 },
772 },
773 "no_subfolder_key": {
774 "domain": "filesystem_nfs",
775 "setup_data": {"export_path": ENCRYPT_SUFFIX + "/mnt/vault"},
776 },
777 "no_export_path": {
778 "domain": "filesystem_nfs",
779 "setup_data": {"subfolder": ENCRYPT_SUFFIX + "Music"},
780 },
781 "smb": {
782 "domain": "filesystem_smb",
783 "setup_data": {
784 "share": ENCRYPT_SUFFIX + "media",
785 "subfolder": ENCRYPT_SUFFIX + "Music",
786 },
787 },
788 }
789 }
790 providers_before = deepcopy(data["providers"])
791
792 # only the one-shot marker is written; no provider config is touched
793 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
794 assert data["providers"] == providers_before
795 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
796
797
798def test_migrate_nfs_subfolder_never_folds_a_post_fix_config() -> None:
799 """A subfolder stored after the marker is set is never folded, however often this runs."""
800 data: dict[str, Any] = {
801 CONF_NFS_SUBFOLDER_MIGRATED: True,
802 "providers": {
803 "filesystem_nfs--1": {
804 "domain": "filesystem_nfs",
805 "setup_data": {
806 "export_path": ENCRYPT_SUFFIX + "/mnt/vault",
807 "subfolder": ENCRYPT_SUFFIX + "Music",
808 },
809 }
810 },
811 }
812 before = deepcopy(data)
813
814 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is False
815 assert data == before
816
817
818def test_migrate_nfs_subfolder_handles_plaintext_values() -> None:
819 """A value that was never encrypted at rest folds just the same."""
820 data: dict[str, Any] = {
821 "providers": {
822 "filesystem_nfs--1": {
823 "domain": "filesystem_nfs",
824 "setup_data": {"export_path": "/mnt/vault", "subfolder": "Music"},
825 }
826 }
827 }
828
829 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _fake_decrypt) is True
830 assert (
831 data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
832 == ENCRYPT_SUFFIX + "/mnt/vault/Music"
833 )
834
835
836def test_migrate_nfs_subfolder_skips_an_undecryptable_instance() -> None:
837 """An unreadable value costs that instance its migration, not the server its startup."""
838
839 def _failing_decrypt(_value: str) -> str:
840 raise InvalidDataError("Password decryption failed")
841
842 data: dict[str, Any] = {
843 "providers": {
844 "broken": {
845 "domain": "filesystem_nfs",
846 "setup_data": {
847 "export_path": ENCRYPT_SUFFIX + "unreadable",
848 "subfolder": ENCRYPT_SUFFIX + "unreadable",
849 },
850 }
851 }
852 }
853
854 assert migrate_nfs_subfolder_into_export_path(data, _fake_encrypt, _failing_decrypt) is True
855
856 # left as it was, but the marker is still claimed
857 assert data["providers"]["broken"]["setup_data"] == {
858 "export_path": ENCRYPT_SUFFIX + "unreadable",
859 "subfolder": ENCRYPT_SUFFIX + "unreadable",
860 }
861 assert data[CONF_NFS_SUBFOLDER_MIGRATED] is True
862
863
864def test_migrate_nfs_subfolder_round_trips_through_real_encryption(tmp_path: Path) -> None:
865 """The folded export path is re-encrypted with the live key, so a migrated config loads."""
866 mass = SimpleNamespace(storage_path=str(tmp_path))
867 controller = ConfigController(mass) # type: ignore[arg-type]
868 controller.initialized = True
869 controller.save = lambda **_kwargs: None # type: ignore[method-assign]
870 controller.set(CONF_SERVER_ID, uuid4().hex)
871 controller._init_encryption()
872
873 data: dict[str, Any] = {
874 "providers": {
875 "filesystem_nfs--1": {
876 "domain": "filesystem_nfs",
877 "setup_data": {
878 "export_path": controller.encrypt_string("/mnt/vault"),
879 "subfolder": controller.encrypt_string("Music"),
880 },
881 }
882 }
883 }
884
885 assert (
886 migrate_nfs_subfolder_into_export_path(
887 data, controller.encrypt_string, controller.decrypt_string
888 )
889 is True
890 )
891
892 stored = data["providers"]["filesystem_nfs--1"]["setup_data"]["export_path"]
893 assert stored.startswith(ENCRYPT_SUFFIX)
894 assert controller.decrypt_string(stored) == "/mnt/vault/Music"
895
896
897def _hass_engine_data(instance_id: str = "hass", **hass_values: Any) -> dict[str, Any]:
898 """Build a config store with a hass provider and all three engine consumers."""
899 return {
900 "providers": {
901 instance_id: {
902 "domain": "hass",
903 "instance_id": instance_id,
904 "values": {"verify_ssl": True, **hass_values},
905 },
906 "ai_radio": {"domain": "ai_radio", "instance_id": "ai_radio", "values": {}},
907 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
908 "smart_playlist": {
909 "domain": "smart_playlist",
910 "instance_id": "smart_playlist",
911 "values": {},
912 },
913 }
914 }
915
916
917def test_migrate_hass_engine_selection_fans_out_to_all_consumers() -> None:
918 """Both stored entity ids become engine uids on every consumer, encrypted for ai_radio."""
919 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
920 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
921 prov = data["providers"]
922 assert prov["ai_radio"]["setup_data"] == {
923 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
924 "tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper",
925 }
926 # the plain providers store their pick unencrypted in values
927 assert prov["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
928 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
929 # the dead keys are consumed, the hass config is otherwise untouched
930 assert prov["hass"]["values"] == {"verify_ssl": True}
931 # a second pass finds nothing left to migrate
932 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
933
934
935def test_migrate_hass_engine_selection_uses_own_instance_id() -> None:
936 """The engine uid is built from the hass config's own instance id, not its domain."""
937 data = _hass_engine_data("hass--abc", ai_task_entity="ai_task.google")
938 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
939 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass--abc/ai_task.google"}
940
941
942def test_migrate_hass_engine_selection_migrates_keys_independently() -> None:
943 """A single stored key migrates on its own; the other selection stays unset."""
944 data = _hass_engine_data(tts_entity="tts.piper")
945 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
946 prov = data["providers"]
947 assert prov["ai_radio"]["setup_data"] == {"tts_engine": ENCRYPT_SUFFIX + "hass/tts.piper"}
948 assert prov["music_quiz"]["values"] == {}
949 assert prov["smart_playlist"]["values"] == {}
950 assert prov["hass"]["values"] == {"verify_ssl": True}
951
952
953def test_migrate_hass_engine_selection_without_consumers() -> None:
954 """The dead hass keys are dropped even when no consuming provider is installed."""
955 data: dict[str, Any] = {
956 "providers": {
957 "hass": {
958 "domain": "hass",
959 "instance_id": "hass",
960 "values": {"tts_entity": "tts.piper", "ai_task_entity": "ai_task.google"},
961 },
962 "music_quiz": {"domain": "music_quiz", "instance_id": "music_quiz", "values": {}},
963 }
964 }
965 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
966 assert data["providers"]["hass"]["values"] == {}
967 assert data["providers"]["music_quiz"]["values"] == {"ai_engine": "hass/ai_task.google"}
968 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
969
970
971def test_migrate_hass_engine_selection_preserves_existing_choice() -> None:
972 """A selection the user already made on a consumer is never overwritten."""
973 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
974 prov = data["providers"]
975 prov["music_quiz"]["values"]["ai_engine"] = "openai/gpt"
976 prov["ai_radio"]["setup_data"] = {"tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice"}
977 assert migrate_hass_engine_selection(data, _fake_encrypt) is True
978 assert prov["music_quiz"]["values"] == {"ai_engine": "openai/gpt"}
979 assert prov["ai_radio"]["setup_data"] == {
980 "ai_engine": ENCRYPT_SUFFIX + "hass/ai_task.google",
981 "tts_engine": ENCRYPT_SUFFIX + "elevenlabs/voice",
982 }
983 # the consumer that had no choice yet still gets the migrated one
984 assert prov["smart_playlist"]["values"] == {"ai_engine": "hass/ai_task.google"}
985 assert prov["hass"]["values"] == {"verify_ssl": True}
986
987
988def test_migrate_hass_engine_selection_noop_without_stored_values() -> None:
989 """Nothing stored (or no hass provider at all) reports no change."""
990 assert migrate_hass_engine_selection({}, _fake_encrypt) is False
991 assert migrate_hass_engine_selection({"providers": {}}, _fake_encrypt) is False
992 data = _hass_engine_data()
993 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
994 assert "setup_data" not in data["providers"]["ai_radio"]
995 assert data["providers"]["music_quiz"]["values"] == {}
996
997
998def test_migrate_hass_engine_selection_skips_multiple_hass_configs() -> None:
999 """With several hass configurations there is no correct winner, so nothing is touched."""
1000 data = _hass_engine_data(tts_entity="tts.piper", ai_task_entity="ai_task.google")
1001 data["providers"]["hass--second"] = {
1002 "domain": "hass",
1003 "instance_id": "hass--second",
1004 "values": {"tts_entity": "tts.cloud"},
1005 }
1006 assert migrate_hass_engine_selection(data, _fake_encrypt) is False
1007 prov = data["providers"]
1008 assert prov["hass"]["values"]["tts_entity"] == "tts.piper"
1009 assert prov["hass"]["values"]["ai_task_entity"] == "ai_task.google"
1010 assert prov["music_quiz"]["values"] == {}
1011 assert "setup_data" not in prov["ai_radio"]
1012
1013
1014def test_migrate_player_icons_rewrites_legacy_values() -> None:
1015 """Legacy mdi-* and pre-1.0 picker icon values are rewritten to canonical ids."""
1016 data: dict[str, Any] = {
1017 "players": {
1018 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1019 "p2": {"player_id": "p2", "values": {"icon": "sofa"}},
1020 "p3": {"player_id": "p3", "values": {"icon": "mdi-television-classic"}},
1021 }
1022 }
1023 assert _migrate_player_icons(data) is True
1024 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1025 assert data["players"]["p2"]["values"]["icon"] == "living-room"
1026 assert data["players"]["p3"]["values"]["icon"] == "tv"
1027
1028
1029def test_migrate_player_icons_drops_unmappable_mdi_values() -> None:
1030 """An mdi-* icon with no close equivalent is dropped so the default applies."""
1031 data: dict[str, Any] = {
1032 "players": {
1033 "p1": {"player_id": "p1", "values": {"icon": "mdi-pac-man", "flow_mode": True}},
1034 }
1035 }
1036 assert _migrate_player_icons(data) is True
1037 assert data["players"]["p1"]["values"] == {"flow_mode": True}
1038
1039
1040def test_migrate_player_icons_keeps_canonical_and_unknown_ids() -> None:
1041 """Canonical ids are never touched; unknown non-mdi values are left in place."""
1042 data: dict[str, Any] = {
1043 "players": {
1044 "p1": {"player_id": "p1", "values": {"icon": "sonos"}},
1045 "p2": {"player_id": "p2", "values": {"icon": "kitchen"}},
1046 "p3": {"player_id": "p3", "values": {"icon": "dog"}},
1047 }
1048 }
1049 assert _migrate_player_icons(data) is False
1050 assert data["players"]["p1"]["values"]["icon"] == "sonos"
1051 assert data["players"]["p2"]["values"]["icon"] == "kitchen"
1052 assert data["players"]["p3"]["values"]["icon"] == "dog"
1053
1054
1055def test_migrate_player_icons_noop_when_absent() -> None:
1056 """Migration reports no change for players without a stored icon."""
1057 data: dict[str, Any] = {
1058 "players": {
1059 "p1": {"player_id": "p1", "values": {"flow_mode": True}},
1060 "p2": {"player_id": "p2"},
1061 }
1062 }
1063 assert _migrate_player_icons(data) is False
1064
1065
1066def test_migrate_player_icons_idempotent() -> None:
1067 """A second run over already-migrated data reports no change."""
1068 data: dict[str, Any] = {
1069 "players": {
1070 "p1": {"player_id": "p1", "values": {"icon": "mdi-speaker-multiple"}},
1071 "p2": {"player_id": "p2", "values": {"icon": "mdi-pac-man"}},
1072 }
1073 }
1074 assert _migrate_player_icons(data) is True
1075 assert _migrate_player_icons(data) is False
1076 assert data["players"]["p1"]["values"]["icon"] == "speakers"
1077 assert "icon" not in data["players"]["p2"]["values"]
1078
1079
1080def test_migrate_player_icons_tolerates_malformed_data() -> None:
1081 """Non-dict player configs/values and non-string icon values are skipped."""
1082 data: dict[str, Any] = {
1083 "players": {
1084 "p1": "not-a-dict",
1085 "p2": {"player_id": "p2", "values": "not-a-dict"},
1086 "p3": {"player_id": "p3", "values": {"icon": None}},
1087 "p4": {"player_id": "p4", "values": {"icon": 123}},
1088 }
1089 }
1090 assert _migrate_player_icons(data) is False
1091 assert data["players"]["p3"]["values"]["icon"] is None
1092 assert data["players"]["p4"]["values"]["icon"] == 123
1093
1094
1095def _orphaned_protocol_data() -> dict[str, Any]:
1096 """Build a config store with a disabled protocol player whose parent was removed."""
1097 return {
1098 "players": {
1099 "spb_esp32": {
1100 "player_id": "spb_esp32",
1101 "provider": "sendspin",
1102 "player_type": "protocol",
1103 "enabled": False,
1104 "values": {"protocol_parent_id": "up_esp32"},
1105 },
1106 "spb_kitchen": {
1107 "player_id": "spb_kitchen",
1108 "provider": "sendspin",
1109 "player_type": "protocol",
1110 "enabled": False,
1111 "values": {"protocol_parent_id": "up_kitchen"},
1112 },
1113 # only the parent side of the link survived
1114 "ap_office": {
1115 "player_id": "ap_office",
1116 "provider": "airplay",
1117 "player_type": "protocol",
1118 "enabled": False,
1119 "values": {},
1120 },
1121 "cast_office": {
1122 "player_id": "cast_office",
1123 "provider": "chromecast",
1124 "player_type": "player",
1125 "enabled": True,
1126 "values": {"linked_protocol_ids": ["ap_office"]},
1127 },
1128 # neither side of the link survived
1129 "ap_ghost": {
1130 "player_id": "ap_ghost",
1131 "provider": "airplay",
1132 "player_type": "protocol",
1133 "enabled": False,
1134 "values": {},
1135 },
1136 "up_kitchen": {
1137 "player_id": "up_kitchen",
1138 "provider": "universal_player",
1139 "player_type": "player",
1140 "enabled": False,
1141 "values": {"linked_protocol_ids": ["spb_kitchen"]},
1142 },
1143 },
1144 "player_dsp": {"spb_esp32": {"enabled": True}, "spb_kitchen": {"enabled": True}},
1145 }
1146
1147
1148def test_migrate_orphaned_disabled_protocol_configs() -> None:
1149 """A disabled protocol player without a parent config is dropped, others are kept."""
1150 data = _orphaned_protocol_data()
1151 assert _migrate_orphaned_disabled_protocol_configs(data) is True
1152 assert "spb_esp32" not in data["players"]
1153 assert "spb_esp32" not in data["player_dsp"]
1154 assert "ap_ghost" not in data["players"]
1155 # protocol players that are still owned by a player are left alone
1156 assert "spb_kitchen" in data["players"]
1157 assert "spb_kitchen" in data["player_dsp"]
1158 assert "ap_office" in data["players"]
1159 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1160
1161
1162def test_migrate_orphaned_disabled_protocol_configs_keeps_enabled_players() -> None:
1163 """An enabled protocol player is kept: it can register and find a new parent."""
1164 data = _orphaned_protocol_data()
1165 data["players"]["spb_esp32"]["enabled"] = True
1166 data["players"]["ap_ghost"]["enabled"] = True
1167 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1168 assert "spb_esp32" in data["players"]
1169
1170
1171def test_migrate_orphaned_disabled_protocol_configs_tolerates_malformed_data() -> None:
1172 """Non-dict player configs/values and non-protocol players are skipped."""
1173 data: dict[str, Any] = {
1174 "players": {
1175 "p1": "not-a-dict",
1176 "p2": {"player_id": "p2", "player_type": "player", "enabled": False, "values": None},
1177 "p3": {"player_id": "p3", "player_type": "player", "enabled": False, "values": {}},
1178 }
1179 }
1180 assert _migrate_orphaned_disabled_protocol_configs(data) is False
1181 assert len(data["players"]) == 3
1182
1183
1184def test_migrate_unrenamed_player_names() -> None:
1185 """A stored name that merely repeats the default name is cleared, real renames stay."""
1186 data: dict[str, Any] = {
1187 "players": {
1188 "never_renamed": {"name": "Living Room", "default_name": "Living Room"},
1189 "user_renamed": {"name": "Bathroom", "default_name": "solarium-bath-sl"},
1190 # without a default name, clearing the name would leave no name at all
1191 "without_default_name": {"name": "Kitchen"},
1192 "not_a_dict": "malformed",
1193 }
1194 }
1195
1196 assert _migrate_unrenamed_player_names(data) is True
1197 assert data["players"]["never_renamed"]["name"] is None
1198 assert data["players"]["user_renamed"]["name"] == "Bathroom"
1199 assert data["players"]["without_default_name"]["name"] == "Kitchen"
1200 # the cleared name must not be picked up again on the next start
1201 assert _migrate_unrenamed_player_names(data) is False
1202