diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 3d24d807a06..9287aa2bf1b 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -18,6 +18,7 @@ import securetar from .const import __version__ as HA_VERSION RESTORE_BACKUP_FILE = ".HA_RESTORE" +RESTORE_BACKUP_RESULT_FILE = ".HA_RESTORE_RESULT" KEEP_BACKUPS = ("backups",) KEEP_DATABASE = ( "home-assistant_v2.db", @@ -62,7 +63,10 @@ def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | restore_database=instruction_content["restore_database"], restore_homeassistant=instruction_content["restore_homeassistant"], ) - except (FileNotFoundError, KeyError, json.JSONDecodeError): + except FileNotFoundError: + return None + except (KeyError, json.JSONDecodeError) as err: + _write_restore_result_file(config_dir, False, err) return None finally: # Always remove the backup instruction file to prevent a boot loop @@ -159,6 +163,23 @@ def _extract_backup( ) +def _write_restore_result_file( + config_dir: Path, success: bool, error: Exception | None +) -> None: + """Write the restore result file.""" + result_path = config_dir.joinpath(RESTORE_BACKUP_RESULT_FILE) + result_path.write_text( + json.dumps( + { + "success": success, + "error": str(error) if error else None, + "error_type": str(type(error).__name__) if error else None, + } + ), + encoding="utf-8", + ) + + def restore_backup(config_dir_path: str) -> bool: """Restore the backup file if any. @@ -177,7 +198,14 @@ def restore_backup(config_dir_path: str) -> bool: restore_content=restore_content, ) except FileNotFoundError as err: - raise ValueError(f"Backup file {backup_file_path} does not exist") from err + file_not_found = ValueError(f"Backup file {backup_file_path} does not exist") + _write_restore_result_file(config_dir, False, file_not_found) + raise file_not_found from err + except Exception as err: + _write_restore_result_file(config_dir, False, err) + raise + else: + _write_restore_result_file(config_dir, True, None) if restore_content.remove_after_restore: backup_file_path.unlink(missing_ok=True) _LOGGER.info("Restore complete, restarting") diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index ce3fea80f67..d3903c2d679 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -26,6 +26,7 @@ from .manager import ( BackupReaderWriterError, CoreBackupReaderWriter, CreateBackupEvent, + IdleEvent, IncorrectPasswordError, ManagerBackup, NewBackup, @@ -47,6 +48,7 @@ __all__ = [ "BackupReaderWriterError", "CreateBackupEvent", "Folder", + "IdleEvent", "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f439160381..fc56505e343 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -19,7 +19,11 @@ from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast import aiohttp from securetar import SecureTarFile, atomic_contents_add -from homeassistant.backup_restore import RESTORE_BACKUP_FILE, password_to_key +from homeassistant.backup_restore import ( + RESTORE_BACKUP_FILE, + RESTORE_BACKUP_RESULT_FILE, + password_to_key, +) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import ( @@ -28,7 +32,7 @@ from homeassistant.helpers import ( issue_registry as ir, ) from homeassistant.helpers.json import json_bytes -from homeassistant.util import dt as dt_util +from homeassistant.util import dt as dt_util, json as json_util from . import util as backup_util from .agent import ( @@ -261,6 +265,14 @@ class BackupReaderWriter(abc.ABC): ) -> None: """Restore a backup.""" + @abc.abstractmethod + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Get restore events after core restart.""" + class BackupReaderWriterError(BackupError): """Backup reader/writer error.""" @@ -318,6 +330,10 @@ class BackupManager: self.config.load(stored["config"]) self.known_backups.load(stored["backups"]) + await self._reader_writer.async_resume_restore_progress_after_restart( + on_progress=self.async_on_backup_event + ) + await self.load_platforms() @property @@ -1605,6 +1621,54 @@ class CoreBackupReaderWriter(BackupReaderWriter): ) await self._hass.services.async_call("homeassistant", "restart", blocking=True) + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + + def _read_restore_file() -> json_util.JsonObjectType | None: + """Read the restore file.""" + result_path = Path(self._hass.config.path(RESTORE_BACKUP_RESULT_FILE)) + + try: + restore_result = json_util.json_loads_object(result_path.read_bytes()) + except FileNotFoundError: + return None + finally: + try: + result_path.unlink(missing_ok=True) + except OSError as err: + LOGGER.warning( + "Unexpected error deleting backup restore result file: %s %s", + type(err), + err, + ) + + return restore_result + + restore_result = await self._hass.async_add_executor_job(_read_restore_file) + if not restore_result: + return + + success = restore_result["success"] + if not success: + LOGGER.warning( + "Backup restore failed with %s: %s", + restore_result["error_type"], + restore_result["error"], + ) + state = RestoreBackupState.COMPLETED if success else RestoreBackupState.FAILED + on_progress( + RestoreBackupEvent( + reason=cast(str, restore_result["error"]), + stage=None, + state=state, + ) + ) + on_progress(IdleEvent()) + def _generate_backup_id(date: str, name: str) -> str: """Generate a backup ID.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index d8a425ab6ba..feb762bb50b 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -60,8 +60,10 @@ async def handle_info( "backups": [backup.as_frontend_json() for backup in backups.values()], "last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup, "last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup, + "last_non_idle_event": manager.last_non_idle_event, "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, "next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional, + "state": manager.state, }, ) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 9362c03b0be..5318e4cd351 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -29,6 +29,7 @@ from homeassistant.components.backup import ( BackupReaderWriterError, CreateBackupEvent, Folder, + IdleEvent, IncorrectPasswordError, NewBackup, RestoreBackupEvent, @@ -456,6 +457,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter): finally: unsub() + async def async_resume_restore_progress_after_restart( + self, + *, + on_progress: Callable[[RestoreBackupEvent | IdleEvent], None], + ) -> None: + """Check restore status after core restart.""" + @callback def _async_listen_job_events( self, job_id: str, on_event: Callable[[Mapping[str, Any]], None] diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index 441f79276a5..032eb7ac537 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -85,8 +85,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -117,8 +119,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -149,8 +153,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -181,8 +187,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -213,8 +221,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index f5a22201138..7ea911496de 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -2977,8 +2977,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3005,8 +3007,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3050,8 +3054,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3078,8 +3084,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3123,8 +3131,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3179,8 +3189,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3219,8 +3231,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3270,8 +3284,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3319,8 +3335,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3375,8 +3393,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3432,8 +3452,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3490,8 +3512,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3546,8 +3570,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3602,8 +3628,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3658,8 +3686,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -3715,8 +3745,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4181,8 +4213,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4226,8 +4260,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4275,8 +4311,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4343,8 +4381,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', @@ -4389,8 +4429,10 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'last_non_idle_event': None, 'next_automatic_backup': None, 'next_automatic_backup_additional': False, + 'state': 'idle', }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index d2993e53410..5e5b0df74cd 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -397,8 +397,10 @@ async def test_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -626,8 +628,10 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -724,8 +728,15 @@ async def test_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "create_backup", + "reason": "upload_failed", + "stage": None, + "state": "failed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -993,8 +1004,10 @@ async def test_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1109,8 +1122,10 @@ async def test_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1217,8 +1232,10 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1703,8 +1720,10 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id( @@ -1786,8 +1805,15 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": { + "manager_state": "receive_backup", + "reason": None, + "stage": None, + "state": "completed", + }, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await hass.async_block_till_done() @@ -1848,8 +1874,10 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1973,8 +2001,10 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2086,8 +2116,10 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2264,8 +2296,10 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -3034,8 +3068,10 @@ async def test_initiate_backup_per_agent_encryption( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } for command in commands: @@ -3127,3 +3163,88 @@ async def test_initiate_backup_per_agent_encryption( "name": "test", "with_automatic_settings": False, } + + +@pytest.mark.parametrize( + ("restore_result", "last_non_idle_event"), + [ + ( + {"error": None, "error_type": None, "success": True}, + { + "manager_state": "restore_backup", + "reason": None, + "stage": None, + "state": "completed", + }, + ), + ( + {"error": "Boom!", "error_type": "ValueError", "success": False}, + { + "manager_state": "restore_backup", + "reason": "Boom!", + "stage": None, + "state": "failed", + }, + ), + ], +) +async def test_restore_progress_after_restart( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + restore_result: dict[str, Any], + last_non_idle_event: dict[str, Any], +) -> None: + """Test restore backup progress after restart.""" + + with patch( + "pathlib.Path.read_bytes", return_value=json.dumps(restore_result).encode() + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": last_non_idle_event, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + +async def test_restore_progress_after_restart_fail_to_remove( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test restore backup progress after restart when failing to remove result file.""" + + with patch("pathlib.Path.unlink", side_effect=OSError("Boom!")): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ws_client = await hass_ws_client(hass) + await ws_client.send_json_auto_id({"type": "backup/info"}) + result = await ws_client.receive_json() + assert result["success"] is True + assert result["result"] == { + "agent_errors": {}, + "backups": [], + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "last_non_idle_event": None, + "next_automatic_backup": None, + "next_automatic_backup_additional": False, + "state": "idle", + } + + assert ( + "Unexpected error deleting backup restore result file: Boom!" + in caplog.text + ) diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index 516dacd5f3d..c2513168ab9 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -205,8 +205,10 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/components/onboarding/snapshots/test_views.ambr b/tests/components/onboarding/snapshots/test_views.ambr index 90428055823..b57c6cf96dd 100644 --- a/tests/components/onboarding/snapshots/test_views.ambr +++ b/tests/components/onboarding/snapshots/test_views.ambr @@ -10,9 +10,12 @@ 'version': '1.0.0', }), ]), - 'agent_ids': list([ - 'backup.local', - ]), + 'agents': dict({ + 'backup.local': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'abc123', 'database_included': True, 'date': '1970-01-01T00:00:00.000Z', @@ -25,16 +28,17 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test', - 'protected': False, - 'size': 0, 'with_automatic_settings': True, }), dict({ 'addons': list([ ]), - 'agent_ids': list([ - 'test.remote', - ]), + 'agents': dict({ + 'test.remote': dict({ + 'protected': True, + 'size': 0, + }), + }), 'backup_id': 'def456', 'database_included': False, 'date': '1980-01-01T00:00:00.000Z', @@ -47,8 +51,6 @@ 'homeassistant_included': True, 'homeassistant_version': '2024.12.0', 'name': 'Test 2', - 'protected': False, - 'size': 1, 'with_automatic_settings': None, }), ]), diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 683d2c370f2..98f6426609e 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -798,6 +798,9 @@ async def test_onboarding_backup_info( backups = { "abc123": backup.ManagerBackup( addons=[backup.AddonInfo(name="Test", slug="test", version="1.0.0")], + agents={ + "backup.local": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="abc123", date="1970-01-01T00:00:00.000Z", database_included=True, @@ -806,14 +809,14 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test", - protected=False, - size=0, - agent_ids=["backup.local"], failed_agent_ids=[], with_automatic_settings=True, ), "def456": backup.ManagerBackup( addons=[], + agents={ + "test.remote": backup.manager.AgentBackupStatus(protected=True, size=0) + }, backup_id="def456", date="1980-01-01T00:00:00.000Z", database_included=False, @@ -825,9 +828,6 @@ async def test_onboarding_backup_info( homeassistant_included=True, homeassistant_version="2024.12.0", name="Test 2", - protected=False, - size=1, - agent_ids=["test.remote"], failed_agent_ids=[], with_automatic_settings=None, ), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0d4fd0dc080..cdbc5934c5f 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -332,8 +332,10 @@ async def test_agents_list_backups_error( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "last_non_idle_event": None, "next_automatic_backup": None, "next_automatic_backup_additional": False, + "state": "idle", } diff --git a/tests/test_backup_restore.py b/tests/test_backup_restore.py index 10ea64a6a61..4c6bc930667 100644 --- a/tests/test_backup_restore.py +++ b/tests/test_backup_restore.py @@ -1,7 +1,10 @@ """Test methods in backup_restore.""" +from collections.abc import Generator +import json from pathlib import Path import tarfile +from typing import Any from unittest import mock import pytest @@ -11,6 +14,23 @@ from homeassistant import backup_restore from .common import get_test_config_dir +@pytest.fixture(autouse=True) +def remove_restore_result_file() -> Generator[None, Any, Any]: + """Remove the restore result file.""" + yield + Path(get_test_config_dir(".HA_RESTORE_RESULT")).unlink(missing_ok=True) + + +def restore_result_file_content() -> dict[str, Any] | None: + """Return the content of the restore result file.""" + try: + return json.loads( + Path(get_test_config_dir(".HA_RESTORE_RESULT")).read_text("utf-8") + ) + except FileNotFoundError: + return None + + @pytest.mark.parametrize( ("side_effect", "content", "expected"), [ @@ -87,6 +107,11 @@ def test_restoring_backup_that_does_not_exist() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_restoring_backup_when_instructions_can_not_be_read() -> None: @@ -98,6 +123,7 @@ def test_restoring_backup_when_instructions_can_not_be_read() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() is None def test_restoring_backup_that_is_not_a_file() -> None: @@ -121,6 +147,11 @@ def test_restoring_backup_that_is_not_a_file() -> None: ), ): assert backup_restore.restore_backup(Path(get_test_config_dir())) is False + assert restore_result_file_content() == { + "error": f"Backup file {backup_file_path} does not exist", + "error_type": "ValueError", + "success": False, + } def test_aborting_for_older_versions() -> None: @@ -152,6 +183,13 @@ def test_aborting_for_older_versions() -> None: ), ): assert backup_restore.restore_backup(config_dir) is True + assert restore_result_file_content() == { + "error": ( + "You need at least Home Assistant version 9999.99.99 to restore this backup" + ), + "error_type": "ValueError", + "success": False, + } @pytest.mark.parametrize( @@ -280,6 +318,11 @@ def test_removal_of_current_configuration_when_restoring( assert removed_directories == { Path(config_dir, d) for d in expected_removed_directories } + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } def test_extracting_the_contents_of_a_backup_file() -> None: @@ -332,6 +375,11 @@ def test_extracting_the_contents_of_a_backup_file() -> None: assert { member.name for member in extractall_mock.mock_calls[-1].kwargs["members"] } == {"data", "data/.HA_VERSION", "data/.storage", "data/www"} + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize( @@ -362,6 +410,11 @@ def test_remove_backup_file_after_restore( assert mock_unlink.call_count == unlink_calls for call in mock_unlink.mock_calls: assert call.args[0] == backup_file_path + assert restore_result_file_content() == { + "error": None, + "error_type": None, + "success": True, + } @pytest.mark.parametrize(