mirror of
https://github.com/home-assistant/core.git
synced 2025-04-27 18:57:57 +00:00
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:
parent
91e0f1cb46
commit
4050c216ed
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@ -1252,6 +1252,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/refoss/ @ashionky
|
/tests/components/refoss/ @ashionky
|
||||||
/homeassistant/components/remote/ @home-assistant/core
|
/homeassistant/components/remote/ @home-assistant/core
|
||||||
/tests/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
|
/homeassistant/components/renault/ @epenet
|
||||||
/tests/components/renault/ @epenet
|
/tests/components/renault/ @epenet
|
||||||
/homeassistant/components/renson/ @jimmyd-be
|
/homeassistant/components/renson/ @jimmyd-be
|
||||||
|
33
homeassistant/components/remote_calendar/__init__.py
Normal file
33
homeassistant/components/remote_calendar/__init__.py
Normal 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)
|
92
homeassistant/components/remote_calendar/calendar.py
Normal file
92
homeassistant/components/remote_calendar/calendar.py
Normal 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,
|
||||||
|
)
|
70
homeassistant/components/remote_calendar/config_flow.py
Normal file
70
homeassistant/components/remote_calendar/config_flow.py
Normal 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,
|
||||||
|
)
|
4
homeassistant/components/remote_calendar/const.py
Normal file
4
homeassistant/components/remote_calendar/const.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Constants for the Remote Calendar integration."""
|
||||||
|
|
||||||
|
DOMAIN = "remote_calendar"
|
||||||
|
CONF_CALENDAR_NAME = "calendar_name"
|
67
homeassistant/components/remote_calendar/coordinator.py
Normal file
67
homeassistant/components/remote_calendar/coordinator.py
Normal 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
|
12
homeassistant/components/remote_calendar/manifest.json
Normal file
12
homeassistant/components/remote_calendar/manifest.json
Normal 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"]
|
||||||
|
}
|
100
homeassistant/components/remote_calendar/quality_scale.yaml
Normal file
100
homeassistant/components/remote_calendar/quality_scale.yaml
Normal 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
|
33
homeassistant/components/remote_calendar/strings.json
Normal file
33
homeassistant/components/remote_calendar/strings.json
Normal 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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@ -513,6 +513,7 @@ FLOWS = {
|
|||||||
"rdw",
|
"rdw",
|
||||||
"recollect_waste",
|
"recollect_waste",
|
||||||
"refoss",
|
"refoss",
|
||||||
|
"remote_calendar",
|
||||||
"renault",
|
"renault",
|
||||||
"renson",
|
"renson",
|
||||||
"reolink",
|
"reolink",
|
||||||
|
@ -5265,6 +5265,11 @@
|
|||||||
"config_flow": false,
|
"config_flow": false,
|
||||||
"iot_class": "cloud_push"
|
"iot_class": "cloud_push"
|
||||||
},
|
},
|
||||||
|
"remote_calendar": {
|
||||||
|
"integration_type": "service",
|
||||||
|
"config_flow": true,
|
||||||
|
"iot_class": "cloud_polling"
|
||||||
|
},
|
||||||
"renault": {
|
"renault": {
|
||||||
"name": "Renault",
|
"name": "Renault",
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
@ -7690,6 +7695,7 @@
|
|||||||
"plant",
|
"plant",
|
||||||
"proximity",
|
"proximity",
|
||||||
"random",
|
"random",
|
||||||
|
"remote_calendar",
|
||||||
"rpi_power",
|
"rpi_power",
|
||||||
"schedule",
|
"schedule",
|
||||||
"season",
|
"season",
|
||||||
|
1
requirements_all.txt
generated
1
requirements_all.txt
generated
@ -1193,6 +1193,7 @@ ibmiotf==0.3.4
|
|||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
# homeassistant.components.local_calendar
|
# homeassistant.components.local_calendar
|
||||||
# homeassistant.components.local_todo
|
# homeassistant.components.local_todo
|
||||||
|
# homeassistant.components.remote_calendar
|
||||||
ical==8.3.0
|
ical==8.3.0
|
||||||
|
|
||||||
# homeassistant.components.caldav
|
# homeassistant.components.caldav
|
||||||
|
1
requirements_test_all.txt
generated
1
requirements_test_all.txt
generated
@ -1010,6 +1010,7 @@ ibeacon-ble==1.2.0
|
|||||||
# homeassistant.components.google
|
# homeassistant.components.google
|
||||||
# homeassistant.components.local_calendar
|
# homeassistant.components.local_calendar
|
||||||
# homeassistant.components.local_todo
|
# homeassistant.components.local_todo
|
||||||
|
# homeassistant.components.remote_calendar
|
||||||
ical==8.3.0
|
ical==8.3.0
|
||||||
|
|
||||||
# homeassistant.components.caldav
|
# homeassistant.components.caldav
|
||||||
|
@ -40,6 +40,7 @@ ALLOW_NAME_TRANSLATION = {
|
|||||||
"local_ip",
|
"local_ip",
|
||||||
"local_todo",
|
"local_todo",
|
||||||
"nmap_tracker",
|
"nmap_tracker",
|
||||||
|
"remote_calendar",
|
||||||
"rpi_power",
|
"rpi_power",
|
||||||
"swiss_public_transport",
|
"swiss_public_transport",
|
||||||
"waze_travel_time",
|
"waze_travel_time",
|
||||||
|
11
tests/components/remote_calendar/__init__.py
Normal file
11
tests/components/remote_calendar/__init__.py
Normal 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)
|
89
tests/components/remote_calendar/conftest.py
Normal file
89
tests/components/remote_calendar/conftest.py
Normal 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
|
394
tests/components/remote_calendar/test_calendar.py
Normal file
394
tests/components/remote_calendar/test_calendar.py
Normal 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
|
276
tests/components/remote_calendar/test_config_flow.py
Normal file
276
tests/components/remote_calendar/test_config_flow.py
Normal 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"
|
73
tests/components/remote_calendar/test_init.py
Normal file
73
tests/components/remote_calendar/test_init.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user