Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
Martin Hjelmare
d2c934923e Rename backup/upload websocket type to backup/agents/upload 2024-11-14 08:48:29 +01:00
Martin Hjelmare
f99b319048
Rename backup sync agent to backup agent (#130575)
* Rename sync agent module to agent

* Rename BackupSyncAgent to BackupAgent

* Fix test typo

* Rename async_get_backup_sync_agents to async_get_backup_agents

* Rename and clean up remaining sync things

* Update kitchen sink

* Apply suggestions from code review

* Update test_manager.py

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
2024-11-14 02:00:49 +01:00
Martin Hjelmare
957ece747d
Make BackupSyncMetadata model a dataclass (#130555)
Make backup BackupSyncMetadata model a dataclass
2024-11-13 21:11:25 +01:00
Joakim Sørensen
325738829d
MVP implementation of Backup sync agents (#126122)
* init sync agent

* add syncing

* root import

* rename list to info and add sync state

* Add base backup class

* Revert unneded change

* adjust tests

* move to kitchen_sink

* split

* move

* Adjustments

* Adjustment

* update

* Tests

* Test unknown agent

* adjust

* Adjust for different test environments

* Change /info WS to contain a dictinary

* reorder

* Add websocket command to trigger sync from the supervisor

* cleanup

* Make mypy happier

---------

Co-authored-by: Erik <erik@montnemery.com>
2024-11-13 20:22:54 +01:00
12 changed files with 1016 additions and 54 deletions

View file

@ -5,18 +5,25 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import DATA_MANAGER, DOMAIN, LOGGER from .agent import BackupAgent, UploadedBackup
from .const import DOMAIN, LOGGER
from .http import async_register_http_views from .http import async_register_http_views
from .manager import BackupManager from .manager import BackupManager
from .models import BackupUploadMetadata
from .websocket import async_register_websocket_handlers from .websocket import async_register_websocket_handlers
__all__ = [
"BackupAgent",
"BackupUploadMetadata",
"UploadedBackup",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Backup integration.""" """Set up the Backup integration."""
backup_manager = BackupManager(hass) hass.data[DOMAIN] = backup_manager = BackupManager(hass)
hass.data[DATA_MANAGER] = backup_manager
with_hassio = is_hassio(hass) with_hassio = is_hassio(hass)

View file

@ -0,0 +1,73 @@
"""Backup agents for the Backup integration."""
from __future__ import annotations
import abc
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from homeassistant.core import HomeAssistant
from .models import BackupUploadMetadata, BaseBackup
@dataclass(slots=True)
class UploadedBackup(BaseBackup):
"""Uploaded backup class."""
id: str
class BackupAgent(abc.ABC):
"""Define the format that backup agents can have."""
def __init__(self, name: str) -> None:
"""Initialize the backup agent."""
self.name = name
@abc.abstractmethod
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file.
The `id` parameter is the ID of the backup that was returned in async_list_backups.
The `path` parameter is the full file path to download the backup to.
"""
@abc.abstractmethod
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup.
The `path` parameter is the full file path to the backup that should be uploaded.
The `metadata` parameter contains metadata about the backup that should be uploaded.
"""
@abc.abstractmethod
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List backups."""
class BackupAgentPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
async def async_get_backup_agents(
self,
*,
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Register the backup agent."""

View file

@ -8,10 +8,11 @@ from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING: if TYPE_CHECKING:
from .manager import BackupManager from .manager import BaseBackupManager
from .models import BaseBackup
DOMAIN = "backup" DOMAIN = "backup"
DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN) DATA_MANAGER: HassKey[BaseBackupManager[BaseBackup]] = HassKey(DOMAIN)
LOGGER = getLogger(__package__) LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [ EXCLUDE_FROM_BACKUP = [

View file

@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.util import slugify from homeassistant.util import slugify
from .const import DATA_MANAGER from .const import DATA_MANAGER
from .manager import BackupManager
@callback @callback
@ -39,7 +40,7 @@ class DownloadBackupView(HomeAssistantView):
if not request["hass_user"].is_admin: if not request["hass_user"].is_admin:
return Response(status=HTTPStatus.UNAUTHORIZED) return Response(status=HTTPStatus.UNAUTHORIZED)
manager = request.app[KEY_HASS].data[DATA_MANAGER] manager = cast(BackupManager, request.app[KEY_HASS].data[DATA_MANAGER])
backup = await manager.async_get_backup(slug=slug) backup = await manager.async_get_backup(slug=slug)
if backup is None or not backup.path.exists(): if backup is None or not backup.path.exists():

View file

@ -16,10 +16,11 @@ import tarfile
from tarfile import TarError from tarfile import TarError
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import time import time
from typing import Any, Protocol, cast from typing import Any, Generic, Protocol, cast
import aiohttp import aiohttp
from securetar import SecureTarFile, atomic_contents_add from securetar import SecureTarFile, atomic_contents_add
from typing_extensions import TypeVar
from homeassistant.backup_restore import RESTORE_BACKUP_FILE from homeassistant.backup_restore import RESTORE_BACKUP_FILE
from homeassistant.const import __version__ as HAVERSION from homeassistant.const import __version__ as HAVERSION
@ -30,10 +31,14 @@ from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.json import json_loads_object from homeassistant.util.json import json_loads_object
from .agent import BackupAgent, BackupAgentPlatformProtocol
from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER
from .models import BackupUploadMetadata, BaseBackup
BUF_SIZE = 2**20 * 4 # 4MB BUF_SIZE = 2**20 * 4 # 4MB
_BackupT = TypeVar("_BackupT", bound=BaseBackup, default=BaseBackup)
@dataclass(slots=True) @dataclass(slots=True)
class NewBackup: class NewBackup:
@ -43,14 +48,10 @@ class NewBackup:
@dataclass(slots=True) @dataclass(slots=True)
class Backup: class Backup(BaseBackup):
"""Backup class.""" """Backup class."""
slug: str
name: str
date: str
path: Path path: Path
size: float
def as_dict(self) -> dict: def as_dict(self) -> dict:
"""Return a dict representation of this backup.""" """Return a dict representation of this backup."""
@ -76,19 +77,21 @@ class BackupPlatformProtocol(Protocol):
"""Perform operations after a backup finishes.""" """Perform operations after a backup finishes."""
class BaseBackupManager(abc.ABC): class BaseBackupManager(abc.ABC, Generic[_BackupT]):
"""Define the format that backup managers can have.""" """Define the format that backup managers can have."""
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the backup manager.""" """Initialize the backup manager."""
self.hass = hass self.hass = hass
self.backup_task: asyncio.Task | None = None self.backup_task: asyncio.Task | None = None
self.backups: dict[str, Backup] = {} self.backups: dict[str, _BackupT] = {}
self.loaded_platforms = False self.loaded_platforms = False
self.platforms: dict[str, BackupPlatformProtocol] = {} self.platforms: dict[str, BackupPlatformProtocol] = {}
self.backup_agents: dict[str, BackupAgent] = {}
self.syncing = False
@callback @callback
def _add_platform( def _add_platform_pre_post_handlers(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
integration_domain: str, integration_domain: str,
@ -98,13 +101,25 @@ class BaseBackupManager(abc.ABC):
if not hasattr(platform, "async_pre_backup") or not hasattr( if not hasattr(platform, "async_pre_backup") or not hasattr(
platform, "async_post_backup" platform, "async_post_backup"
): ):
LOGGER.warning(
"%s does not implement required functions for the backup platform",
integration_domain,
)
return return
self.platforms[integration_domain] = platform self.platforms[integration_domain] = platform
async def _async_add_platform_agents(
self,
hass: HomeAssistant,
integration_domain: str,
platform: BackupAgentPlatformProtocol,
) -> None:
"""Add a platform to the backup manager."""
if not hasattr(platform, "async_get_backup_agents"):
return
agents = await platform.async_get_backup_agents(hass=hass)
self.backup_agents.update(
{f"{integration_domain}.{agent.name}": agent for agent in agents}
)
async def async_pre_backup_actions(self, **kwargs: Any) -> None: async def async_pre_backup_actions(self, **kwargs: Any) -> None:
"""Perform pre backup actions.""" """Perform pre backup actions."""
if not self.loaded_platforms: if not self.loaded_platforms:
@ -139,10 +154,22 @@ class BaseBackupManager(abc.ABC):
async def load_platforms(self) -> None: async def load_platforms(self) -> None:
"""Load backup platforms.""" """Load backup platforms."""
if self.loaded_platforms:
return
await integration_platform.async_process_integration_platforms( await integration_platform.async_process_integration_platforms(
self.hass, DOMAIN, self._add_platform, wait_for_platforms=True self.hass,
DOMAIN,
self._add_platform_pre_post_handlers,
wait_for_platforms=True,
)
await integration_platform.async_process_integration_platforms(
self.hass,
DOMAIN,
self._async_add_platform_agents,
wait_for_platforms=True,
) )
LOGGER.debug("Loaded %s platforms", len(self.platforms)) LOGGER.debug("Loaded %s platforms", len(self.platforms))
LOGGER.debug("Loaded %s agents", len(self.backup_agents))
self.loaded_platforms = True self.loaded_platforms = True
@abc.abstractmethod @abc.abstractmethod
@ -159,14 +186,14 @@ class BaseBackupManager(abc.ABC):
"""Generate a backup.""" """Generate a backup."""
@abc.abstractmethod @abc.abstractmethod
async def async_get_backups(self, **kwargs: Any) -> dict[str, Backup]: async def async_get_backups(self, **kwargs: Any) -> dict[str, _BackupT]:
"""Get backups. """Get backups.
Return a dictionary of Backup instances keyed by their slug. Return a dictionary of Backup instances keyed by their slug.
""" """
@abc.abstractmethod @abc.abstractmethod
async def async_get_backup(self, *, slug: str, **kwargs: Any) -> Backup | None: async def async_get_backup(self, *, slug: str, **kwargs: Any) -> _BackupT | None:
"""Get a backup.""" """Get a backup."""
@abc.abstractmethod @abc.abstractmethod
@ -182,8 +209,12 @@ class BaseBackupManager(abc.ABC):
) -> None: ) -> None:
"""Receive and store a backup file from upload.""" """Receive and store a backup file from upload."""
@abc.abstractmethod
async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Upload a backup."""
class BackupManager(BaseBackupManager):
class BackupManager(BaseBackupManager[Backup]):
"""Backup manager for the Backup integration.""" """Backup manager for the Backup integration."""
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
@ -192,10 +223,42 @@ class BackupManager(BaseBackupManager):
self.backup_dir = Path(hass.config.path("backups")) self.backup_dir = Path(hass.config.path("backups"))
self.loaded_backups = False self.loaded_backups = False
async def async_upload_backup(self, *, slug: str, **kwargs: Any) -> None:
"""Upload a backup."""
await self.load_platforms()
if not self.backup_agents:
return
if not (backup := await self.async_get_backup(slug=slug)):
return
self.syncing = True
sync_backup_results = await asyncio.gather(
*(
agent.async_upload_backup(
path=backup.path,
metadata=BackupUploadMetadata(
homeassistant=HAVERSION,
size=backup.size,
date=backup.date,
slug=backup.slug,
name=backup.name,
),
)
for agent in self.backup_agents.values()
),
return_exceptions=True,
)
for result in sync_backup_results:
if isinstance(result, Exception):
LOGGER.error("Error during backup upload - %s", result)
self.syncing = False
async def load_backups(self) -> None: async def load_backups(self) -> None:
"""Load data of stored backup files.""" """Load data of stored backup files."""
backups = await self.hass.async_add_executor_job(self._read_backups) backups = await self.hass.async_add_executor_job(self._read_backups)
LOGGER.debug("Loaded %s backups", len(backups)) LOGGER.debug("Loaded %s local backups", len(backups))
self.backups = backups self.backups = backups
self.loaded_backups = True self.loaded_backups = True

View file

@ -0,0 +1,28 @@
"""Models for the backup integration."""
from dataclasses import asdict, dataclass
@dataclass()
class BaseBackup:
"""Base backup class."""
date: str
slug: str
size: float
name: str
def as_dict(self) -> dict:
"""Return a dict representation of this backup."""
return asdict(self)
@dataclass()
class BackupUploadMetadata:
"""Backup upload metadata."""
date: str # The date the backup was created
slug: str # The slug of the backup
size: float # The size of the backup (in bytes)
name: str # The name of the backup
homeassistant: str # The version of Home Assistant that created the backup

View file

@ -1,5 +1,6 @@
"""Websocket commands for the Backup integration.""" """Websocket commands for the Backup integration."""
from pathlib import Path
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
@ -14,9 +15,14 @@ from .manager import BackupProgress
@callback @callback
def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None: def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> None:
"""Register websocket commands.""" """Register websocket commands."""
websocket_api.async_register_command(hass, backup_agents_download)
websocket_api.async_register_command(hass, backup_agents_info)
websocket_api.async_register_command(hass, backup_agents_list_backups)
if with_hassio: if with_hassio:
websocket_api.async_register_command(hass, handle_backup_end) websocket_api.async_register_command(hass, handle_backup_end)
websocket_api.async_register_command(hass, handle_backup_start) websocket_api.async_register_command(hass, handle_backup_start)
websocket_api.async_register_command(hass, handle_backup_upload)
return return
websocket_api.async_register_command(hass, handle_details) websocket_api.async_register_command(hass, handle_details)
@ -40,7 +46,7 @@ async def handle_info(
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{ {
"backups": list(backups.values()), "backups": [b.as_dict() for b in backups.values()],
"backing_up": manager.backup_task is not None, "backing_up": manager.backup_task is not None,
}, },
) )
@ -162,3 +168,105 @@ async def handle_backup_end(
return return
connection.send_result(msg["id"]) connection.send_result(msg["id"])
@websocket_api.ws_require_user(only_supervisor=True)
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/agents/upload",
vol.Required("data"): {
vol.Required("slug"): str,
},
}
)
@websocket_api.async_response
async def handle_backup_upload(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Backup upload."""
LOGGER.debug("Backup upload notification")
data = msg["data"]
try:
await hass.data[DATA_MANAGER].async_upload_backup(slug=data["slug"])
except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "backup_upload_failed", str(err))
return
connection.send_result(msg["id"])
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/info"})
@websocket_api.async_response
async def backup_agents_info(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return backup agents info."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()
connection.send_result(
msg["id"],
{
"agents": [{"id": agent_id} for agent_id in manager.backup_agents],
"syncing": manager.syncing,
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "backup/agents/list_backups"})
@websocket_api.async_response
async def backup_agents_list_backups(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Return a list of uploaded backups."""
manager = hass.data[DATA_MANAGER]
backups: list[dict[str, Any]] = []
await manager.load_platforms()
for agent_id, agent in manager.backup_agents.items():
_listed_backups = await agent.async_list_backups()
backups.extend({**b.as_dict(), "agent_id": agent_id} for b in _listed_backups)
connection.send_result(msg["id"], backups)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/agents/download",
vol.Required("agent"): str,
vol.Required("backup_id"): str,
vol.Required("slug"): str,
}
)
@websocket_api.async_response
async def backup_agents_download(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Download an uploaded backup."""
manager = hass.data[DATA_MANAGER]
await manager.load_platforms()
if not (agent := manager.backup_agents.get(msg["agent"])):
connection.send_error(
msg["id"], "unknown_agent", f"Agent {msg['agent']} not found"
)
return
try:
await agent.async_download_backup(
id=msg["backup_id"],
path=Path(hass.config.path("backup"), f"{msg['slug']}.tar"),
)
except Exception as err: # noqa: BLE001
connection.send_error(msg["id"], "backup_agents_download", str(err))
return
connection.send_result(msg["id"])

View file

@ -0,0 +1,74 @@
"""Backup platform for the kitchen_sink integration."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from uuid import uuid4
from homeassistant.components.backup import (
BackupAgent,
BackupUploadMetadata,
UploadedBackup,
)
from homeassistant.core import HomeAssistant
LOGGER = logging.getLogger(__name__)
async def async_get_backup_sync_agents(
hass: HomeAssistant,
) -> list[BackupAgent]:
"""Register the backup agents."""
return [KitchenSinkBackupAgent("syncer")]
class KitchenSinkBackupAgent(BackupAgent):
"""Kitchen sink backup agent."""
def __init__(self, name: str) -> None:
"""Initialize the kitchen sink backup sync agent."""
super().__init__(name)
self._uploads = [
UploadedBackup(
id="def456",
name="Kitchen sink syncer",
slug="abc123",
size=1234,
date="1970-01-01T00:00:00Z",
)
]
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file."""
LOGGER.info("Downloading backup %s to %s", id, path)
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup."""
LOGGER.info("Uploading backup %s %s", path.name, metadata)
self._uploads.append(
UploadedBackup(
id=uuid4().hex,
name=metadata.name,
slug=metadata.slug,
size=metadata.size,
date=metadata.date,
)
)
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List synced backups."""
return self._uploads

View file

@ -3,10 +3,13 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from unittest.mock import patch from unittest.mock import patch
from homeassistant.components.backup import DOMAIN from homeassistant.components.backup import DOMAIN
from homeassistant.components.backup.agent import BackupAgent, UploadedBackup
from homeassistant.components.backup.manager import Backup from homeassistant.components.backup.manager import Backup
from homeassistant.components.backup.models import BackupUploadMetadata
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -20,6 +23,40 @@ TEST_BACKUP = Backup(
) )
class BackupAgentTest(BackupAgent):
"""Test backup agent."""
async def async_download_backup(
self,
*,
id: str,
path: Path,
**kwargs: Any,
) -> None:
"""Download a backup file."""
async def async_upload_backup(
self,
*,
path: Path,
metadata: BackupUploadMetadata,
**kwargs: Any,
) -> None:
"""Upload a backup."""
async def async_list_backups(self, **kwargs: Any) -> list[UploadedBackup]:
"""List backups."""
return [
UploadedBackup(
id="abc123",
name="Test",
slug="abc123",
size=13.37,
date="1970-01-01T00:00:00Z",
)
]
async def setup_backup_integration( async def setup_backup_integration(
hass: HomeAssistant, hass: HomeAssistant,
with_hassio: bool = False, with_hassio: bool = False,

View file

@ -1,4 +1,106 @@
# serializer version: 1 # serializer version: 1
# name: test_agents_download[with_hassio]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_agents_download[without_hassio]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_agents_download_exception
dict({
'error': dict({
'code': 'backup_agents_download',
'message': 'Boom',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_agents_download_unknown_agent
dict({
'error': dict({
'code': 'unknown_agent',
'message': 'Agent domain.test not found',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_agents_info[with_hassio]
dict({
'id': 1,
'result': dict({
'agents': list([
dict({
'id': 'domain.test',
}),
]),
'syncing': False,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_info[without_hassio]
dict({
'id': 1,
'result': dict({
'agents': list([
dict({
'id': 'domain.test',
}),
]),
'syncing': False,
}),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_list_backups[with_hassio]
dict({
'id': 1,
'result': list([
dict({
'agent_id': 'domain.test',
'date': '1970-01-01T00:00:00Z',
'id': 'abc123',
'name': 'Test',
'size': 13.37,
'slug': 'abc123',
}),
]),
'success': True,
'type': 'result',
})
# ---
# name: test_agents_list_backups[without_hassio]
dict({
'id': 1,
'result': list([
dict({
'agent_id': 'domain.test',
'date': '1970-01-01T00:00:00Z',
'id': 'abc123',
'name': 'Test',
'size': 13.37,
'slug': 'abc123',
}),
]),
'success': True,
'type': 'result',
})
# ---
# name: test_backup_end[with_hassio-hass_access_token] # name: test_backup_end[with_hassio-hass_access_token]
dict({ dict({
'error': dict({ 'error': dict({
@ -40,7 +142,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_end_excepion[exception0] # name: test_backup_end_exception[exception0]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'post_backup_actions_failed', 'code': 'post_backup_actions_failed',
@ -51,7 +153,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_end_excepion[exception1] # name: test_backup_end_exception[exception1]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'post_backup_actions_failed', 'code': 'post_backup_actions_failed',
@ -62,7 +164,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_end_excepion[exception2] # name: test_backup_end_exception[exception2]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'post_backup_actions_failed', 'code': 'post_backup_actions_failed',
@ -114,7 +216,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_start_excepion[exception0] # name: test_backup_start_exception[exception0]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'pre_backup_actions_failed', 'code': 'pre_backup_actions_failed',
@ -125,7 +227,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_start_excepion[exception1] # name: test_backup_start_exception[exception1]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'pre_backup_actions_failed', 'code': 'pre_backup_actions_failed',
@ -136,7 +238,7 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_start_excepion[exception2] # name: test_backup_start_exception[exception2]
dict({ dict({
'error': dict({ 'error': dict({
'code': 'pre_backup_actions_failed', 'code': 'pre_backup_actions_failed',
@ -147,6 +249,80 @@
'type': 'result', 'type': 'result',
}) })
# --- # ---
# name: test_backup_upload[with_hassio-hass_access_token]
dict({
'error': dict({
'code': 'only_supervisor',
'message': 'Only allowed as Supervisor',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_backup_upload[with_hassio-hass_supervisor_access_token]
dict({
'id': 1,
'result': None,
'success': True,
'type': 'result',
})
# ---
# name: test_backup_upload[without_hassio-hass_access_token]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_backup_upload[without_hassio-hass_supervisor_access_token]
dict({
'error': dict({
'code': 'unknown_command',
'message': 'Unknown command.',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_backup_upload_exception[exception0]
dict({
'error': dict({
'code': 'backup_upload_failed',
'message': '',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_backup_upload_exception[exception1]
dict({
'error': dict({
'code': 'backup_upload_failed',
'message': 'Boom',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_backup_upload_exception[exception2]
dict({
'error': dict({
'code': 'backup_upload_failed',
'message': 'Boom',
}),
'id': 1,
'success': False,
'type': 'result',
})
# ---
# name: test_details[with_hassio-with_backup_content] # name: test_details[with_hassio-with_backup_content]
dict({ dict({
'error': dict({ 'error': dict({

View file

@ -3,13 +3,15 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
import aiohttp import aiohttp
from multidict import CIMultiDict, CIMultiDictProxy from multidict import CIMultiDict, CIMultiDictProxy
import pytest import pytest
from homeassistant.components.backup import BackupManager from homeassistant.components.backup import BackupManager, BackupUploadMetadata
from homeassistant.components.backup.agent import BackupAgentPlatformProtocol
from homeassistant.components.backup.manager import ( from homeassistant.components.backup.manager import (
BackupPlatformProtocol, BackupPlatformProtocol,
BackupProgress, BackupProgress,
@ -18,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import TEST_BACKUP from .common import TEST_BACKUP, BackupAgentTest
from tests.common import MockPlatform, mock_platform from tests.common import MockPlatform, mock_platform
@ -39,7 +41,7 @@ async def _mock_backup_generation(
assert manager.backup_task is not None assert manager.backup_task is not None
assert progress == [] assert progress == []
await manager.backup_task backup = await manager.backup_task
assert progress == [BackupProgress(done=True, stage=None, success=True)] assert progress == [BackupProgress(done=True, stage=None, success=True)]
assert mocked_json_bytes.call_count == 1 assert mocked_json_bytes.call_count == 1
@ -48,10 +50,12 @@ async def _mock_backup_generation(
assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"} assert backup_json_dict["homeassistant"] == {"version": "2025.1.0"}
assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0]) assert manager.backup_dir.as_posix() in str(mocked_tarfile.call_args_list[0][0][0])
return backup
async def _setup_mock_domain( async def _setup_mock_domain(
hass: HomeAssistant, hass: HomeAssistant,
platform: BackupPlatformProtocol | None = None, platform: BackupPlatformProtocol | BackupAgentPlatformProtocol | None = None,
) -> None: ) -> None:
"""Set up a mock domain.""" """Set up a mock domain."""
mock_platform(hass, "some_domain.backup", platform or MockPlatform()) mock_platform(hass, "some_domain.backup", platform or MockPlatform())
@ -174,6 +178,7 @@ async def test_async_create_backup(
assert "Generated new backup with slug " in caplog.text assert "Generated new backup with slug " in caplog.text
assert "Creating backup directory" in caplog.text assert "Creating backup directory" in caplog.text
assert "Loaded 0 platforms" in caplog.text assert "Loaded 0 platforms" in caplog.text
assert "Loaded 0 agents" in caplog.text
async def test_loading_platforms( async def test_loading_platforms(
@ -191,6 +196,7 @@ async def test_loading_platforms(
Mock( Mock(
async_pre_backup=AsyncMock(), async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
), ),
) )
await manager.load_platforms() await manager.load_platforms()
@ -202,6 +208,32 @@ async def test_loading_platforms(
assert "Loaded 1 platforms" in caplog.text assert "Loaded 1 platforms" in caplog.text
async def test_loading_agents(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test loading backup agents."""
manager = BackupManager(hass)
assert not manager.loaded_platforms
assert not manager.platforms
await _setup_mock_domain(
hass,
Mock(
async_get_backup_agents=AsyncMock(return_value=[BackupAgentTest("test")]),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
assert manager.loaded_platforms
assert len(manager.backup_agents) == 1
assert "Loaded 1 agents" in caplog.text
assert "some_domain.test" in manager.backup_agents
async def test_not_loading_bad_platforms( async def test_not_loading_bad_platforms(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
@ -220,10 +252,151 @@ async def test_not_loading_bad_platforms(
assert len(manager.platforms) == 0 assert len(manager.platforms) == 0
assert "Loaded 0 platforms" in caplog.text assert "Loaded 0 platforms" in caplog.text
assert (
"some_domain does not implement required functions for the backup platform"
in caplog.text @pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup."""
manager = BackupManager(hass)
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
return_value=[
BackupAgentTest("agent1"),
BackupAgentTest("agent2"),
]
),
),
) )
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with (
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=backup,
),
patch.object(BackupAgentTest, "async_upload_backup") as mocked_upload,
patch(
"homeassistant.components.backup.manager.HAVERSION",
"2025.1.0",
),
):
await manager.async_upload_backup(slug=backup.slug)
assert mocked_upload.call_count == 2
first_call = mocked_upload.call_args_list[0]
assert first_call[1]["path"] == backup.path
assert first_call[1]["metadata"] == BackupUploadMetadata(
date=backup.date,
homeassistant="2025.1.0",
name=backup.name,
size=backup.size,
slug=backup.slug,
)
assert "Error during backup upload" not in caplog.text
@pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup_with_exception(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup with exception."""
manager = BackupManager(hass)
class ModifiedBackupSyncAgentTest(BackupAgentTest):
async def async_upload_backup(self, **kwargs: Any) -> None:
raise HomeAssistantError("Test exception")
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(
return_value=[
ModifiedBackupSyncAgentTest("agent1"),
ModifiedBackupSyncAgentTest("agent2"),
]
),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with (
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
return_value=backup,
),
patch.object(
ModifiedBackupSyncAgentTest,
"async_upload_backup",
) as mocked_upload,
patch(
"homeassistant.components.backup.manager.HAVERSION",
"2025.1.0",
),
):
mocked_upload.side_effect = HomeAssistantError("Test exception")
await manager.async_upload_backup(slug=backup.slug)
assert mocked_upload.call_count == 2
first_call = mocked_upload.call_args_list[0]
assert first_call[1]["path"] == backup.path
assert first_call[1]["metadata"] == BackupUploadMetadata(
date=backup.date,
homeassistant="2025.1.0",
name=backup.name,
size=backup.size,
slug=backup.slug,
)
assert "Error during backup upload - Test exception" in caplog.text
@pytest.mark.usefixtures("mock_backup_generation")
async def test_syncing_backup_no_agents(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
mocked_json_bytes: Mock,
mocked_tarfile: Mock,
) -> None:
"""Test syncing a backup with no agents."""
manager = BackupManager(hass)
await _setup_mock_domain(
hass,
Mock(
async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(return_value=[]),
),
)
await manager.load_platforms()
await hass.async_block_till_done()
backup = await _mock_backup_generation(manager, mocked_json_bytes, mocked_tarfile)
with patch(
"homeassistant.components.backup.agent.BackupAgent.async_upload_backup"
) as mocked_async_upload_backup:
await manager.async_upload_backup(slug=backup.slug)
assert mocked_async_upload_backup.call_count == 0
async def test_exception_plaform_pre( async def test_exception_plaform_pre(
@ -241,6 +414,7 @@ async def test_exception_plaform_pre(
Mock( Mock(
async_pre_backup=_mock_step, async_pre_backup=_mock_step,
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
), ),
) )
@ -263,6 +437,7 @@ async def test_exception_plaform_post(
Mock( Mock(
async_pre_backup=AsyncMock(), async_pre_backup=AsyncMock(),
async_post_backup=_mock_step, async_post_backup=_mock_step,
async_get_backup_agents=AsyncMock(),
), ),
) )
@ -285,6 +460,7 @@ async def test_loading_platforms_when_running_async_pre_backup_actions(
Mock( Mock(
async_pre_backup=AsyncMock(), async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
), ),
) )
await manager.async_pre_backup_actions() await manager.async_pre_backup_actions()
@ -310,6 +486,7 @@ async def test_loading_platforms_when_running_async_post_backup_actions(
Mock( Mock(
async_pre_backup=AsyncMock(), async_pre_backup=AsyncMock(),
async_post_backup=AsyncMock(), async_post_backup=AsyncMock(),
async_get_backup_agents=AsyncMock(),
), ),
) )
await manager.async_post_backup_actions() await manager.async_post_backup_actions()

View file

@ -1,16 +1,18 @@
"""Tests for the Backup integration.""" """Tests for the Backup integration."""
from unittest.mock import patch from pathlib import Path
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.backup.manager import Backup from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.models import BaseBackup
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from .common import TEST_BACKUP, setup_backup_integration from .common import TEST_BACKUP, BackupAgentTest, setup_backup_integration
from tests.typing import WebSocketGenerator from tests.typing import WebSocketGenerator
@ -43,15 +45,23 @@ async def test_info(
"""Test getting backup info.""" """Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio) await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backups = {TEST_BACKUP.slug: TEST_BACKUP}
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await hass.async_block_till_done() await hass.async_block_till_done()
with patch( with (
"homeassistant.components.backup.manager.BackupManager.async_get_backups", patch(
return_value={TEST_BACKUP.slug: TEST_BACKUP}, "homeassistant.components.backup.manager.BackupManager.load_backups",
AsyncMock(),
),
patch(
"homeassistant.components.backup.manager.BackupManager.async_get_backups",
return_value={TEST_BACKUP.slug: TEST_BACKUP},
),
): ):
await client.send_json_auto_id({"type": "backup/info"}) await client.send_json_auto_id({"type": "backup/info"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -73,7 +83,7 @@ async def test_details(
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
with_hassio: bool, with_hassio: bool,
backup_content: Backup | None, backup_content: BaseBackup | None,
) -> None: ) -> None:
"""Test getting backup info.""" """Test getting backup info."""
await setup_backup_integration(hass, with_hassio=with_hassio) await setup_backup_integration(hass, with_hassio=with_hassio)
@ -112,7 +122,7 @@ async def test_remove(
"homeassistant.components.backup.manager.BackupManager.async_remove_backup", "homeassistant.components.backup.manager.BackupManager.async_remove_backup",
): ):
await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"}) await client.send_json_auto_id({"type": "backup/remove", "slug": "abc123"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -140,7 +150,7 @@ async def test_generate(
await client.send_json_auto_id({"type": "backup/generate"}) await client.send_json_auto_id({"type": "backup/generate"})
for _ in range(number_of_messages): for _ in range(number_of_messages):
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -199,7 +209,7 @@ async def test_backup_end(
"homeassistant.components.backup.manager.BackupManager.async_post_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_post_backup_actions",
): ):
await client.send_json_auto_id({"type": "backup/end"}) await client.send_json_auto_id({"type": "backup/end"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -232,7 +242,47 @@ async def test_backup_start(
"homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions", "homeassistant.components.backup.manager.BackupManager.async_pre_backup_actions",
): ):
await client.send_json_auto_id({"type": "backup/start"}) await client.send_json_auto_id({"type": "backup/start"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"access_token_fixture_name",
["hass_access_token", "hass_supervisor_access_token"],
)
@pytest.mark.parametrize(
("with_hassio"),
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_backup_upload(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
sync_access_token_proxy: str,
*,
access_token_fixture_name: str,
with_hassio: bool,
) -> None:
"""Test backup upload from a WS command."""
await setup_backup_integration(hass, with_hassio=with_hassio)
client = await hass_ws_client(hass, sync_access_token_proxy)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_upload_backup",
):
await client.send_json_auto_id(
{
"type": "backup/agents/upload",
"data": {
"slug": "abc123",
},
}
)
assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -243,7 +293,7 @@ async def test_backup_start(
Exception("Boom"), Exception("Boom"),
], ],
) )
async def test_backup_end_excepion( async def test_backup_end_exception(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -261,7 +311,7 @@ async def test_backup_end_excepion(
side_effect=exception, side_effect=exception,
): ):
await client.send_json_auto_id({"type": "backup/end"}) await client.send_json_auto_id({"type": "backup/end"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -272,7 +322,43 @@ async def test_backup_end_excepion(
Exception("Boom"), Exception("Boom"),
], ],
) )
async def test_backup_start_excepion( async def test_backup_upload_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
hass_supervisor_access_token: str,
exception: Exception,
) -> None:
"""Test exception handling while running backup upload from a WS command."""
await setup_backup_integration(hass, with_hassio=True)
client = await hass_ws_client(hass, hass_supervisor_access_token)
await hass.async_block_till_done()
with patch(
"homeassistant.components.backup.manager.BackupManager.async_upload_backup",
side_effect=exception,
):
await client.send_json_auto_id(
{
"type": "backup/agents/upload",
"data": {
"slug": "abc123",
},
}
)
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"exception",
[
TimeoutError(),
HomeAssistantError("Boom"),
Exception("Boom"),
],
)
async def test_backup_start_exception(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
@ -290,4 +376,135 @@ async def test_backup_start_excepion(
side_effect=exception, side_effect=exception,
): ):
await client.send_json_auto_id({"type": "backup/start"}) await client.send_json_auto_id({"type": "backup/start"})
assert snapshot == await client.receive_json() assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_info(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test getting backup agents info."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/agents/info"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_list_backups(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test backup agents list backups details."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id({"type": "backup/agents/list_backups"})
assert await client.receive_json() == snapshot
@pytest.mark.parametrize(
"with_hassio",
[
pytest.param(True, id="with_hassio"),
pytest.param(False, id="without_hassio"),
],
)
async def test_agents_download(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
with_hassio: bool,
) -> None:
"""Test WS command to start downloading a backup."""
await setup_backup_integration(hass, with_hassio=with_hassio)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
with patch.object(BackupAgentTest, "async_download_backup") as download_mock:
assert await client.receive_json() == snapshot
assert download_mock.call_args[1] == {
"id": "abc123",
"path": Path(hass.config.path("backup"), "abc123.tar"),
}
async def test_agents_download_exception(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test WS command to start downloading a backup throwing an exception."""
await setup_backup_integration(hass)
hass.data[DATA_MANAGER].backup_agents = {"domain.test": BackupAgentTest("test")}
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
with patch.object(BackupAgentTest, "async_download_backup") as download_mock:
download_mock.side_effect = Exception("Boom")
assert await client.receive_json() == snapshot
async def test_agents_download_unknown_agent(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test downloading a backup with an unknown agent."""
await setup_backup_integration(hass)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "backup/agents/download",
"slug": "abc123",
"agent": "domain.test",
"backup_id": "abc123",
}
)
assert await client.receive_json() == snapshot