From 43da828a5186f802410d07bd6ac99e48bd69d50a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jan 2025 12:57:46 +0100 Subject: [PATCH] Make the time for automated backups configurable (#135825) * Make the time for automated backups configurable * Store time as a string, use None to indicate default time * Don't add jitter if the time is set by user * Include time of next automatic backup in response to backup/info * Update tests * Rename recurrence to state * Include scheduled backup time in backup/config/info response * Address review comments * Update cloud test * Add test for store migration * Address review comments --- homeassistant/components/backup/config.py | 65 ++++- homeassistant/components/backup/store.py | 37 ++- homeassistant/components/backup/websocket.py | 14 +- .../backup/snapshots/test_backup.ambr | 5 + .../backup/snapshots/test_store.ambr | 40 +++ .../backup/snapshots/test_websocket.ambr | 254 ++++++++++++++++- tests/components/backup/test_manager.py | 12 + tests/components/backup/test_store.py | 54 ++++ tests/components/backup/test_websocket.py | 269 ++++++++++++------ tests/components/cloud/test_backup.py | 1 + 10 files changed, 629 insertions(+), 122 deletions(-) create mode 100644 tests/components/backup/snapshots/test_store.ambr create mode 100644 tests/components/backup/test_store.py diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index 7c40792aec5..997813eca21 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass, field, replace +import datetime as dt from datetime import datetime, timedelta from enum import StrEnum import random @@ -23,11 +24,13 @@ from .models import BackupManagerError, Folder if TYPE_CHECKING: from .manager import BackupManager, ManagerBackup -# The time of the automatic backup event should be compatible with -# the time of the recorder's nightly job which runs at 04:12. -# Run the backup at 04:45. -CRON_PATTERN_DAILY = "45 4 * * *" -CRON_PATTERN_WEEKLY = "45 4 * * {}" +CRON_PATTERN_DAILY = "{m} {h} * * *" +CRON_PATTERN_WEEKLY = "{m} {h} * * {d}" + +# The default time for automatic backups to run is at 04:45. +# This time is chosen to be compatible with the time of the recorder's +# nightly job which runs at 04:12. +DEFAULT_BACKUP_TIME = dt.time(4, 45) # Randomize the start time of the backup by up to 60 minutes to avoid # all backups running at the same time. @@ -74,6 +77,11 @@ class BackupConfigData: else: last_completed = None + if time_str := data["schedule"]["time"]: + time = dt_util.parse_time(time_str) + else: + time = None + return cls( create_backup=CreateBackupConfig( agent_ids=data["create_backup"]["agent_ids"], @@ -90,7 +98,9 @@ class BackupConfigData: copies=retention["copies"], days=retention["days"], ), - schedule=BackupSchedule(state=ScheduleState(data["schedule"]["state"])), + schedule=BackupSchedule( + state=ScheduleState(data["schedule"]["state"]), time=time + ), ) def to_dict(self) -> StoredBackupConfig: @@ -137,7 +147,7 @@ class BackupConfig: *, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED, - schedule: ScheduleState | UndefinedType = UNDEFINED, + schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, ) -> None: """Update config.""" if create_backup is not UNDEFINED: @@ -148,7 +158,7 @@ class BackupConfig: self.data.retention = new_retention self.data.retention.apply(self._manager) if schedule is not UNDEFINED: - new_schedule = BackupSchedule(state=schedule) + new_schedule = BackupSchedule(**schedule) if new_schedule.to_dict() != self.data.schedule.to_dict(): self.data.schedule = new_schedule self.data.schedule.apply(self._manager) @@ -243,10 +253,18 @@ class StoredBackupSchedule(TypedDict): """Represent the stored backup schedule configuration.""" state: ScheduleState + time: str | None + + +class ScheduleParametersDict(TypedDict, total=False): + """Represent parameters for backup schedule.""" + + state: ScheduleState + time: dt.time | None class ScheduleState(StrEnum): - """Represent the schedule state.""" + """Represent the schedule recurrence.""" NEVER = "never" DAILY = "daily" @@ -264,7 +282,9 @@ class BackupSchedule: """Represent the backup schedule.""" state: ScheduleState = ScheduleState.NEVER + time: dt.time | None = None cron_event: CronSim | None = field(init=False, default=None) + next_automatic_backup: datetime | None = field(init=False, default=None) @callback def apply( @@ -279,11 +299,17 @@ class BackupSchedule: self._unschedule_next(manager) return + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME if self.state is ScheduleState.DAILY: - self._schedule_next(CRON_PATTERN_DAILY, manager) + self._schedule_next( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), + manager, + ) else: self._schedule_next( - CRON_PATTERN_WEEKLY.format(self.state.value), + CRON_PATTERN_WEEKLY.format( + m=time.minute, h=time.hour, d=self.state.value + ), manager, ) @@ -304,7 +330,10 @@ class BackupSchedule: if next_time < now: # schedule a backup at next daily time once # if we missed the last scheduled backup - cron_event = CronSim(CRON_PATTERN_DAILY, now) + time = self.time if self.time is not None else DEFAULT_BACKUP_TIME + cron_event = CronSim( + CRON_PATTERN_DAILY.format(m=time.minute, h=time.hour), now + ) next_time = next(cron_event) # reseed the cron event attribute # add a day to the next time to avoid scheduling at the same time again @@ -334,19 +363,27 @@ class BackupSchedule: except Exception: # noqa: BLE001 LOGGER.exception("Unexpected error creating automatic backup") - next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) + if self.time is None: + # randomize the start time of the backup by up to 60 minutes if the time is + # not set to avoid all backups running at the same time + next_time += timedelta(seconds=random.randint(0, BACKUP_START_TIME_JITTER)) LOGGER.debug("Scheduling next automatic backup at %s", next_time) + self.next_automatic_backup = next_time manager.remove_next_backup_event = async_track_point_in_time( manager.hass, _create_backup, next_time ) def to_dict(self) -> StoredBackupSchedule: """Convert backup schedule to a dict.""" - return StoredBackupSchedule(state=self.state) + return StoredBackupSchedule( + state=self.state, + time=self.time.isoformat() if self.time else None, + ) @callback def _unschedule_next(self, manager: BackupManager) -> None: """Unschedule the next backup.""" + self.next_automatic_backup = None if (remove_next_event := manager.remove_next_backup_event) is not None: remove_next_event() manager.remove_next_backup_event = None diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index ddabead24f9..205bdf80375 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store @@ -16,6 +16,7 @@ if TYPE_CHECKING: STORE_DELAY_SAVE = 30 STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +STORAGE_VERSION_MINOR = 2 class StoredBackupData(TypedDict): @@ -25,14 +26,44 @@ class StoredBackupData(TypedDict): config: StoredBackupConfig +class _BackupStore(Store[StoredBackupData]): + """Class to help storing backup data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize storage class.""" + super().__init__( + hass, + STORAGE_VERSION, + STORAGE_KEY, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def _async_migrate_func( + self, + old_major_version: int, + old_minor_version: int, + old_data: dict[str, Any], + ) -> dict[str, Any]: + """Migrate to the new version.""" + data = old_data + if old_major_version == 1: + if old_minor_version < 2: + # Version 1.2 adds configurable backup time + data["config"]["schedule"]["time"] = None + + if old_major_version > 1: + raise NotImplementedError + return data + + class BackupStore: """Store backup config.""" def __init__(self, hass: HomeAssistant, manager: BackupManager) -> None: - """Initialize the backup manager.""" + """Initialize the backup store.""" self._hass = hass self._manager = manager - self._store: Store[StoredBackupData] = Store(hass, STORAGE_VERSION, STORAGE_KEY) + self._store = _BackupStore(hass) async def load(self) -> StoredBackupData | None: """Load the store.""" diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 1b8433e2f24..235d53952c1 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from .config import ScheduleState from .const import DATA_MANAGER, LOGGER @@ -59,6 +60,7 @@ 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, + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup, }, ) @@ -321,7 +323,10 @@ async def handle_config_info( connection.send_result( msg["id"], { - "config": manager.config.data.to_dict(), + "config": manager.config.data.to_dict() + | { + "next_automatic_backup": manager.config.data.schedule.next_automatic_backup + }, }, ) @@ -351,7 +356,12 @@ async def handle_config_info( vol.Optional("days"): vol.Any(int, None), }, ), - vol.Optional("schedule"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("schedule"): vol.Schema( + { + vol.Optional("state"): vol.All(str, vol.Coerce(ScheduleState)), + vol.Optional("time"): vol.Any(cv.time, None), + } + ), } ) @websocket_api.async_response diff --git a/tests/components/backup/snapshots/test_backup.ambr b/tests/components/backup/snapshots/test_backup.ambr index f21de9d9fad..f1208877690 100644 --- a/tests/components/backup/snapshots/test_backup.ambr +++ b/tests/components/backup/snapshots/test_backup.ambr @@ -83,6 +83,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -112,6 +113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -141,6 +143,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -170,6 +173,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -199,6 +203,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/snapshots/test_store.ambr b/tests/components/backup/snapshots/test_store.ambr new file mode 100644 index 00000000000..fb5d0c276b5 --- /dev/null +++ b/tests/components/backup/snapshots/test_store.ambr @@ -0,0 +1,40 @@ +# serializer version: 1 +# name: test_store_migration + dict({ + 'data': dict({ + 'backups': list([ + dict({ + 'backup_id': 'abc123', + 'failed_agent_ids': list([ + 'test.remote', + ]), + }), + ]), + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'key': 'backup', + 'minor_version': 2, + 'version': 1, + }) +# --- diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 06bfa89369a..8b0ab1317c3 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -244,12 +244,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -279,12 +281,14 @@ }), 'last_attempted_automatic_backup': '2024-10-26T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -310,12 +314,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -341,12 +347,14 @@ }), 'last_attempted_automatic_backup': '2024-10-27T04:45:00+01:00', 'last_completed_automatic_backup': '2024-10-26T04:45:00+01:00', + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -372,12 +380,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -403,12 +413,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-16T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'sat', + 'time': None, }), }), }), @@ -433,12 +445,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -464,12 +478,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -502,11 +518,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -527,12 +544,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -558,12 +577,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -596,11 +617,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -621,12 +643,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -652,12 +676,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T06:00:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), @@ -690,11 +716,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': '06:00:00', }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -715,12 +742,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -746,12 +775,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-18T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), @@ -784,11 +815,12 @@ }), 'schedule': dict({ 'state': 'mon', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -809,12 +841,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -840,12 +874,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -878,11 +914,12 @@ }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -903,12 +940,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -938,12 +977,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -980,11 +1021,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1005,12 +1047,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1036,12 +1080,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1074,11 +1120,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1099,12 +1146,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1130,12 +1179,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1168,11 +1219,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1193,12 +1245,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1224,12 +1278,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1262,11 +1318,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1287,12 +1344,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1318,12 +1377,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': None, 'days': 7, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1356,11 +1417,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1381,12 +1443,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1412,12 +1476,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': '2024-11-14T04:55:00+01:00', 'retention': dict({ 'copies': 3, 'days': None, }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), @@ -1450,11 +1516,12 @@ }), 'schedule': dict({ 'state': 'daily', + 'time': None, }), }), }), 'key': 'backup', - 'minor_version': 1, + 'minor_version': 2, 'version': 1, }) # --- @@ -1475,12 +1542,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1505,12 +1574,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1535,12 +1606,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1565,12 +1638,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1595,12 +1670,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1625,12 +1702,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1655,12 +1734,14 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, }), }), }), @@ -1685,12 +1766,142 @@ }), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, 'retention': dict({ 'copies': None, 'days': None, }), 'schedule': dict({ 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command4].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5] + dict({ + 'id': 1, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, + }), + }), + }), + 'success': True, + 'type': 'result', + }) +# --- +# name: test_config_update_errors[command5].1 + dict({ + 'id': 3, + 'result': dict({ + 'config': dict({ + 'create_backup': dict({ + 'agent_ids': list([ + ]), + 'include_addons': None, + 'include_all_addons': False, + 'include_database': True, + 'include_folders': None, + 'name': None, + 'password': None, + }), + 'last_attempted_automatic_backup': None, + 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, + 'retention': dict({ + 'copies': None, + 'days': None, + }), + 'schedule': dict({ + 'state': 'never', + 'time': None, }), }), }), @@ -1708,6 +1919,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1734,6 +1946,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1776,6 +1989,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1802,6 +2016,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1844,6 +2059,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1897,6 +2113,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1934,6 +2151,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -1982,6 +2200,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2025,6 +2244,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2078,6 +2298,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2132,6 +2353,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2187,6 +2409,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2240,6 +2463,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2293,6 +2517,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2346,6 +2571,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2400,6 +2626,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2844,6 +3071,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2886,6 +3114,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2929,6 +3158,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -2993,6 +3223,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', @@ -3036,6 +3267,7 @@ ]), 'last_attempted_automatic_backup': None, 'last_completed_automatic_backup': None, + 'next_automatic_backup': None, }), 'success': True, 'type': 'result', diff --git a/tests/components/backup/test_manager.py b/tests/components/backup/test_manager.py index eef9e069e0f..224f87bea47 100644 --- a/tests/components/backup/test_manager.py +++ b/tests/components/backup/test_manager.py @@ -274,6 +274,7 @@ async def test_async_initiate_backup( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -519,6 +520,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id( @@ -613,6 +615,7 @@ async def test_async_initiate_backup_with_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await hass.async_block_till_done() @@ -880,6 +883,7 @@ async def test_async_initiate_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -990,6 +994,7 @@ async def test_async_initiate_backup_with_task_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1094,6 +1099,7 @@ async def test_initiate_backup_file_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1614,6 +1620,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id( @@ -1691,6 +1698,7 @@ async def test_receive_backup_agent_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await hass.async_block_till_done() @@ -1751,6 +1759,7 @@ async def test_receive_backup_non_agent_upload_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1871,6 +1880,7 @@ async def test_receive_backup_file_write_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -1979,6 +1989,7 @@ async def test_receive_backup_read_tar_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) @@ -2146,6 +2157,7 @@ async def test_receive_backup_file_read_error( "agent_errors": {}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, } await ws_client.send_json_auto_id({"type": "backup/subscribe_events"}) diff --git a/tests/components/backup/test_store.py b/tests/components/backup/test_store.py new file mode 100644 index 00000000000..d240e21531d --- /dev/null +++ b/tests/components/backup/test_store.py @@ -0,0 +1,54 @@ +"""Tests for the Backup integration.""" + +from typing import Any + +from syrupy import SnapshotAssertion + +from homeassistant.components.backup.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_backup_integration + + +async def test_store_migration( + hass: HomeAssistant, + hass_storage: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test migrating the backup store.""" + hass_storage[DOMAIN] = { + "data": { + "backups": [ + { + "backup_id": "abc123", + "failed_agent_ids": ["test.remote"], + } + ], + "config": { + "create_backup": { + "agent_ids": [], + "include_addons": None, + "include_all_addons": False, + "include_database": True, + "include_folders": None, + "name": None, + "password": None, + }, + "last_attempted_automatic_backup": None, + "last_completed_automatic_backup": None, + "retention": { + "copies": None, + "days": None, + }, + "schedule": { + "state": "never", + }, + }, + }, + "key": DOMAIN, + "version": 1, + } + await setup_backup_integration(hass) + await hass.async_block_till_done() + + assert hass_storage[DOMAIN] == snapshot diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 7498fbe2a67..29ce4dc485e 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -14,6 +14,7 @@ from homeassistant.components.backup import ( BackupAgentPlatformProtocol, BackupReaderWriterError, Folder, + store, ) from homeassistant.components.backup.agent import BackupAgentUnreachableError from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN @@ -70,9 +71,7 @@ DEFAULT_STORAGE_DATA: dict[str, Any] = { "copies": None, "days": None, }, - "schedule": { - "state": "never", - }, + "schedule": {"state": "never", "time": None}, }, } @@ -305,7 +304,8 @@ async def test_delete_with_errors( hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } await setup_backup_integration( hass, with_hassio=False, backups={LOCAL_AGENT_ID: [TEST_BACKUP_ABC123]} @@ -924,11 +924,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": 7}, "last_attempted_automatic_backup": "2024-10-26T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -948,11 +949,12 @@ async def test_agents_info( "retention": {"copies": 3, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -972,11 +974,12 @@ async def test_agents_info( "retention": {"copies": None, "days": 7}, "last_attempted_automatic_backup": "2024-10-27T04:45:00+01:00", "last_completed_automatic_backup": "2024-10-26T04:45:00+01:00", - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -996,11 +999,12 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "mon"}, + "schedule": {"state": "mon", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, { @@ -1020,30 +1024,35 @@ async def test_agents_info( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "sat"}, + "schedule": {"state": "sat", "time": None}, }, }, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, }, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_info( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, hass_storage: dict[str, Any], storage_data: dict[str, Any] | None, ) -> None: """Test getting backup config info.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + hass_storage.update(storage_data) await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1060,17 +1069,17 @@ async def test_config_info( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily", "time": "06:00"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "type": "backup/config/update", @@ -1081,59 +1090,63 @@ async def test_config_info( "name": "test-name", "password": "test-password", }, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": 3}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"days": 7}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, ], ) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_update( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, command: dict[str, Any], hass_storage: dict[str, Any], ) -> None: """Test updating the backup config.""" + client = await hass_ws_client(hass) + await hass.config.async_set_time_zone("Europe/Amsterdam") + freezer.move_to("2024-11-13T12:01:00+01:00") + await setup_backup_integration(hass) await hass.async_block_till_done() - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/config/info"}) assert await client.receive_json() == snapshot @@ -1146,6 +1159,11 @@ async def test_config_update( assert await client.receive_json() == snapshot await hass.async_block_till_done() + # Trigger store write + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass_storage[DOMAIN] == snapshot @@ -1156,7 +1174,17 @@ async def test_config_update( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, - "schedule": "someday", + "schedule": "blah", + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"state": "someday"}, + }, + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test-agent"]}, + "schedule": {"time": "early"}, }, { "type": "backup/config/update", @@ -1205,6 +1233,7 @@ async def test_config_update_errors( "time_2", "attempted_backup_time", "completed_backup_time", + "scheduled_backup_time", "backup_calls_1", "backup_calls_2", "call_args", @@ -1215,10 +1244,11 @@ async def test_config_update_errors( # No config update [], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1230,14 +1260,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1248,14 +1279,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-25T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", - "2024-11-18T04:45:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-25T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", + "2024-11-18T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1266,7 +1298,45 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "mon", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-25T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + "2024-11-18T03:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "daily", "time": "03:45"}, + } + ], + "2024-11-11T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-13T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + "2024-11-12T03:45:00+01:00", + 1, + 2, + BACKUP_CALL, + None, + ), + ( + [ + { + "type": "backup/config/update", + "create_backup": {"agent_ids": ["test.test-agent"]}, + "schedule": {"state": "never"}, } ], "2024-11-11T04:45:00+01:00", @@ -1274,6 +1344,7 @@ async def test_config_update_errors( "2034-11-11T13:00:00+01:00", "2024-11-11T04:45:00+01:00", "2024-11-11T04:45:00+01:00", + None, 0, 0, None, @@ -1284,14 +1355,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1302,14 +1374,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "mon", + "schedule": {"state": "mon"}, } ], "2024-10-26T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once - "2024-11-12T04:45:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", # missed event uses daily schedule once + "2024-11-12T04:55:00+01:00", 1, 1, BACKUP_CALL, @@ -1320,7 +1393,7 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "never", + "schedule": {"state": "never"}, } ], "2024-10-26T04:45:00+01:00", @@ -1328,6 +1401,7 @@ async def test_config_update_errors( "2034-11-12T12:00:00+01:00", "2024-10-26T04:45:00+01:00", "2024-10-26T04:45:00+01:00", + None, 0, 0, None, @@ -1338,14 +1412,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1356,14 +1431,15 @@ async def test_config_update_errors( { "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, - "schedule": "daily", + "schedule": {"state": "daily"}, } ], "2024-11-11T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", - "2024-11-13T04:45:00+01:00", - "2024-11-12T04:45:00+01:00", # attempted to create backup but failed + "2024-11-12T04:55:00+01:00", + "2024-11-13T04:55:00+01:00", + "2024-11-12T04:55:00+01:00", # attempted to create backup but failed "2024-11-11T04:45:00+01:00", + "2024-11-12T04:55:00+01:00", 1, 2, BACKUP_CALL, @@ -1371,7 +1447,7 @@ async def test_config_update_errors( ), ], ) -@patch("homeassistant.components.backup.config.BACKUP_START_TIME_JITTER", 0) +@patch("homeassistant.components.backup.config.random.randint", Mock(return_value=600)) async def test_config_schedule_logic( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -1384,6 +1460,7 @@ async def test_config_schedule_logic( time_2: str, attempted_backup_time: str, completed_backup_time: str, + scheduled_backup_time: str, backup_calls_1: int, backup_calls_2: int, call_args: Any, @@ -1406,13 +1483,14 @@ async def test_config_schedule_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": last_completed_automatic_backup, "last_completed_automatic_backup": last_completed_automatic_backup, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } create_backup.side_effect = create_backup_side_effect await hass.config.async_set_time_zone("Europe/Amsterdam") @@ -1426,6 +1504,10 @@ async def test_config_schedule_logic( result = await client.receive_json() assert result["success"] + await client.send_json_auto_id({"type": "backup/info"}) + result = await client.receive_json() + assert result["result"]["next_automatic_backup"] == scheduled_backup_time + freezer.move_to(time_1) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -1471,7 +1553,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1510,7 +1592,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1549,7 +1631,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1578,7 +1660,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1622,7 +1704,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1666,7 +1748,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1705,7 +1787,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1744,7 +1826,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1788,7 +1870,7 @@ async def test_config_schedule_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 0, "days": None}, - "schedule": "daily", + "schedule": {"state": "daily"}, }, { "backup-1": MagicMock( @@ -1852,13 +1934,14 @@ async def test_config_retention_copies_logic( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -1922,7 +2005,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": None, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1958,7 +2041,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -1994,7 +2077,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 3, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2035,7 +2118,7 @@ async def test_config_retention_copies_logic( "type": "backup/config/update", "create_backup": {"agent_ids": ["test.test-agent"]}, "retention": {"copies": 2, "days": None}, - "schedule": "never", + "schedule": {"state": "never"}, }, { "backup-1": MagicMock( @@ -2109,13 +2192,14 @@ async def test_config_retention_copies_logic_manual_backup( "retention": {"copies": None, "days": None}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, - "schedule": {"state": "daily"}, + "schedule": {"state": "daily", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors @@ -2236,7 +2320,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2272,7 +2356,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2308,7 +2392,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 3}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2344,7 +2428,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2385,7 +2469,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2421,7 +2505,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 2}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2457,7 +2541,7 @@ async def test_config_retention_copies_logic_manual_backup( "type": "backup/config/update", "create_backup": {"agent_ids": ["test-agent"]}, "retention": {"copies": None, "days": 0}, - "schedule": "never", + "schedule": {"state": "never"}, } ], { @@ -2529,13 +2613,14 @@ async def test_config_retention_days_logic( "retention": {"copies": None, "days": stored_retained_days}, "last_attempted_automatic_backup": None, "last_completed_automatic_backup": last_backup_time, - "schedule": {"state": "never"}, + "schedule": {"state": "never", "time": None}, }, } hass_storage[DOMAIN] = { "data": storage_data, "key": DOMAIN, - "version": 1, + "version": store.STORAGE_VERSION, + "minor_version": store.STORAGE_VERSION_MINOR, } get_backups.return_value = (backups, get_backups_agent_errors) delete_backup.return_value = delete_backup_agent_errors diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index fc8c7f27e56..112e71ec2db 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -204,6 +204,7 @@ async def test_agents_list_backups_fail_cloud( "backups": [], "last_attempted_automatic_backup": None, "last_completed_automatic_backup": None, + "next_automatic_backup": None, }