1
0
mirror of https://github.com/home-assistant/core.git synced 2026-06-02 21:54:27 +01:00
Files
core/tests/components/onedrive/test_services.py
T
2026-05-04 20:17:41 +02:00

598 lines
19 KiB
Python

"""Tests for OneDrive services."""
from collections.abc import Generator
from dataclasses import dataclass
import re
from typing import Any, cast
from unittest.mock import MagicMock, Mock, patch
from onedrive_personal_sdk.exceptions import OneDriveException
import pytest
import voluptuous as vol
from homeassistant.components.onedrive.const import CONF_DELETE_PERMANENTLY, DOMAIN
from homeassistant.components.onedrive.services import (
CONF_CONFIG_ENTRY_ID,
CONF_DESTINATION_FOLDER,
CONF_DESTINATION_PATH,
DELETE_SERVICE,
UPLOAD_SERVICE,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_FILENAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import setup_integration
from tests.common import MockConfigEntry
TEST_FILENAME = "doorbell_snapshot.jpg"
TEST_DESTINATION_PATH = "photos/snapshots/image.jpg"
DESTINATION_FOLDER = "TestFolder"
@dataclass
class MockUploadFile:
"""Dataclass used to configure the test with a fake file behavior."""
content: bytes = b"image bytes"
exists: bool = True
is_allowed_path: bool = True
size: int | None = None
@pytest.fixture(name="upload_file")
def upload_file_fixture() -> MockUploadFile:
"""Fixture to set up test configuration with a fake file."""
return MockUploadFile()
@pytest.fixture(autouse=True)
def mock_upload_file(
hass: HomeAssistant, upload_file: MockUploadFile
) -> Generator[None]:
"""Fixture that mocks out the file calls using the FakeFile fixture."""
with (
patch(
"homeassistant.components.onedrive.services.Path.read_bytes",
return_value=upload_file.content,
),
patch(
"homeassistant.components.onedrive.services.Path.exists",
return_value=upload_file.exists,
),
patch.object(
hass.config, "is_allowed_path", return_value=upload_file.is_allowed_path
),
patch("pathlib.Path.stat") as mock_stat,
):
mock_stat.return_value = Mock()
mock_stat.return_value.st_size = upload_file.size or len(upload_file.content)
yield
async def test_upload_service(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test service call to upload content."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(DOMAIN, "upload")
response = await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
assert response
assert response["files"]
assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "metadata_id"
async def test_upload_service_no_response(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test service call to upload content without response."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(DOMAIN, "upload")
response = await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
)
assert response is None
async def test_upload_service_config_entry_not_found(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service call with a config entry that does not exist."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
assert err.value.translation_key == "service_config_entry_not_found"
async def test_config_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service call with a config entry that is not loaded."""
await setup_integration(hass, mock_config_entry)
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
assert err.value.translation_key == "service_config_entry_not_loaded"
@pytest.mark.parametrize("upload_file", [MockUploadFile(is_allowed_path=False)])
async def test_path_is_not_allowed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service call with a filename path that is not allowed."""
await setup_integration(hass, mock_config_entry)
with (
pytest.raises(HomeAssistantError, match="no access to path"),
):
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)])
async def test_filename_does_not_exist(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service call with a filename path that does not exist."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
assert exc_info.value.translation_key == "filenames_do_not_exist"
assert TEST_FILENAME in exc_info.value.translation_placeholders["filenames"]
@pytest.mark.parametrize("upload_file", [MockUploadFile(exists=False)])
async def test_multiple_filenames_do_not_exist(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service reports all missing files, not just the first one."""
await setup_integration(hass, mock_config_entry)
second_filename = "other_snapshot.jpg"
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: [TEST_FILENAME, second_filename],
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
assert exc_info.value.translation_key == "filenames_do_not_exist"
assert TEST_FILENAME in exc_info.value.translation_placeholders["filenames"]
assert second_filename in exc_info.value.translation_placeholders["filenames"]
async def test_upload_service_fails_upload(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test service call to upload content."""
await setup_integration(hass, mock_config_entry)
mock_onedrive_client.upload_file.side_effect = OneDriveException("error")
with pytest.raises(HomeAssistantError, match="Failed to upload"):
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
@pytest.mark.parametrize("upload_file", [MockUploadFile(size=260 * 1024 * 1024)])
async def test_upload_size_limit(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test upload service call with a filename path that does not exist."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(
HomeAssistantError,
match=re.escape(f"`{TEST_FILENAME}` is too large (272629760 > 262144000)"),
):
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
async def test_create_album_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test service call when folder creation fails."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(DOMAIN, "upload")
mock_onedrive_client.create_folder.side_effect = OneDriveException()
with pytest.raises(HomeAssistantError, match="Failed to create folder"):
await hass.services.async_call(
DOMAIN,
UPLOAD_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_FILENAME: TEST_FILENAME,
CONF_DESTINATION_FOLDER: DESTINATION_FOLDER,
},
blocking=True,
return_response=True,
)
async def test_delete_service_config_entry_not_found(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete service call with a config entry that does not exist."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id",
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert err.value.translation_key == "service_config_entry_not_found"
async def test_delete_service_config_entry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete service call with a config entry that is not loaded."""
await setup_integration(hass, mock_config_entry)
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert err.value.translation_key == "service_config_entry_not_loaded"
async def test_delete_service(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service call removes the remote file."""
await setup_integration(hass, mock_config_entry)
assert hass.services.has_service(DOMAIN, DELETE_SERVICE)
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
mock_onedrive_client.delete_drive_item.assert_called_once()
call_args = mock_onedrive_client.delete_drive_item.call_args
assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:"
assert call_args.args[1] is False
async def test_delete_service_delete_permanently(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service passes delete_permanently=True when option is set."""
await setup_integration(hass, mock_config_entry)
hass.config_entries.async_update_entry(
mock_config_entry, options={CONF_DELETE_PERMANENTLY: True}
)
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
call_args = mock_onedrive_client.delete_drive_item.call_args
assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:"
assert call_args.args[1] is True
async def test_delete_service_multiple_files(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service removes multiple remote files in parallel."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
called_paths = {
c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list
}
assert called_paths == {
f"id:/{TEST_DESTINATION_PATH}:",
f"id:/{second_path}:",
}
async def test_delete_service_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service raises HomeAssistantError on OneDriveException."""
await setup_integration(hass, mock_config_entry)
mock_onedrive_client.delete_drive_item.side_effect = OneDriveException("api error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert exc_info.value.translation_key == "delete_error"
assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_multiple_files_all_fail(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service aggregates errors from multiple failed deletions."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
mock_onedrive_client.delete_drive_item.side_effect = [
OneDriveException("error one"),
OneDriveException("error two"),
]
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
assert isinstance(exc_info.value.__cause__, ExceptionGroup)
assert len(exc_info.value.__cause__.exceptions) == 2
assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"]
assert second_path in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_multiple_files_partial_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service attempts all deletions before raising on partial failure."""
await setup_integration(hass, mock_config_entry)
second_path = "photos/snapshots/image2.jpg"
mock_onedrive_client.delete_drive_item.side_effect = [
None,
OneDriveException("error two"),
]
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path],
},
blocking=True,
)
assert mock_onedrive_client.delete_drive_item.call_count == 2
called_paths = {
c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list
}
assert called_paths == {
f"id:/{TEST_DESTINATION_PATH}:",
f"id:/{second_path}:",
}
assert exc_info.value.translation_key == "delete_error"
assert second_path in exc_info.value.translation_placeholders["paths"]
async def test_delete_service_get_approot_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_onedrive_client: MagicMock,
) -> None:
"""Test delete service raises HomeAssistantError when get_approot fails."""
await setup_integration(hass, mock_config_entry)
mock_onedrive_client.get_approot.side_effect = OneDriveException("network error")
with pytest.raises(HomeAssistantError) as exc_info:
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH],
},
blocking=True,
)
assert exc_info.value.translation_key == "connection_error"
async def test_delete_empty_destination_path(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test delete service raises when destination_path is an empty list."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(vol.Invalid):
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: [],
},
blocking=True,
)
@pytest.mark.parametrize(
"bad_path",
[
"",
"/",
"//",
"photos/../secrets",
"photos/file:name.jpg",
"../escape",
],
)
async def test_delete_invalid_destination_path(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
bad_path: str,
) -> None:
"""Test delete service raises HomeAssistantError for invalid destination paths."""
await setup_integration(hass, mock_config_entry)
with pytest.raises(HomeAssistantError, match="Invalid destination path"):
await hass.services.async_call(
DOMAIN,
DELETE_SERVICE,
{
CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id,
CONF_DESTINATION_PATH: bad_path,
},
blocking=True,
)