mirror of
https://github.com/home-assistant/core.git
synced 2025-04-25 01:38:02 +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
|
||||
/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
|
||||
|
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",
|
||||
"recollect_waste",
|
||||
"refoss",
|
||||
"remote_calendar",
|
||||
"renault",
|
||||
"renson",
|
||||
"reolink",
|
||||
|
@ -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
1
requirements_all.txt
generated
@ -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
|
||||
|
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.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==8.3.0
|
||||
|
||||
# homeassistant.components.caldav
|
||||
|
@ -40,6 +40,7 @@ ALLOW_NAME_TRANSLATION = {
|
||||
"local_ip",
|
||||
"local_todo",
|
||||
"nmap_tracker",
|
||||
"remote_calendar",
|
||||
"rpi_power",
|
||||
"swiss_public_transport",
|
||||
"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