Add Remote calendar integration (#138862)

* Add remote_calendar with storage

* Use coordinator and remove storage

* cleanup

* cleanup

* remove init from config_flow

* add some tests

* some fixes

* test-before-setup

* fix error handling

* remove unneeded code

* fix updates

* load calendar in the event loop

* allow redirects

* test_update_failed

* tests

* address review

* use error from local_calendar

* adress more comments

* remove unique_id

* add unique entity_id

* add excemption

* abort_entries_match

* unique_id

* add ,

* cleanup

* deduplicate call

* don't raise for status end de-nest

* multiline

* test

* tests

* use raise_for_status again

* use respx

* just use config_entry argument that already is defined

* Also assert on the config entry result title and data

* improve config_flow

* update quality scale

* address review

---------

Co-authored-by: Allen Porter <allen@thebends.org>
This commit is contained in:
Thomas55555 2025-03-16 02:57:45 +01:00 committed by GitHub
parent 91e0f1cb46
commit 4050c216ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1266 additions and 0 deletions

2
CODEOWNERS generated
View File

@ -1252,6 +1252,8 @@ build.json @home-assistant/supervisor
/tests/components/refoss/ @ashionky
/homeassistant/components/remote/ @home-assistant/core
/tests/components/remote/ @home-assistant/core
/homeassistant/components/remote_calendar/ @Thomas55555
/tests/components/remote_calendar/ @Thomas55555
/homeassistant/components/renault/ @epenet
/tests/components/renault/ @epenet
/homeassistant/components/renson/ @jimmyd-be

View File

@ -0,0 +1,33 @@
"""The Remote Calendar integration."""
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import RemoteCalendarConfigEntry, RemoteCalendarDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.CALENDAR]
async def async_setup_entry(
hass: HomeAssistant, entry: RemoteCalendarConfigEntry
) -> bool:
"""Set up Remote Calendar from a config entry."""
hass.data.setdefault(DOMAIN, {})
coordinator = RemoteCalendarDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: RemoteCalendarConfigEntry
) -> bool:
"""Handle unload of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,92 @@
"""Calendar platform for a Remote Calendar."""
from datetime import datetime
import logging
from ical.event import Event
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from . import RemoteCalendarConfigEntry
from .const import CONF_CALENDAR_NAME
from .coordinator import RemoteCalendarDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: RemoteCalendarConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the remote calendar platform."""
coordinator = entry.runtime_data
entity = RemoteCalendarEntity(coordinator, entry)
async_add_entities([entity])
class RemoteCalendarEntity(
CoordinatorEntity[RemoteCalendarDataUpdateCoordinator], CalendarEntity
):
"""A calendar entity backed by a remote iCalendar url."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: RemoteCalendarDataUpdateCoordinator,
entry: RemoteCalendarConfigEntry,
) -> None:
"""Initialize RemoteCalendarEntity."""
super().__init__(coordinator)
self._attr_name = entry.data[CONF_CALENDAR_NAME]
self._attr_unique_id = entry.entry_id
@property
def event(self) -> CalendarEvent | None:
"""Return the next upcoming event."""
now = dt_util.now()
events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
if event := next(events, None):
return _get_calendar_event(event)
return None
async def async_get_events(
self, hass: HomeAssistant, start_date: datetime, end_date: datetime
) -> list[CalendarEvent]:
"""Get all events in a specific time frame."""
events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping(
start_date,
end_date,
)
return [_get_calendar_event(event) for event in events]
def _get_calendar_event(event: Event) -> CalendarEvent:
"""Return a CalendarEvent from an API event."""
return CalendarEvent(
summary=event.summary,
start=(
dt_util.as_local(event.start)
if isinstance(event.start, datetime)
else event.start
),
end=(
dt_util.as_local(event.end)
if isinstance(event.end, datetime)
else event.end
),
description=event.description,
uid=event.uid,
rrule=event.rrule.as_rrule_str() if event.rrule else None,
recurrence_id=event.recurrence_id,
location=event.location,
)

View File

@ -0,0 +1,70 @@
"""Config flow for Remote Calendar integration."""
import logging
from typing import Any
from httpx import HTTPError, InvalidURL
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_CALENDAR_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_CALENDAR_NAME): str,
vol.Required(CONF_URL): str,
}
)
class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remote Calendar."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors: dict = {}
_LOGGER.debug("User input: %s", user_input)
self._async_abort_entries_match(
{CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]}
)
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
client = get_async_client(self.hass)
try:
res = await client.get(user_input[CONF_URL], follow_redirects=True)
res.raise_for_status()
except (HTTPError, InvalidURL) as err:
errors["base"] = "cannot_connect"
_LOGGER.debug("An error occurred: %s", err)
else:
try:
await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, res.text
)
except CalendarParseError as err:
errors["base"] = "invalid_ics_file"
_LOGGER.debug("Invalid .ics file: %s", err)
else:
return self.async_create_entry(
title=user_input[CONF_CALENDAR_NAME], data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

View File

@ -0,0 +1,4 @@
"""Constants for the Remote Calendar integration."""
DOMAIN = "remote_calendar"
CONF_CALENDAR_NAME = "calendar_name"

View File

@ -0,0 +1,67 @@
"""Data UpdateCoordinator for the Remote Calendar integration."""
from datetime import timedelta
import logging
from httpx import HTTPError, InvalidURL
from ical.calendar import Calendar
from ical.calendar_stream import IcsCalendarStream
from ical.exceptions import CalendarParseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(days=1)
class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
"""Class to manage fetching calendar data."""
config_entry: RemoteCalendarConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: RemoteCalendarConfigEntry,
) -> None:
"""Initialize data updater."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
always_update=True,
)
self._etag = None
self._client = get_async_client(hass)
self._url = config_entry.data[CONF_URL]
async def _async_update_data(self) -> Calendar:
"""Update data from the url."""
try:
res = await self._client.get(self._url, follow_redirects=True)
res.raise_for_status()
except (HTTPError, InvalidURL) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unable_to_fetch",
translation_placeholders={"err": str(err)},
) from err
try:
return await self.hass.async_add_executor_job(
IcsCalendarStream.calendar_from_ics, res.text
)
except CalendarParseError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unable_to_parse",
translation_placeholders={"err": str(err)},
) from err

