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