diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 7699b0693790..a72b2468bf5c 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -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) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index a760e6c32e3c..0433ca936c21 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -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 diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index f651b41b258f..a7f6fbcc9537 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -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" diff --git a/homeassistant/components/icloud/coordinator.py b/homeassistant/components/icloud/coordinator.py index 1f4d11dff3ae..18055beeed23 100644 --- a/homeassistant/components/icloud/coordinator.py +++ b/homeassistant/components/icloud/coordinator.py @@ -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, + ) diff --git a/homeassistant/components/icloud/todo.py b/homeassistant/components/icloud/todo.py new file mode 100644 index 000000000000..00b2d0266999 --- /dev/null +++ b/homeassistant/components/icloud/todo.py @@ -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 + ), + ) diff --git a/tests/components/icloud/snapshots/test_todo.ambr b/tests/components/icloud/snapshots/test_todo.ambr new file mode 100644 index 000000000000..7fa832bde247 --- /dev/null +++ b/tests/components/icloud/snapshots/test_todo.ambr @@ -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': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + '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': , + '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({ + : 'Test iCloud Account Groceries', + : , + }), + 'context': , + 'entity_id': 'todo.test_icloud_account_groceries', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- diff --git a/tests/components/icloud/test_calendar.py b/tests/components/icloud/test_calendar.py index baf97f35a22f..acefa0546610 100644 --- a/tests/components/icloud/test_calendar.py +++ b/tests/components/icloud/test_calendar.py @@ -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) diff --git a/tests/components/icloud/test_todo.py b/tests/components/icloud/test_todo.py new file mode 100644 index 000000000000..0b7fde39bd15 --- /dev/null +++ b/tests/components/icloud/test_todo.py @@ -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