View File

@ -0,0 +1,12 @@
{
"domain": "remote_calendar",
"name": "Remote Calendar",
"codeowners": ["@Thomas55555"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/remote_calendar",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==8.3.0"]
}

View File

@ -0,0 +1,100 @@
rules:
# Bronze
config-flow: done
test-before-configure: done
unique-config-entry:
status: exempt
comment: |
No unique identifier.
config-flow-test-coverage: done
runtime-data: done
test-before-setup: done
appropriate-polling: done
entity-unique-id: done
has-entity-name: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
dependency-transparency: done
action-setup:
status: exempt
comment: |
There are no actions.
common-modules: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-actions:
status: exempt
comment: No actions available.
brands: done
# Silver
config-entry-unloading: done
log-when-unavailable: done
entity-unavailable: done
action-exceptions:
status: exempt
comment: |
There are no actions.
reauthentication-flow:
status: exempt
comment: |
There is no authentication required.
parallel-updates: done
test-coverage: done
integration-owner: done
docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: no configuration options
# Gold
devices:
status: exempt
comment: No devices. One URL is always assigned to one calendar.
diagnostics:
status: todo
comment: Diagnostics not implemented, yet.
discovery-update-info:
status: todo
comment: No discovery protocol available.
discovery:
status: exempt
comment: No discovery protocol available.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: No devices. One URL is always assigned to one calendar.
entity-category: done
entity-device-class:
status: exempt
comment: No devices classes for calendars.
entity-disabled-by-default:
status: exempt
comment: Only one entity per entry.
entity-translations:
status: exempt
comment: Entity name is defined by the user, so no translation possible.
exception-translations: done
icon-translations:
status: exempt
comment: Only the default icon is used.
reconfiguration-flow:
status: exempt
comment: no configuration possible
repair-issues: todo
stale-devices:
status: exempt
comment: No devices. One URL is always assigned to one calendar.
# Platinum
async-dependency: todo
inject-websession: done
strict-typing: todo

