Add calendar platform to Apple iCloud (#180735)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Federico Zivolo
2026-08-30 17:14:38 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 9145c009f8
commit 700e9da2ee
7 changed files with 861 additions and 1 deletions
@@ -0,0 +1,126 @@
# serializer version: 1
# name: test_all_day_event_end_is_exclusive
dict({
'calendar.test_icloud_account_personal': dict({
'events': list([
dict({
'end': '2024-05-02',
'start': '2024-05-01',
'summary': 'Holiday',
}),
]),
}),
})
# ---
# name: test_datetime_dates_are_accepted
dict({
'calendar.test_icloud_account_personal': dict({
'events': list([
dict({
'end': '2024-05-01T11:00:00+02:00',
'start': '2024-05-01T10:00:00+02:00',
'summary': 'Naive',
}),
dict({
'end': '2024-05-01T11:00:00+00:00',
'start': '2024-05-01T10:00:00+00:00',
'summary': 'Aware',
}),
]),
}),
})
# ---
# name: test_entities[calendar.test_icloud_account_personal-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': 'calendar',
'entity_category': None,
'entity_id': 'calendar.test_icloud_account_personal',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Personal',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Personal',
'platform': 'icloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'test_account_id_cal1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[calendar.test_icloud_account_personal-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Test iCloud Account Personal',
}),
'context': <ANY>,
'entity_id': 'calendar.test_icloud_account_personal',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_event_uses_its_own_timezone
dict({
'calendar.test_icloud_account_personal': dict({
'events': list([
dict({
'end': '2024-05-01T11:00:00+02:00',
'start': '2024-05-01T10:00:00+02:00',
'summary': 'Meeting',
}),
]),
}),
})
# ---
# name: test_get_events_filters_to_the_requested_window
dict({
'calendar.test_icloud_account_personal': dict({
'events': list([
dict({
'end': '2024-05-01T20:00:00-07:00',
'start': '2024-05-01T07:00:00-07:00',
'summary': 'Spanning the window',
}),
dict({
'end': '2024-05-01T10:30:00-07:00',
'start': '2024-05-01T09:30:00-07:00',
'summary': 'Overlapping the start',
}),
]),
}),
})
# ---
# name: test_unknown_event_timezone_falls_back
dict({
'calendar.test_icloud_account_personal': dict({
'events': list([
dict({
'end': '2024-05-01T11:00:00-07:00',
'start': '2024-05-01T10:00:00-07:00',
'summary': 'Meeting',
}),
]),
}),
})
# ---
+402
View File
@@ -0,0 +1,402 @@
"""Tests for the iCloud calendar platform."""
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pyicloud.exceptions import PyiCloudException
from pyicloud.services.calendar import CalendarObject, EventObject
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.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 homeassistant.util import dt as dt_util
from tests.common import (
AsyncMock,
MockConfigEntry,
async_fire_time_changed,
snapshot_platform,
)
ENTITY_ID = "calendar.test_icloud_account_personal"
def _apple_date(value: datetime) -> list[int]:
"""Return a datetime in the wire format pyicloud passes through."""
return [
int(value.strftime("%Y%m%d")),
value.year,
value.month,
value.day,
value.hour,
value.minute,
value.hour * 60 + value.minute,
]
def _event(
guid: str,
title: str,
start: datetime,
end: datetime,
*,
pguid: str = "cal1",
all_day: bool = False,
location: str = "",
tz: str = "Floating",
) -> MagicMock:
"""Build a mock pyicloud event."""
event = MagicMock(spec=EventObject)
event.guid = guid
event.pguid = pguid
event.title = title
event.all_day = all_day
event.location = location
event.tz = tz
event.local_start_date = _apple_date(start)
event.local_end_date = _apple_date(end)
event.start_date = event.local_start_date
event.end_date = event.local_end_date
return event
def _calendar(guid: str, title: str) -> MagicMock:
"""Build a mock pyicloud calendar."""
calendar = MagicMock(spec=CalendarObject)
calendar.guid = guid
calendar.title = title
return calendar
@pytest.fixture(name="calendars")
def mock_calendars(icloud_client: AsyncMock) -> MagicMock:
"""Mock the calendar service with one calendar and one event."""
service = icloud_client.api.calendar
service.get_calendars.return_value = [_calendar("cal1", "Personal")]
service.get_events.return_value = [
_event(
"ev1",
"Dentist",
datetime(2024, 5, 1, 10, 0),
datetime(2024, 5, 1, 11, 0),
)
]
return service
async def _setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the config entry with only the calendar platform loaded."""
config_entry.add_to_hass(hass)
with patch("homeassistant.components.icloud.PLATFORMS", [Platform.CALENDAR]):
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,
calendars: MagicMock,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test that a calendar becomes an entity."""
await _setup(hass, config_entry)
await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id)
async def test_event_in_progress_wins(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a running event is preferred over a later one."""
now = dt_util.now()
calendars.get_events.return_value = [
_event(
"ev1",
"Now",
now.replace(tzinfo=None) - SCAN_INTERVAL,
now.replace(tzinfo=None) + SCAN_INTERVAL,
),
_event(
"ev2",
"Later",
now.replace(tzinfo=None) + SCAN_INTERVAL * 2,
now.replace(tzinfo=None) + SCAN_INTERVAL * 3,
),
]
await _setup(hass, config_entry)
state = hass.states.get(ENTITY_ID)
assert state is not None
assert state.attributes["message"] == "Now"
async def test_all_day_event_end_is_exclusive(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that a single all-day event ends on the following day.
iCloud reports the same day for start and end, but Home Assistant treats
the end of an all-day event as exclusive.
"""
calendars.get_events.return_value = [
_event(
"ev1",
"Holiday",
datetime(2024, 5, 1),
datetime(2024, 5, 1),
all_day=True,
)
]
await _setup(hass, config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 4, 30),
"end_date_time": datetime(2024, 5, 3),
},
blocking=True,
return_response=True,
)
assert events == snapshot
async def test_get_events_filters_to_the_requested_window(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that events outside the requested range are dropped.
pyicloud sends both bounds as plain dates, so iCloud answers a one-hour
request with everything on the boundary days.
"""
calendars.get_events.return_value = [
_event(
"ev1",
"Before",
datetime(2024, 5, 1, 8, 0),
datetime(2024, 5, 1, 9, 0),
),
_event(
"ev2",
"Overlapping the start",
datetime(2024, 5, 1, 9, 30),
datetime(2024, 5, 1, 10, 30),
),
_event(
"ev3",
"Spanning the window",
datetime(2024, 5, 1, 7, 0),
datetime(2024, 5, 1, 20, 0),
),
_event(
"ev4",
"After",
datetime(2024, 5, 1, 14, 0),
datetime(2024, 5, 1, 15, 0),
),
]
await _setup(hass, config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 5, 1, 10, 0),
"end_date_time": datetime(2024, 5, 1, 11, 0),
},
blocking=True,
return_response=True,
)
assert events == snapshot
async def test_new_calendar_added_on_later_poll(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a calendar created after setup appears on a later refresh."""
await _setup(hass, config_entry)
assert hass.states.get("calendar.test_icloud_account_work") is None
calendars.get_calendars.return_value = [
_calendar("cal1", "Personal"),
_calendar("cal2", "Work"),
]
freezer.tick(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)
assert hass.states.get("calendar.test_icloud_account_work") is not None
async def test_get_events_error_raises(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
) -> None:
"""Test that an iCloud error surfaces as a Home Assistant error."""
await _setup(hass, config_entry)
calendars.get_events.side_effect = PyiCloudException("boom")
with pytest.raises(HomeAssistantError, match="Error fetching events"):
await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 4, 30),
"end_date_time": datetime(2024, 5, 3),
},
blocking=True,
return_response=True,
)
async def test_event_uses_its_own_timezone(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that an event in another timezone keeps its own instant.
iCloud reports naive wall-clock times alongside a `tz` field, so assuming
Home Assistant's timezone would place the event at the wrong instant.
"""
calendars.get_events.return_value = [
_event(
"ev1",
"Meeting",
datetime(2024, 5, 1, 10, 0),
datetime(2024, 5, 1, 11, 0),
tz="Europe/Rome",
)
]
await _setup(hass, config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 4, 30),
"end_date_time": datetime(2024, 5, 3),
},
blocking=True,
return_response=True,
)
assert events == snapshot
async def test_datetime_dates_are_accepted(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that real datetimes are accepted alongside the wire format.
pyicloud annotates `EventObject` as holding `datetime` but passes Apple's
wire format through unchanged, so both forms are handled. A naive datetime
is a wall-clock time in the event's own timezone, while an aware one
already names its instant and must keep it.
"""
naive = _event(
"ev1",
"Naive",
datetime(2024, 5, 1, 10, 0),
datetime(2024, 5, 1, 11, 0),
tz="Europe/Rome",
)
naive.local_start_date = datetime(2024, 5, 1, 10, 0)
naive.local_end_date = datetime(2024, 5, 1, 11, 0)
aware = _event(
"ev2",
"Aware",
datetime(2024, 5, 1, 10, 0),
datetime(2024, 5, 1, 11, 0),
tz="Europe/Rome",
)
aware.local_start_date = datetime(2024, 5, 1, 10, 0, tzinfo=dt_util.UTC)
aware.local_end_date = datetime(2024, 5, 1, 11, 0, tzinfo=dt_util.UTC)
calendars.get_events.return_value = [naive, aware]
await _setup(hass, config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 4, 30),
"end_date_time": datetime(2024, 5, 3),
},
blocking=True,
return_response=True,
)
assert events == snapshot
async def test_unknown_event_timezone_falls_back(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendars: MagicMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test that a malformed timezone falls back to Home Assistant's own.
The event has to survive the fetch rather than be dropped, and its
wall-clock time is read in the default timezone.
"""
calendars.get_events.return_value = [
_event(
"ev1",
"Meeting",
datetime(2024, 5, 1, 10, 0),
datetime(2024, 5, 1, 11, 0),
tz="Not/AZone",
)
]
await _setup(hass, config_entry)
events = await hass.services.async_call(
CALENDAR_DOMAIN,
"get_events",
{
ATTR_ENTITY_ID: ENTITY_ID,
"start_date_time": datetime(2024, 4, 30),
"end_date_time": datetime(2024, 5, 3),
},
blocking=True,
return_response=True,
)
assert events == snapshot