mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 07:51:46 -05:00
Add to-do list platform to Apple iCloud (#181058)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d4842c8be3
commit
5006c36d10
@@ -18,7 +18,7 @@ from .const import (
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
from .coordinator import IcloudCalendarCoordinator
|
||||
from .coordinator import IcloudCalendarCoordinator, IcloudRemindersCoordinator
|
||||
from .media_source import async_setup_mediasource, async_setup_photo_cache
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -63,15 +63,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: IcloudConfigEntry) -> bo
|
||||
|
||||
await hass.async_add_executor_job(account.setup)
|
||||
|
||||
# Refreshed before the platforms are forwarded so the calendars are known
|
||||
# by the time the calendar platform sets up. This deliberately does not use
|
||||
# async_config_entry_first_refresh: an account that fails to authenticate
|
||||
# still loads and starts a reauth flow, and a calendar outage should not
|
||||
# take device tracking down with it. Calendars that are missing from the
|
||||
# first refresh appear on a later one through the coordinator listener.
|
||||
# Refreshed before the platforms are forwarded so the calendars and lists
|
||||
# are known by the time their platforms set up. This deliberately does not
|
||||
# use async_config_entry_first_refresh: an account that fails to
|
||||
# authenticate still loads and starts a reauth flow, and a calendar or
|
||||
# Reminders outage should not take device tracking down with it. Anything
|
||||
# missing from the first refresh appears on a later one through the
|
||||
# coordinator listener.
|
||||
account.calendar_coordinator = IcloudCalendarCoordinator(hass, entry)
|
||||
await account.calendar_coordinator.async_refresh()
|
||||
|
||||
account.reminders_coordinator = IcloudRemindersCoordinator(hass, entry)
|
||||
await account.reminders_coordinator.async_refresh()
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
await async_setup_photo_cache(hass, account)
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ from .const import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .coordinator import IcloudCalendarCoordinator
|
||||
from .coordinator import IcloudCalendarCoordinator, IcloudRemindersCoordinator
|
||||
from .media_source import PhotoCache
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -101,6 +101,7 @@ class IcloudAccount:
|
||||
|
||||
# Built in async_setup_entry, before the platforms are forwarded.
|
||||
self.calendar_coordinator: IcloudCalendarCoordinator | None = None
|
||||
self.reminders_coordinator: IcloudRemindersCoordinator | None = None
|
||||
|
||||
self.photo_cache: PhotoCache | None = None
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ DEFAULT_GPS_ACCURACY_THRESHOLD = 500 # meters
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 2
|
||||
|
||||
PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.CALENDAR, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.TODO]
|
||||
|
||||
# pyicloud.AppleDevice status
|
||||
DEVICE_BATTERY_LEVEL = "batteryLevel"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Coordinator for iCloud Calendars."""
|
||||
"""Coordinators for the iCloud integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta, tzinfo
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from pyicloud.exceptions import PyiCloudException
|
||||
from pyicloud.services.calendar import CalendarService, EventObject
|
||||
from pyicloud.services.reminders.client import RemindersApiError, RemindersAuthError
|
||||
from pyicloud.services.reminders.service import RemindersService
|
||||
|
||||
from homeassistant.components.calendar import CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -19,7 +21,7 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=15)
|
||||
CALENDAR_SCAN_INTERVAL = timedelta(minutes=15)
|
||||
|
||||
# How much of the calendar to keep cached for the entity's current/next event.
|
||||
# `async_get_events` queries iCloud directly for anything outside this window.
|
||||
@@ -52,7 +54,7 @@ class IcloudCalendarCoordinator(DataUpdateCoordinator[dict[str, IcloudCalendarDa
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
update_interval=CALENDAR_SCAN_INTERVAL,
|
||||
)
|
||||
self.account = entry.runtime_data
|
||||
|
||||
@@ -196,3 +198,169 @@ def _as_calendar_event(event: EventObject) -> CalendarEvent | None:
|
||||
end=end_value,
|
||||
location=event.location or None,
|
||||
)
|
||||
|
||||
|
||||
REMINDERS_SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
# pyicloud raises these for CloudKit failures; neither subclasses
|
||||
# PyiCloudException, so both have to be caught explicitly.
|
||||
# `get()` raises a bare LookupError when a reminder was removed elsewhere.
|
||||
REMINDERS_ERRORS = (
|
||||
PyiCloudException,
|
||||
RemindersApiError,
|
||||
RemindersAuthError,
|
||||
LookupError,
|
||||
)
|
||||
|
||||
# pyicloud substitutes this when a reminder's title cannot be decrypted, which
|
||||
# happens on Advanced Data Protection accounts whose session has no Protected
|
||||
# Cloud Storage key. Such a reminder has no readable title and no notes, and
|
||||
# `update()` rewrites TitleDocument and NotesDocument unconditionally, so
|
||||
# writing one back would replace the real title with this placeholder and blank
|
||||
# the notes. They are left out of the to-do lists entirely until the library
|
||||
# requests Protected Cloud Storage access.
|
||||
UNDECODED_TITLE = "Error Decoding Title"
|
||||
|
||||
# Reminders are fetched one list at a time, so keep the per-list page bounded.
|
||||
RESULTS_LIMIT = 200
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IcloudReminder:
|
||||
"""A single reminder."""
|
||||
|
||||
uid: str
|
||||
summary: str
|
||||
description: str | None
|
||||
due: date | datetime | None
|
||||
completed: bool
|
||||
completed_at: datetime | None
|
||||
# Only used to order subtasks after their parent; never exposed on the
|
||||
# to-do item, which has no notion of a parent.
|
||||
parent_uid: str | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IcloudReminderList:
|
||||
"""A reminder list and the reminders it holds."""
|
||||
|
||||
list_id: str
|
||||
name: str
|
||||
reminders: list[IcloudReminder] = field(default_factory=list)
|
||||
|
||||
|
||||
class IcloudRemindersCoordinator(DataUpdateCoordinator[dict[str, IcloudReminderList]]):
|
||||
"""Fetch reminder lists and their contents."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: IcloudConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=REMINDERS_SCAN_INTERVAL,
|
||||
)
|
||||
self.account = entry.runtime_data
|
||||
self._warned_undecoded = False
|
||||
self._skipped_undecoded = False
|
||||
|
||||
@property
|
||||
def reminders(self) -> RemindersService:
|
||||
"""Return the reminders service of the authenticated account."""
|
||||
if (api := self.account.api) is None:
|
||||
raise ConfigEntryAuthFailed("iCloud account is not authenticated")
|
||||
return api.reminders
|
||||
|
||||
def _fetch(self) -> dict[str, IcloudReminderList]:
|
||||
"""Fetch every list and its reminders. Runs in the executor."""
|
||||
service = self.reminders
|
||||
result: dict[str, IcloudReminderList] = {}
|
||||
self._skipped_undecoded = False
|
||||
|
||||
for list_id, name in _live_lists(service):
|
||||
batch = service.list_reminders(
|
||||
list_id=list_id,
|
||||
include_completed=True,
|
||||
results_limit=RESULTS_LIMIT,
|
||||
)
|
||||
reminders = []
|
||||
for reminder in batch.reminders:
|
||||
if reminder.deleted:
|
||||
continue
|
||||
if reminder.title == UNDECODED_TITLE:
|
||||
self._skipped_undecoded = True
|
||||
continue
|
||||
reminders.append(_as_reminder(reminder))
|
||||
|
||||
result[list_id] = IcloudReminderList(
|
||||
list_id=list_id, name=name, reminders=reminders
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> dict[str, IcloudReminderList]:
|
||||
"""Fetch reminders from iCloud."""
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(self._fetch)
|
||||
except REMINDERS_ERRORS as err:
|
||||
raise UpdateFailed(f"Error fetching reminders: {err}") from err
|
||||
|
||||
self._warn_if_undecoded()
|
||||
return data
|
||||
|
||||
def _warn_if_undecoded(self) -> None:
|
||||
"""Warn once if reminders had to be left out because of encryption.
|
||||
|
||||
With Advanced Data Protection enabled, CloudKit only returns readable
|
||||
content to a session holding a Protected Cloud Storage key, which
|
||||
pyicloud does not yet request. Those reminders are skipped rather than
|
||||
shown as placeholders, so say once why a list looks short.
|
||||
"""
|
||||
if self._warned_undecoded or not self._skipped_undecoded:
|
||||
return
|
||||
|
||||
self._warned_undecoded = True
|
||||
_LOGGER.warning(
|
||||
"Some reminders could not be decrypted and have been left out of "
|
||||
"the to-do lists. This account uses Advanced Data Protection, "
|
||||
"which Home Assistant cannot read Reminders from yet"
|
||||
)
|
||||
|
||||
|
||||
def _live_lists(service: RemindersService) -> list[tuple[str, str]]:
|
||||
"""Return the ``(list_id, name)`` pairs that should become todo entities.
|
||||
|
||||
``lists()`` yields every List record in the CloudKit zone, which includes
|
||||
groups: a folder holding other lists rather than reminders. A group and a
|
||||
list inside it can share a name, so leaving groups in shows that name
|
||||
twice, once as a permanently empty list.
|
||||
|
||||
Deleted lists leave tombstones that cannot be recognised yet. pyicloud
|
||||
exposes ``deleted`` on a reminder but not on a list, and the model forbids
|
||||
extra fields, so a tombstone is indistinguishable from an empty list until
|
||||
timlaing/pyicloud#319 is released.
|
||||
"""
|
||||
return [
|
||||
(reminder_list.id, reminder_list.title)
|
||||
for reminder_list in service.lists()
|
||||
if not reminder_list.is_group
|
||||
]
|
||||
|
||||
|
||||
def _as_reminder(reminder) -> IcloudReminder:
|
||||
"""Convert a pyicloud reminder into the coordinator's model."""
|
||||
due = reminder.due_date
|
||||
if due is not None and reminder.all_day and isinstance(due, datetime):
|
||||
due = due.date()
|
||||
|
||||
return IcloudReminder(
|
||||
uid=reminder.id,
|
||||
summary=reminder.title,
|
||||
description=reminder.desc or None,
|
||||
due=due,
|
||||
completed=reminder.completed,
|
||||
completed_at=reminder.completed_date,
|
||||
parent_uid=reminder.parent_reminder_id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Support for iCloud Reminders."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import override
|
||||
|
||||
from pyicloud.services.reminders.service import RemindersService
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoItemStatus,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .account import IcloudConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
REMINDERS_ERRORS,
|
||||
UNDECODED_TITLE,
|
||||
IcloudReminder,
|
||||
IcloudRemindersCoordinator,
|
||||
)
|
||||
|
||||
# Every action is a blocking read-modify-write against one shared reminders
|
||||
# service, so two concurrent calls could read the same reminder and then
|
||||
# overwrite each other.
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: IcloudConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the iCloud reminder lists."""
|
||||
coordinator = entry.runtime_data.reminders_coordinator
|
||||
assert coordinator is not None
|
||||
|
||||
known: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _add_new_lists() -> None:
|
||||
"""Add entities for lists that appeared since the last poll."""
|
||||
if not (new := set(coordinator.data or {}) - known):
|
||||
return
|
||||
known.update(new)
|
||||
async_add_entities(
|
||||
IcloudTodoListEntity(coordinator, entry, list_id) for list_id in new
|
||||
)
|
||||
|
||||
_add_new_lists()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_new_lists))
|
||||
|
||||
|
||||
class IcloudTodoListEntity(
|
||||
CoordinatorEntity[IcloudRemindersCoordinator], TodoListEntity
|
||||
):
|
||||
"""A reminder list from iCloud."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.UPDATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
|
||||
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
|
||||
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: IcloudRemindersCoordinator,
|
||||
entry: IcloudConfigEntry,
|
||||
list_id: str,
|
||||
) -> None:
|
||||
"""Initialize the reminder list."""
|
||||
super().__init__(coordinator)
|
||||
self._list_id = list_id
|
||||
self._attr_unique_id = f"{entry.unique_id}_{list_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{entry.unique_id}_account")},
|
||||
manufacturer="Apple",
|
||||
name=entry.title,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return True if the list still exists in iCloud."""
|
||||
return super().available and self._list_id in (self.coordinator.data or {})
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str | None:
|
||||
"""Return the name of the list."""
|
||||
if (
|
||||
reminder_list := (self.coordinator.data or {}).get(self._list_id)
|
||||
) is not None:
|
||||
return reminder_list.name
|
||||
return None
|
||||
|
||||
@property
|
||||
@override
|
||||
def todo_items(self) -> list[TodoItem] | None:
|
||||
"""Return the reminders in this list.
|
||||
|
||||
A subtask is an ordinary to-do item here, not a child of anything: the
|
||||
hierarchy only decides the order, so no entity model change is needed.
|
||||
"""
|
||||
if (reminder_list := (self.coordinator.data or {}).get(self._list_id)) is None:
|
||||
return None
|
||||
return [_as_todo_item(item) for item in _ordered(reminder_list.reminders)]
|
||||
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Add a reminder to the list."""
|
||||
await self._async_call(self._create, item)
|
||||
|
||||
@override
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update a reminder."""
|
||||
if item.uid is None:
|
||||
raise HomeAssistantError("Cannot update a reminder without an identifier")
|
||||
|
||||
await self._async_call(self._update, item)
|
||||
|
||||
@override
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete reminders from the list."""
|
||||
await self._async_call(self._delete, uids)
|
||||
|
||||
def _service(self) -> RemindersService:
|
||||
"""Return the reminders service. Runs in the executor."""
|
||||
return self.coordinator.reminders
|
||||
|
||||
def _create(self, item: TodoItem) -> None:
|
||||
"""Create a reminder. Runs in the executor."""
|
||||
due, all_day = _as_due(item.due)
|
||||
self._service().create(
|
||||
list_id=self._list_id,
|
||||
title=item.summary or "",
|
||||
desc=item.description or "",
|
||||
due_date=due,
|
||||
all_day=all_day,
|
||||
)
|
||||
|
||||
def _update(self, item: TodoItem) -> None:
|
||||
"""Apply an update. Runs in the executor."""
|
||||
service = self._service()
|
||||
reminder = service.get(item.uid)
|
||||
|
||||
if reminder.title == UNDECODED_TITLE:
|
||||
# Writing this back would destroy the reminder's real title and
|
||||
# notes, so refuse rather than silently corrupting the reminder.
|
||||
raise HomeAssistantError(
|
||||
"This reminder's title could not be decrypted, so it cannot be "
|
||||
"updated. Approve access on one of your Apple devices."
|
||||
)
|
||||
|
||||
if item.summary is not None:
|
||||
reminder.title = item.summary
|
||||
reminder.desc = item.description or ""
|
||||
reminder.due_date, reminder.all_day = _as_due(item.due)
|
||||
if item.status is not None:
|
||||
reminder.completed = item.status == TodoItemStatus.COMPLETED
|
||||
|
||||
service.update(reminder)
|
||||
|
||||
def _delete(self, uids: list[str]) -> None:
|
||||
"""Delete reminders. Runs in the executor."""
|
||||
service = self._service()
|
||||
for uid in uids:
|
||||
service.delete(service.get(uid))
|
||||
|
||||
async def _async_call(self, func, *args, **kwargs) -> None:
|
||||
"""Run a blocking call and refresh the list afterwards."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(lambda: func(*args, **kwargs))
|
||||
except REMINDERS_ERRORS as err:
|
||||
raise HomeAssistantError(f"Error updating reminders: {err}") from err
|
||||
finally:
|
||||
# A batch that failed part of the way through still changed things
|
||||
# in iCloud, so refresh either way rather than showing reminders
|
||||
# that are already gone until the next poll.
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
def _as_due(due: date | datetime | None) -> tuple[datetime | None, bool]:
|
||||
"""Return the due value as ``(datetime, all_day)``.
|
||||
|
||||
``TodoItem.due`` is a plain ``date`` for an all-day item, but pyicloud
|
||||
reads ``tzinfo`` and ``timestamp()`` off whatever it is given, so a date
|
||||
has to be widened before it reaches the library.
|
||||
"""
|
||||
if due is None:
|
||||
return None, False
|
||||
if isinstance(due, datetime):
|
||||
return due, False
|
||||
return datetime(due.year, due.month, due.day), True
|
||||
|
||||
|
||||
def _ordered(reminders: list[IcloudReminder]) -> list[IcloudReminder]:
|
||||
"""Return the reminders with each subtask placed after its parent.
|
||||
|
||||
Home Assistant renders a flat list in the order given, and iCloud does not
|
||||
guarantee that a subtask follows the reminder it belongs to, so putting it
|
||||
there keeps the list reading the way Reminders shows it on iOS. Nesting can
|
||||
be deeper than one level, so descendants are emitted recursively.
|
||||
"""
|
||||
uids = {reminder.uid for reminder in reminders}
|
||||
children: dict[str, list[IcloudReminder]] = {}
|
||||
roots: list[IcloudReminder] = []
|
||||
for reminder in reminders:
|
||||
# A parent outside this list is no anchor to sort against, so treat the
|
||||
# subtask as a root here rather than dropping it. Its parent may well
|
||||
# be a reminder in another list.
|
||||
if reminder.parent_uid is not None and reminder.parent_uid in uids:
|
||||
children.setdefault(reminder.parent_uid, []).append(reminder)
|
||||
else:
|
||||
roots.append(reminder)
|
||||
|
||||
ordered: list[IcloudReminder] = []
|
||||
|
||||
def _emit(reminder: IcloudReminder) -> None:
|
||||
ordered.append(reminder)
|
||||
for child in children.pop(reminder.uid, ()):
|
||||
_emit(child)
|
||||
|
||||
for reminder in roots:
|
||||
_emit(reminder)
|
||||
|
||||
# Anything still here is part of a parent cycle, which iCloud should never
|
||||
# return. Pop the whole group before emitting, so the loop terminates.
|
||||
while children:
|
||||
for orphan in children.pop(next(iter(children))):
|
||||
_emit(orphan)
|
||||
|
||||
return ordered
|
||||
|
||||
|
||||
def _as_todo_item(reminder: IcloudReminder) -> TodoItem:
|
||||
"""Convert a reminder into a to-do item."""
|
||||
return TodoItem(
|
||||
uid=reminder.uid,
|
||||
summary=reminder.summary,
|
||||
description=reminder.description,
|
||||
due=reminder.due,
|
||||
completed=reminder.completed_at if reminder.completed else None,
|
||||
status=(
|
||||
TodoItemStatus.COMPLETED
|
||||
if reminder.completed
|
||||
else TodoItemStatus.NEEDS_ACTION
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[todo.test_icloud_account_groceries-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'todo',
|
||||
'entity_category': None,
|
||||
'entity_id': 'todo.test_icloud_account_groceries',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Groceries',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Groceries',
|
||||
'platform': 'icloud',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <TodoListEntityFeature: 119>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'test_account_id_list1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[todo.test_icloud_account_groceries-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test iCloud Account Groceries',
|
||||
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <TodoListEntityFeature: 119>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'todo.test_icloud_account_groceries',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1',
|
||||
})
|
||||
# ---
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.calendar import DOMAIN as CALENDAR_DOMAIN
|
||||
from homeassistant.components.icloud.coordinator import SCAN_INTERVAL
|
||||
from homeassistant.components.icloud.coordinator import CALENDAR_SCAN_INTERVAL
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -123,14 +123,14 @@ async def test_event_in_progress_wins(
|
||||
_event(
|
||||
"ev1",
|
||||
"Now",
|
||||
now.replace(tzinfo=None) - SCAN_INTERVAL,
|
||||
now.replace(tzinfo=None) + SCAN_INTERVAL,
|
||||
now.replace(tzinfo=None) - CALENDAR_SCAN_INTERVAL,
|
||||
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL,
|
||||
),
|
||||
_event(
|
||||
"ev2",
|
||||
"Later",
|
||||
now.replace(tzinfo=None) + SCAN_INTERVAL * 2,
|
||||
now.replace(tzinfo=None) + SCAN_INTERVAL * 3,
|
||||
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL * 2,
|
||||
now.replace(tzinfo=None) + CALENDAR_SCAN_INTERVAL * 3,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -246,7 +246,7 @@ async def test_new_calendar_added_on_later_poll(
|
||||
_calendar("cal1", "Personal"),
|
||||
_calendar("cal2", "Work"),
|
||||
]
|
||||
freezer.tick(SCAN_INTERVAL + timedelta(seconds=1))
|
||||
freezer.tick(CALENDAR_SCAN_INTERVAL + timedelta(seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
# The scheduled refresh runs as a background task of the config entry.
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
"""Tests for the iCloud to-do platform."""
|
||||
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyicloud.services.reminders.client import RemindersApiError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.icloud.coordinator import (
|
||||
REMINDERS_SCAN_INTERVAL,
|
||||
UNDECODED_TITLE,
|
||||
)
|
||||
from homeassistant.components.todo import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_DUE_DATE,
|
||||
ATTR_DUE_DATETIME,
|
||||
ATTR_ITEM,
|
||||
ATTR_RENAME,
|
||||
ATTR_STATUS,
|
||||
DOMAIN as TODO_DOMAIN,
|
||||
TodoServices,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import (
|
||||
AsyncMock,
|
||||
MockConfigEntry,
|
||||
async_fire_time_changed,
|
||||
snapshot_platform,
|
||||
)
|
||||
|
||||
ENTITY_ID = "todo.test_icloud_account_groceries"
|
||||
|
||||
|
||||
def _reminder(
|
||||
uid: str,
|
||||
title: str,
|
||||
*,
|
||||
completed: bool = False,
|
||||
parent: str | None = None,
|
||||
due: datetime | None = None,
|
||||
desc: str = "",
|
||||
) -> MagicMock:
|
||||
"""Build a mock pyicloud reminder."""
|
||||
reminder = MagicMock()
|
||||
reminder.id = uid
|
||||
reminder.title = title
|
||||
reminder.desc = desc
|
||||
reminder.due_date = due
|
||||
reminder.all_day = False
|
||||
reminder.completed = completed
|
||||
reminder.completed_date = datetime(2024, 1, 2, 3, 4) if completed else None
|
||||
reminder.parent_reminder_id = parent
|
||||
reminder.deleted = False
|
||||
return reminder
|
||||
|
||||
|
||||
def _list(list_id: str, title: str, *, is_group: bool = False) -> MagicMock:
|
||||
"""Build a mock pyicloud reminder list."""
|
||||
reminder_list = MagicMock()
|
||||
reminder_list.id = list_id
|
||||
reminder_list.title = title
|
||||
reminder_list.is_group = is_group
|
||||
reminder_list.deleted = False
|
||||
return reminder_list
|
||||
|
||||
|
||||
@pytest.fixture(name="reminders")
|
||||
def mock_reminders(icloud_client: AsyncMock) -> MagicMock:
|
||||
"""Mock the reminders service with one list."""
|
||||
service = icloud_client.api.reminders
|
||||
service.lists.return_value = [_list("list1", "Groceries")]
|
||||
service.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Milk"),
|
||||
_reminder("r2", "Bread", completed=True),
|
||||
]
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
async def _setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the config entry with only the to-do platform loaded."""
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.icloud.PLATFORMS", [Platform.TODO]):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test that a reminder list becomes a to-do entity with its items."""
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_groups_skipped(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that reminder groups do not become entities.
|
||||
|
||||
A group and a list inside it can share a name, which would otherwise
|
||||
create two identically named entities, one of them always empty.
|
||||
"""
|
||||
reminders.lists.return_value = [
|
||||
_list("list1", "Groceries"),
|
||||
_list("list2", "Family", is_group=True),
|
||||
]
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
assert hass.states.get(ENTITY_ID) is not None
|
||||
assert hass.states.get("todo.test_icloud_account_family") is None
|
||||
|
||||
|
||||
async def test_subtasks_follow_their_parent(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a subtask is ordered directly after its parent."""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Child", parent="r2"),
|
||||
_reminder("r2", "Parent"),
|
||||
_reminder("r3", "Other"),
|
||||
]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
result = await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.GET_ITEMS,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
|
||||
assert summaries == ["Parent", "Child", "Other"]
|
||||
|
||||
|
||||
async def test_create_item(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test creating a reminder."""
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Eggs",
|
||||
ATTR_DUE_DATE: date(2024, 5, 1),
|
||||
ATTR_DESCRIPTION: "a dozen",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
reminders.create.assert_called_once_with(
|
||||
list_id="list1",
|
||||
title="Eggs",
|
||||
desc="a dozen",
|
||||
due_date=datetime(2024, 5, 1),
|
||||
all_day=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_update_item(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test renaming and completing a reminder."""
|
||||
existing = _reminder("r1", "Milk")
|
||||
reminders.get.return_value = existing
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Milk",
|
||||
ATTR_RENAME: "Oat milk",
|
||||
ATTR_STATUS: "completed",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert existing.title == "Oat milk"
|
||||
assert existing.completed is True
|
||||
reminders.update.assert_called_once_with(existing)
|
||||
|
||||
|
||||
async def test_delete_item(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test deleting a reminder."""
|
||||
existing = _reminder("r1", "Milk")
|
||||
reminders.get.return_value = existing
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
reminders.delete.assert_called_once_with(existing)
|
||||
|
||||
|
||||
async def test_partial_delete_still_refreshes(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a batch failing halfway still refreshes the list.
|
||||
|
||||
The reminders deleted before the failure are already gone in iCloud, so
|
||||
leaving them on screen until the next poll would be wrong.
|
||||
"""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[_reminder("r1", "Milk"), _reminder("r2", "Eggs")]
|
||||
)
|
||||
reminders.delete.side_effect = [None, RemindersApiError("boom")]
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
before = reminders.list_reminders.call_count
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: ["Milk", "Eggs"]},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert reminders.delete.call_count == 2
|
||||
assert reminders.list_reminders.call_count > before
|
||||
|
||||
|
||||
async def test_api_error_raises(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that an iCloud error surfaces as a Home Assistant error."""
|
||||
reminders.get.side_effect = RemindersApiError("boom")
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_new_list_added_on_later_poll(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that a list created after setup appears on a later refresh."""
|
||||
await _setup(hass, config_entry)
|
||||
assert hass.states.get("todo.test_icloud_account_travel") is None
|
||||
|
||||
reminders.lists.return_value = [
|
||||
_list("list1", "Groceries"),
|
||||
_list("list2", "Travel"),
|
||||
]
|
||||
freezer.tick(REMINDERS_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
# The scheduled refresh runs as a background task of the config entry.
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get("todo.test_icloud_account_travel") is not None
|
||||
|
||||
|
||||
async def test_create_item_with_date_due(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a date-only due value is widened to a datetime.
|
||||
|
||||
pyicloud reads tzinfo and timestamp() off the value it is given, so a
|
||||
plain date would raise AttributeError before reaching iCloud.
|
||||
"""
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Eggs",
|
||||
ATTR_DUE_DATE: date(2024, 5, 1),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert reminders.create.call_args.kwargs["due_date"] == datetime(2024, 5, 1)
|
||||
assert reminders.create.call_args.kwargs["all_day"] is True
|
||||
|
||||
|
||||
async def test_update_item_with_datetime_due(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a timed due value clears the all-day flag."""
|
||||
existing = _reminder("r1", "Milk")
|
||||
existing.all_day = True
|
||||
reminders.get.return_value = existing
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Milk",
|
||||
ATTR_DUE_DATETIME: datetime(2024, 5, 1, 9, 30),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert existing.all_day is False
|
||||
# Home Assistant hands the platform a timezone-aware value.
|
||||
assert existing.due_date.tzinfo is not None
|
||||
assert existing.due_date.replace(tzinfo=None) == datetime(2024, 5, 1, 9, 30)
|
||||
|
||||
|
||||
async def test_nested_subtasks_follow_their_parent(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a grandchild stays with its parent rather than sorting last."""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Grandchild", parent="r2"),
|
||||
_reminder("r2", "Child", parent="r3"),
|
||||
_reminder("r3", "Parent"),
|
||||
_reminder("r4", "Other"),
|
||||
]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
result = await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.GET_ITEMS,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
|
||||
assert summaries == ["Parent", "Child", "Grandchild", "Other"]
|
||||
|
||||
|
||||
async def test_orphaned_subtask_is_kept(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a subtask whose parent is absent is still listed.
|
||||
|
||||
The parent may live in another list, so the subtask has no anchor here.
|
||||
It must still appear, and must not spin the ordering loop.
|
||||
"""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Top level"),
|
||||
_reminder("r2", "Orphan", parent="missing"),
|
||||
_reminder("r3", "Orphan child", parent="r2"),
|
||||
]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
result = await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.GET_ITEMS,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
|
||||
assert summaries == ["Top level", "Orphan", "Orphan child"]
|
||||
|
||||
|
||||
async def test_orphan_parent_returned_after_its_child(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that an orphan still comes before its own child.
|
||||
|
||||
iCloud gives no ordering guarantee, so the child of a subtask whose parent
|
||||
is missing can arrive first. The subtask is the root of what is present.
|
||||
"""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Grandchild", parent="r2"),
|
||||
_reminder("r2", "Child", parent="missing"),
|
||||
]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
result = await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.GET_ITEMS,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
summaries = [item["summary"] for item in result[ENTITY_ID]["items"]]
|
||||
assert summaries == ["Child", "Grandchild"]
|
||||
|
||||
|
||||
async def test_update_refused_when_title_undecrypted(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MagicMock,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a reminder with an undecryptable title is not written back.
|
||||
|
||||
pyicloud's update() rewrites TitleDocument and NotesDocument
|
||||
unconditionally, so persisting the placeholder would destroy the real
|
||||
title and blank the notes.
|
||||
"""
|
||||
existing = _reminder("r1", UNDECODED_TITLE)
|
||||
reminders.get.return_value = existing
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="could not be decrypted"):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Milk",
|
||||
ATTR_STATUS: "completed",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
reminders.update.assert_not_called()
|
||||
|
||||
|
||||
async def test_missing_reminder_raises_service_error(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MagicMock,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a reminder deleted elsewhere surfaces as a service error."""
|
||||
reminders.get.side_effect = LookupError("Reminder not found: r1")
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Error updating reminders"):
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Milk"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_undecryptable_reminders_are_left_out(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MagicMock,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that reminders which cannot be decrypted are skipped.
|
||||
|
||||
On an Advanced Data Protection account pyicloud returns a placeholder
|
||||
title and no notes, which is worse than useless in a to-do list, and
|
||||
writing such a reminder back would destroy its real content.
|
||||
"""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[
|
||||
_reminder("r1", "Milk"),
|
||||
_reminder("r2", UNDECODED_TITLE),
|
||||
]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
items = await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.GET_ITEMS,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
summaries = [item["summary"] for item in items[ENTITY_ID]["items"]]
|
||||
assert summaries == ["Milk"]
|
||||
|
||||
|
||||
async def test_warns_once_when_titles_undecrypted(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MagicMock,
|
||||
reminders: MagicMock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that encrypted reminders are explained once, not on every poll."""
|
||||
reminders.list_reminders.return_value = MagicMock(
|
||||
reminders=[_reminder("r1", UNDECODED_TITLE)]
|
||||
)
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
assert "could not be decrypted" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
freezer.tick(REMINDERS_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
# The scheduled refresh runs as a background task of the config entry.
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert "could not be decrypted" not in caplog.text
|
||||
|
||||
|
||||
async def test_entry_loads_when_reminders_fail(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MagicMock,
|
||||
reminders: MagicMock,
|
||||
) -> None:
|
||||
"""Test that a Reminders outage does not stop the entry loading.
|
||||
|
||||
The coordinator is refreshed before the platforms are forwarded, but
|
||||
without raising: the rest of the integration, and the reauth flow for a
|
||||
failed login, must keep working regardless of Reminders.
|
||||
"""
|
||||
reminders.lists.side_effect = RemindersApiError("boom")
|
||||
|
||||
await _setup(hass, config_entry)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert hass.states.get(ENTITY_ID) is None
|
||||
Reference in New Issue
Block a user