View File

@ -0,0 +1,33 @@
{
"title": "Remote Calendar",
"config": {
"step": {
"user": {
"description": "Please choose a name for the calendar to be imported",
"data": {
"calendar_name": "Calendar Name",
"url": "Calendar URL"
},
"data_description": {
"calendar_name": "The name of the calendar shown in th UI.",
"url": "The URL of the remote calendar."
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_ics_file": "[%key:component::local_calendar::config::error::invalid_ics_file%]"
}
},
"exceptions": {
"unable_to_fetch": {
"message": "Unable to fetch calendar data: {err}"
},
"unable_to_parse": {
"message": "Unable to parse calendar data: {err}"
}
}
}

View File

@ -513,6 +513,7 @@ FLOWS = {
"rdw",
"recollect_waste",
"refoss",
"remote_calendar",
"renault",
"renson",
"reolink",

View File

@ -5265,6 +5265,11 @@
"config_flow": false,
"iot_class": "cloud_push"
},
"remote_calendar": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"renault": {
"name": "Renault",
"integration_type": "hub",
@ -7690,6 +7695,7 @@
"plant",
"proximity",
"random",
"remote_calendar",
"rpi_power",
"schedule",
"season",

1
requirements_all.txt generated
View File

@ -1193,6 +1193,7 @@ ibmiotf==0.3.4
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==8.3.0
# homeassistant.components.caldav

View File

@ -1010,6 +1010,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==8.3.0
# homeassistant.components.caldav

View File

@ -40,6 +40,7 @@ ALLOW_NAME_TRANSLATION = {
"local_ip",
"local_todo",
"nmap_tracker",
"remote_calendar",
"rpi_power",
"swiss_public_transport",
"waze_travel_time",

View File

@ -0,0 +1,11 @@
"""Tests for the Remote Calendar integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)

View File

@ -0,0 +1,89 @@
"""Fixtures for Remote Calendar."""
from collections.abc import Awaitable, Callable
from http import HTTPStatus
import textwrap
from typing import Any
import urllib
import pytest
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.typing import ClientSessionGenerator
CALENDAR_NAME = "Home Assistant Events"
TEST_ENTITY = "calendar.home_assistant_events"
CALENDER_URL = "https://some.calendar.com/calendar.ics"
FRIENDLY_NAME = "Home Assistant Events"
@pytest.fixture(name="time_zone")
def mock_time_zone() -> str:
"""Fixture for time zone to use in tests."""
# Set our timezone to CST/Regina so we can check calculations
# This keeps UTC-6 all year round
return "America/Regina"
@pytest.fixture(autouse=True)
async def set_time_zone(hass: HomeAssistant, time_zone: str):
"""Set the time zone for the tests."""
# Set our timezone to CST/Regina so we can check calculations
# This keeps UTC-6 all year round
await hass.config.async_set_time_zone(time_zone)
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Fixture for mock configuration entry."""
return MockConfigEntry(
domain=DOMAIN, data={CONF_CALENDAR_NAME: CALENDAR_NAME, CONF_URL: CALENDER_URL}
)
type GetEventsFn = Callable[[str, str], Awaitable[list[dict[str, Any]]]]
@pytest.fixture(name="get_events")
def get_events_fixture(hass_client: ClientSessionGenerator) -> GetEventsFn:
"""Fetch calendar events from the HTTP API."""
async def _fetch(start: str, end: str) -> list[dict[str, Any]]:
client = await hass_client()
response = await client.get(
f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}"
)
assert response.status == HTTPStatus.OK
return await response.json()
return _fetch
def event_fields(data: dict[str, str]) -> dict[str, str]:
"""Filter event API response to minimum fields."""
return {
k: data[k]
for k in ("summary", "start", "end", "recurrence_id", "location")
if data.get(k)
}
@pytest.fixture(name="ics_content")
def mock_ics_content(request: pytest.FixtureRequest) -> str:
"""Fixture to allow tests to set initial ics content for the calendar store."""
default_content = textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714T170000Z
DTEND:19970715T040000Z
END:VEVENT
END:VCALENDAR
"""
)
return request.param if hasattr(request, "param") else default_content

View File

@ -0,0 +1,394 @@
"""Tests for calendar platform of Remote Calendar."""
from datetime import datetime
import textwrap
from httpx import Response
import pytest
import respx
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from . import setup_integration
from .conftest import (
CALENDER_URL,
FRIENDLY_NAME,
TEST_ENTITY,
GetEventsFn,
event_fields,
)
from tests.common import MockConfigEntry
@respx.mock
async def test_empty_calendar(
hass: HomeAssistant,
config_entry: MockConfigEntry,
get_events: GetEventsFn,
) -> None:
"""Test querying the API and fetching events."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//hacksw/handcal//NONSGML v1.0//EN
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
events = await get_events("1997-07-14T00:00:00", "1997-07-16T00:00:00")
assert len(events) == 0
state = hass.states.get(TEST_ENTITY)
assert state
assert state.name == FRIENDLY_NAME
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": FRIENDLY_NAME,
}
@pytest.mark.parametrize(
"ics_content",
[
textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART;TZID=Europe/Berlin:19970714T190000
DTEND;TZID=Europe/Berlin:19970715T060000
END:VEVENT
END:VCALENDAR
"""
),
textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART:19970714T170000Z
DTEND:19970715T040000Z
END:VEVENT
END:VCALENDAR
"""
),
textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART;TZID=America/Regina:19970714T110000
DTEND;TZID=America/Regina:19970714T220000
END:VEVENT
END:VCALENDAR
"""
),
textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Bastille Day Party
DTSTART;TZID=America/Los_Angeles:19970714T100000
DTEND;TZID=America/Los_Angeles:19970714T210000
END:VEVENT
END:VCALENDAR
"""
),
],
)
@respx.mock
async def test_api_date_time_event(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
ics_content: str,
) -> None:
"""Test an event with a start/end date time."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
await setup_integration(hass, config_entry)
events = await get_events("1997-07-14T00:00:00Z", "1997-07-16T00:00:00Z")
assert list(map(event_fields, events)) == [
{
"summary": "Bastille Day Party",
"start": {"dateTime": "1997-07-14T11:00:00-06:00"},
"end": {"dateTime": "1997-07-14T22:00:00-06:00"},
}
]
# Query events in UTC
# Time range before event
events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T16:00:00Z")
assert len(events) == 0
# Time range after event
events = await get_events("1997-07-15T05:00:00Z", "1997-07-15T06:00:00Z")
assert len(events) == 0
# Overlap with event start
events = await get_events("1997-07-13T00:00:00Z", "1997-07-14T18:00:00Z")
assert len(events) == 1
# Overlap with event end
events = await get_events("1997-07-15T03:00:00Z", "1997-07-15T06:00:00Z")
assert len(events) == 1
# Query events overlapping with start and end but in another timezone
events = await get_events("1997-07-12T23:00:00-01:00", "1997-07-14T17:00:00-01:00")
assert len(events) == 1
events = await get_events("1997-07-15T02:00:00-01:00", "1997-07-15T05:00:00-01:00")
assert len(events) == 1
@respx.mock
async def test_api_date_event(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test an event with a start/end date all day event."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Festival International de Jazz de Montreal
DTSTART:20070628
DTEND:20070709
END:VEVENT
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
events = await get_events("2007-06-20T00:00:00", "2007-07-20T00:00:00")
assert list(map(event_fields, events)) == [
{
"summary": "Festival International de Jazz de Montreal",
"start": {"date": "2007-06-28"},
"end": {"date": "2007-07-09"},
}
]
# Time range before event (timezone is -6)
events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T01:00:00Z")
assert len(events) == 0
# Time range after event
events = await get_events("2007-07-10T00:00:00Z", "2007-07-11T00:00:00Z")
assert len(events) == 0
# Overlap with event start (timezone is -6)
events = await get_events("2007-06-26T00:00:00Z", "2007-06-28T08:00:00Z")
assert len(events) == 1
# Overlap with event end
events = await get_events("2007-07-09T00:00:00Z", "2007-07-11T00:00:00Z")
assert len(events) == 1
@pytest.mark.freeze_time(datetime(2007, 6, 28, 12))
@respx.mock
async def test_active_event(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test an event with a start/end date time."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Festival International de Jazz de Montreal
LOCATION:Montreal
DTSTART:20070628
DTEND:20070709
END:VEVENT
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.name == FRIENDLY_NAME
assert state.state == STATE_ON
assert dict(state.attributes) == {
"friendly_name": FRIENDLY_NAME,
"message": "Festival International de Jazz de Montreal",
"all_day": True,
"description": "",
"location": "Montreal",
"start_time": "2007-06-28 00:00:00",
"end_time": "2007-07-09 00:00:00",
}
@pytest.mark.freeze_time(datetime(2007, 6, 27, 12))
@respx.mock
async def test_upcoming_event(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test an event with a start/end date time."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""\
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Festival International de Jazz de Montreal
LOCATION:Montreal
DTSTART:20070628
DTEND:20070709
END:VEVENT
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.name == FRIENDLY_NAME
assert state.state == STATE_OFF
assert dict(state.attributes) == {
"friendly_name": FRIENDLY_NAME,
"message": "Festival International de Jazz de Montreal",
"all_day": True,
"description": "",
"location": "Montreal",
"start_time": "2007-06-28 00:00:00",
"end_time": "2007-07-09 00:00:00",
}
@respx.mock
async def test_recurring_event(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test an event with a recurrence rule."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20220829T090000
DTEND:20220829T100000
SUMMARY:Monday meeting
RRULE:FREQ=WEEKLY;BYDAY=MO
END:VEVENT
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
events = await get_events("2022-08-20T00:00:00", "2022-09-20T00:00:00")
assert list(map(event_fields, events)) == [
{
"summary": "Monday meeting",
"start": {"dateTime": "2022-08-29T09:00:00-06:00"},
"end": {"dateTime": "2022-08-29T10:00:00-06:00"},
"recurrence_id": "20220829T090000",
},
{
"summary": "Monday meeting",
"start": {"dateTime": "2022-09-05T09:00:00-06:00"},
"end": {"dateTime": "2022-09-05T10:00:00-06:00"},
"recurrence_id": "20220905T090000",
},
{
"summary": "Monday meeting",
"start": {"dateTime": "2022-09-12T09:00:00-06:00"},
"end": {"dateTime": "2022-09-12T10:00:00-06:00"},
"recurrence_id": "20220912T090000",
},
{
"summary": "Monday meeting",
"start": {"dateTime": "2022-09-19T09:00:00-06:00"},
"end": {"dateTime": "2022-09-19T10:00:00-06:00"},
"recurrence_id": "20220919T090000",
},
]
@respx.mock
@pytest.mark.parametrize(
("time_zone", "event_order"),
[
("America/Los_Angeles", ["One", "Two", "All Day Event"]),
("America/Regina", ["One", "Two", "All Day Event"]),
("UTC", ["One", "All Day Event", "Two"]),
("Asia/Tokyo", ["All Day Event", "One", "Two"]),
],
)
async def test_all_day_iter_order(
get_events: GetEventsFn,
hass: HomeAssistant,
config_entry: MockConfigEntry,
event_order: list[str],
) -> None:
"""Test the sort order of an all day events depending on the time zone."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=textwrap.dedent(
"""\
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20221008
DTEND:20221009
SUMMARY:All Day Event
END:VEVENT
BEGIN:VEVENT
DTSTART:20221007T230000Z
DTEND:20221008T233000Z
SUMMARY:One
END:VEVENT
BEGIN:VEVENT
DTSTART:20221008T010000Z
DTEND:20221008T020000Z
SUMMARY:Two
END:VEVENT
END:VCALENDAR
"""
),
)
)
await setup_integration(hass, config_entry)
events = await get_events("2022-10-06T00:00:00Z", "2022-10-09T00:00:00Z")
assert [event["summary"] for event in events] == event_order

