/
/
/
1"""
2Regression tests for persistent settings storage durability (support issue #5716).
3
4Users reported complete config loss after a power failure or unclean stop:
5both ``settings.json`` AND ``settings.json.backup`` ended up as zero-length
6files. The old save path renamed the live settings file to the backup and then
7rewrote ``settings.json`` in place, so a single crash inside the write window
8(or a failure during serialization) could destroy both generations at once.
9
10These tests pin down the safe behavior:
11- a failed save must leave the existing files on disk untouched
12- an empty/corrupt settings file must never be rotated over a good backup
13- a successful save rotates the previous file to the backup atomically
14"""
15
16from __future__ import annotations
17
18import json
19from copy import deepcopy
20from pathlib import Path
21from types import SimpleNamespace
22from unittest.mock import MagicMock, patch
23
24import pytest
25from music_assistant_models.enums import PlayerType
26
27from music_assistant.constants import CONF_PLAYERS
28from music_assistant.controllers.config.controller import ConfigController
29
30
31def _make_controller(tmp_path: Path) -> ConfigController:
32 mass = SimpleNamespace(storage_path=str(tmp_path))
33 return ConfigController(mass) # type: ignore[arg-type]
34
35
36async def test_save_rotates_previous_file_to_backup(tmp_path: Path) -> None:
37 """A successful save keeps the previous generation as a valid backup."""
38 controller = _make_controller(tmp_path)
39 controller._data = {"generation": 1}
40 await controller._async_save()
41 controller._data = {"generation": 2}
42 await controller._async_save()
43
44 assert json.loads(Path(controller.filename).read_text()) == {"generation": 2}
45 assert json.loads(Path(f"{controller.filename}.backup").read_text()) == {"generation": 1}
46 assert not Path(f"{controller.filename}.tmp").is_file()
47
48
49async def test_failed_save_leaves_existing_files_untouched(tmp_path: Path) -> None:
50 """A save that fails mid-write may not corrupt the files already on disk."""
51 controller = _make_controller(tmp_path)
52 controller._data = {"generation": 1}
53 await controller._async_save()
54 controller._data = {"generation": 2}
55 await controller._async_save()
56
57 controller._data = {"generation": 3}
58 with (
59 patch(
60 "music_assistant.controllers.config.controller.async_json_dumps",
61 side_effect=RuntimeError("serialization failed"),
62 ),
63 pytest.raises(RuntimeError),
64 ):
65 await controller._async_save()
66
67 assert json.loads(Path(controller.filename).read_text()) == {"generation": 2}
68 assert json.loads(Path(f"{controller.filename}.backup").read_text()) == {"generation": 1}
69
70
71async def test_empty_settings_file_does_not_clobber_backup(tmp_path: Path) -> None:
72 """
73 A zero-length settings file (crash leftover) must never replace a good backup.
74
75 This is the exact scenario from issue #5716: after loading from the backup,
76 the first save used to rotate the empty main file over the backup, making
77 the loss permanent if anything went wrong before the new write completed.
78 """
79 controller = _make_controller(tmp_path)
80 Path(controller.filename).write_text("")
81 Path(f"{controller.filename}.backup").write_text(json.dumps({"recovered": True}))
82
83 await controller._load()
84 assert controller._data == {"recovered": True}
85
86 await controller._async_save()
87
88 assert json.loads(Path(controller.filename).read_text()) == {"recovered": True}
89 assert json.loads(Path(f"{controller.filename}.backup").read_text()) == {"recovered": True}
90
91
92async def test_corrupt_settings_file_does_not_clobber_backup(tmp_path: Path) -> None:
93 """A non-empty but corrupt settings file (torn write) must never replace a good backup."""
94 controller = _make_controller(tmp_path)
95 Path(controller.filename).write_text('{"truncated": tr')
96 Path(f"{controller.filename}.backup").write_text(json.dumps({"recovered": True}))
97
98 await controller._load()
99 assert controller._data == {"recovered": True}
100
101 await controller._async_save()
102
103 assert json.loads(Path(controller.filename).read_text()) == {"recovered": True}
104 assert json.loads(Path(f"{controller.filename}.backup").read_text()) == {"recovered": True}
105
106
107async def test_save_succeeds_when_directory_fsync_unsupported(tmp_path: Path) -> None:
108 """The best-effort directory fsync may not fail the save on unsupported platforms."""
109 controller = _make_controller(tmp_path)
110 controller._data = {"generation": 1}
111 with patch(
112 "music_assistant.controllers.config.controller.os.open",
113 side_effect=OSError("fsync on directory not supported"),
114 ):
115 await controller._async_save()
116
117 assert json.loads(Path(controller.filename).read_text()) == {"generation": 1}
118
119
120async def test_player_config_summary_read_does_not_rewrite_settings(
121 tmp_path: Path,
122) -> None:
123 """A config/players read must not dirty raw player config that is later saved."""
124 mass = SimpleNamespace(storage_path=str(tmp_path), players=MagicMock())
125 controller = ConfigController(mass) # type: ignore[arg-type]
126 controller.initialized = True
127 player_id = "upe45f0170ef67"
128 raw_config = {
129 "player_id": player_id,
130 "provider": "universal_player",
131 "player_type": "player",
132 "enabled": True,
133 "name": "WC-Player",
134 "default_name": "solarium-bath-sl",
135 "values": {
136 "hide_in_ui": True,
137 "announce_volume_min": 55,
138 "announce_volume_max": 98,
139 "play_media_overrides_group": False,
140 "linked_protocol_ids": ["e4:5f:01:70:ef:67"],
141 },
142 }
143 controller._data = {CONF_PLAYERS: {player_id: deepcopy(raw_config)}}
144 live_player = SimpleNamespace(
145 state=SimpleNamespace(
146 name="solarium-bath-sl",
147 available=False,
148 type=PlayerType.PLAYER,
149 )
150 )
151 mass.players.get_player.return_value = live_player
152
153 configs = await controller.get_player_configs(include_values=False)
154 await controller._async_save()
155
156 assert configs[0].default_name == "solarium-bath-sl"
157 assert controller._data[CONF_PLAYERS][player_id] == raw_config
158 saved = json.loads(Path(controller.filename).read_text())
159 assert saved[CONF_PLAYERS][player_id] == raw_config
160