View File

@ -0,0 +1,276 @@
"""Test the Remote Calendar config flow."""
from httpx import ConnectError, Response, UnsupportedProtocol
import pytest
import respx
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from .conftest import CALENDAR_NAME, CALENDER_URL
from tests.common import MockConfigEntry
@respx.mock
async def test_form_import_ics(hass: HomeAssistant, ics_content: str) -> None:
"""Test we get the import form."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == CALENDAR_NAME
assert result2["data"] == {
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
}
@pytest.mark.parametrize(
("side_effect"),
[
ConnectError("Connection failed"),
UnsupportedProtocol("Unsupported protocol"),
],
)
@respx.mock
async def test_form_inavild_url(
hass: HomeAssistant,
side_effect: Exception,
ics_content: str,
) -> None:
"""Test we get the import form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
respx.get("invalid-url.com").mock(side_effect=side_effect)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: "invalid-url.com",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == CALENDAR_NAME
assert result3["data"] == {
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
}
@pytest.mark.parametrize(
("url", "log_message"),
[
(
"unsupported://protocol.com", # Test for httpx.UnsupportedProtocol
"Request URL has an unsupported protocol 'unsupported://'",
),
(
"invalid-url", # Test for httpx.ProtocolError
"Request URL is missing an 'http://' or 'https://' protocol",
),
(
"https://example.com:abc/", # Test for httpx.InvalidURL
"Invalid port: 'abc'",
),
],
)
async def test_unsupported_inputs(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, url: str, log_message: str
) -> None:
"""Test that an unsupported inputs results in a form error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: url,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
assert log_message in caplog.text
## It's not possible to test a successful config flow because, we need to mock httpx.get here
## and then the exception isn't raised anymore.
@respx.mock
async def test_form_http_status_error(hass: HomeAssistant, ics_content: str) -> None:
"""Test we http status."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=403,
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == CALENDAR_NAME
assert result3["data"] == {
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
}
@respx.mock
async def test_no_valid_calendar(hass: HomeAssistant, ics_content: str) -> None:
"""Test invalid ics content."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text="blabla",
)
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_ics_file"}
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == CALENDAR_NAME
assert result3["data"] == {
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: CALENDER_URL,
}
async def test_duplicate_name(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test two calendars cannot be added with the same name."""
await setup_integration(hass, config_entry)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result.get("errors")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: CALENDAR_NAME,
CONF_URL: "http://other-calendar.com",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
async def test_duplicate_url(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test two calendars cannot be added with the same url."""
await setup_integration(hass, config_entry)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result.get("errors")
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_CALENDAR_NAME: "new name",
CONF_URL: CALENDER_URL,
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"

View File

@ -0,0 +1,73 @@
"""Tests for init platform of Remote Calendar."""
from httpx import ConnectError, Response, UnsupportedProtocol
import pytest
import respx
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from . import setup_integration
from .conftest import CALENDER_URL, TEST_ENTITY
from tests.common import MockConfigEntry
@respx.mock
async def test_load_unload(
hass: HomeAssistant, config_entry: MockConfigEntry, ics_content: str
) -> None:
"""Test loading and unloading a config entry."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=200,
text=ics_content,
)
)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == STATE_OFF
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
@respx.mock
async def test_raise_for_status(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test update failed using respx to simulate HTTP exceptions."""
respx.get(CALENDER_URL).mock(
return_value=Response(
status_code=403,
)
)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.parametrize(
"side_effect",
[
ConnectError("Connection failed"),
UnsupportedProtocol("Unsupported protocol"),
ValueError("Invalid response"),
],
)
@respx.mock
async def test_update_failed(
hass: HomeAssistant,
config_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""Test update failed using respx to simulate different exceptions."""
respx.get(CALENDER_URL).mock(side_effect=side_effect)
await setup_integration(hass, config_entry)
assert config_entry.state is ConfigEntryState.SETUP_RETRY