Add config flow to CalDAV (#103215)
* Initial caldav config flow with broken calendar platform * Set up calendar entities * Remove separate caldav entity * Update tests after merge * Readbility improvements * Address lint issues * Apply suggestions from code review Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Add checking for duplicate configuration entries * Use verify SSL as input into caldav and simplify test setup --------- Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
06c9719cd6
commit
a95aa4e15f
12 changed files with 752 additions and 29 deletions
|
@ -1 +1,61 @@
|
|||
"""The caldav component."""
|
||||
|
||||
import logging
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import AuthorizationError, DAVError
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.CALENDAR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up CalDAV from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
client = caldav.DAVClient(
|
||||
entry.data[CONF_URL],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
ssl_verify_cert=entry.data[CONF_VERIFY_SSL],
|
||||
)
|
||||
try:
|
||||
await hass.async_add_executor_job(client.principal)
|
||||
except AuthorizationError as err:
|
||||
if err.reason == "Unauthorized":
|
||||
raise ConfigEntryAuthFailed("Credentials error from CalDAV server") from err
|
||||
# AuthorizationError can be raised if the url is incorrect or
|
||||
# on some other unexpected server response.
|
||||
_LOGGER.warning("Unexpected CalDAV server response: %s", err)
|
||||
return False
|
||||
except requests.ConnectionError as err:
|
||||
raise ConfigEntryNotReady("Connection error from CalDAV server") from err
|
||||
except DAVError as err:
|
||||
raise ConfigEntryNotReady("CalDAV client error") from err
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = client
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
|
|
@ -14,6 +14,7 @@ from homeassistant.components.calendar import (
|
|||
CalendarEvent,
|
||||
is_offset_reached,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
|
@ -28,6 +29,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import CalDavUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
@ -38,6 +40,10 @@ CONF_CALENDAR = "calendar"
|
|||
CONF_SEARCH = "search"
|
||||
CONF_DAYS = "days"
|
||||
|
||||
# Number of days to look ahead for next event when configured by ConfigEntry
|
||||
CONFIG_ENTRY_DEFAULT_DAYS = 7
|
||||
|
||||
OFFSET = "!!"
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
|
@ -106,7 +112,9 @@ def setup_platform(
|
|||
include_all_day=True,
|
||||
search=cust_calendar[CONF_SEARCH],
|
||||
)
|
||||
calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator))
|
||||
calendar_devices.append(
|
||||
WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True)
|
||||
)
|
||||
|
||||
# Create a default calendar if there was no custom one for all calendars
|
||||
# that support events.
|
||||
|
@ -131,20 +139,61 @@ def setup_platform(
|
|||
include_all_day=False,
|
||||
search=None,
|
||||
)
|
||||
calendar_devices.append(WebDavCalendarEntity(name, entity_id, coordinator))
|
||||
calendar_devices.append(
|
||||
WebDavCalendarEntity(name, entity_id, coordinator, supports_offset=True)
|
||||
)
|
||||
|
||||
add_entities(calendar_devices, True)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the CalDav calendar platform for a config entry."""
|
||||
client: caldav.DAVClient = hass.data[DOMAIN][entry.entry_id]
|
||||
calendars = await hass.async_add_executor_job(client.principal().calendars)
|
||||
async_add_entities(
|
||||
(
|
||||
WebDavCalendarEntity(
|
||||
calendar.name,
|
||||
generate_entity_id(ENTITY_ID_FORMAT, calendar.name, hass=hass),
|
||||
CalDavUpdateCoordinator(
|
||||
hass,
|
||||
calendar=calendar,
|
||||
days=CONFIG_ENTRY_DEFAULT_DAYS,
|
||||
include_all_day=True,
|
||||
search=None,
|
||||
),
|
||||
unique_id=f"{entry.entry_id}-{calendar.id}",
|
||||
)
|
||||
for calendar in calendars
|
||||
if calendar.name
|
||||
),
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarEntity):
|
||||
"""A device for getting the next Task from a WebDav Calendar."""
|
||||
|
||||
def __init__(self, name, entity_id, coordinator):
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
entity_id: str,
|
||||
coordinator: CalDavUpdateCoordinator,
|
||||
unique_id: str | None = None,
|
||||
supports_offset: bool = False,
|
||||
) -> None:
|
||||
"""Create the WebDav Calendar Event Device."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_id = entity_id
|
||||
self._event: CalendarEvent | None = None
|
||||
self._attr_name = name
|
||||
if unique_id is not None:
|
||||
self._attr_unique_id = unique_id
|
||||
self._supports_offset = supports_offset
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
|
@ -161,13 +210,14 @@ class WebDavCalendarEntity(CoordinatorEntity[CalDavUpdateCoordinator], CalendarE
|
|||
def _handle_coordinator_update(self) -> None:
|
||||
"""Update event data."""
|
||||
self._event = self.coordinator.data
|
||||
self._attr_extra_state_attributes = {
|
||||
"offset_reached": is_offset_reached(
|
||||
self._event.start_datetime_local, self.coordinator.offset
|
||||
)
|
||||
if self._event
|
||||
else False
|
||||
}
|
||||
if self._supports_offset:
|
||||
self._attr_extra_state_attributes = {
|
||||
"offset_reached": is_offset_reached(
|
||||
self._event.start_datetime_local, self.coordinator.offset
|
||||
)
|
||||
if self._event
|
||||
else False
|
||||
}
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
|
127
homeassistant/components/caldav/config_flow.py
Normal file
127
homeassistant/components/caldav/config_flow.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
"""Configuration flow for CalDav."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import caldav
|
||||
from caldav.lib.error import AuthorizationError, DAVError
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD, default=""): cv.string,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for caldav."""
|
||||
|
||||
VERSION = 1
|
||||
_reauth_entry: config_entries.ConfigEntry | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_URL: user_input[CONF_URL],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
}
|
||||
)
|
||||
if error := await self._test_connection(user_input):
|
||||
errors["base"] = error
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _test_connection(self, user_input: dict[str, Any]) -> str | None:
|
||||
"""Test the connection to the CalDAV server and return an error if any."""
|
||||
client = caldav.DAVClient(
|
||||
user_input[CONF_URL],
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
ssl_verify_cert=user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(client.principal)
|
||||
except AuthorizationError as err:
|
||||
_LOGGER.warning("Authorization Error connecting to CalDAV server: %s", err)
|
||||
if err.reason == "Unauthorized":
|
||||
return "invalid_auth"
|
||||
# AuthorizationError can be raised if the url is incorrect or
|
||||
# on some other unexpected server response.
|
||||
return "cannot_connect"
|
||||
except requests.ConnectionError as err:
|
||||
_LOGGER.warning("Connection Error connecting to CalDAV server: %s", err)
|
||||
return "cannot_connect"
|
||||
except DAVError as err:
|
||||
_LOGGER.warning("CalDAV client error: %s", err)
|
||||
return "cannot_connect"
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
return None
|
||||
|
||||
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
self._reauth_entry = self.hass.config_entries.async_get_entry(
|
||||
self.context["entry_id"]
|
||||
)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
errors = {}
|
||||
assert self._reauth_entry
|
||||
if user_input is not None:
|
||||
user_input = {**self._reauth_entry.data, **user_input}
|
||||
|
||||
if error := await self._test_connection(user_input):
|
||||
errors["base"] = error
|
||||
else:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._reauth_entry, data=user_input
|
||||
)
|
||||
await self.hass.config_entries.async_reload(self._reauth_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
description_placeholders={
|
||||
CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME],
|
||||
},
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
5
homeassistant/components/caldav/const.py
Normal file
5
homeassistant/components/caldav/const.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""Constands for CalDAV."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "caldav"
|
|
@ -2,6 +2,7 @@
|
|||
"domain": "caldav",
|
||||
"name": "CalDAV",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/caldav",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["caldav", "vobject"],
|
||||
|
|
34
homeassistant/components/caldav/strings.json
Normal file
34
homeassistant/components/caldav/strings.json
Normal file
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"description": "Please enter your CalDAV server credentials"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The password for {username} is invalid.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -78,6 +78,7 @@ FLOWS = {
|
|||
"bsblan",
|
||||
"bthome",
|
||||
"buienradar",
|
||||
"caldav",
|
||||
"canary",
|
||||
"cast",
|
||||
"cert_expiry",
|
||||
|
|
|
@ -765,7 +765,7 @@
|
|||
"caldav": {
|
||||
"name": "CalDAV",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"canary": {
|
||||
|
|
77
tests/components/caldav/conftest.py
Normal file
77
tests/components/caldav/conftest.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
"""Test fixtures for caldav."""
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.caldav.const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_URL = "https://example.com/url-1"
|
||||
TEST_USERNAME = "username-1"
|
||||
TEST_PASSWORD = "password-1"
|
||||
|
||||
|
||||
@pytest.fixture(name="platforms")
|
||||
def mock_platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(name="calendars")
|
||||
def mock_calendars() -> list[Mock]:
|
||||
"""Fixture to provide calendars returned by CalDAV client."""
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture(name="dav_client", autouse=True)
|
||||
def mock_dav_client(calendars: list[Mock]) -> Mock:
|
||||
"""Fixture to mock the DAVClient."""
|
||||
with patch(
|
||||
"homeassistant.components.caldav.calendar.caldav.DAVClient"
|
||||
) as mock_client:
|
||||
mock_client.return_value.principal.return_value.calendars.return_value = (
|
||||
calendars
|
||||
)
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture(name="config_entry")
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Fixture for a config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="setup_integration")
|
||||
async def mock_setup_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
platforms: list[str],
|
||||
) -> Callable[[], Awaitable[bool]]:
|
||||
"""Fixture to set up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
async def run() -> bool:
|
||||
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
|
||||
result = await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return result
|
||||
|
||||
return run
|
|
@ -3,14 +3,14 @@ from collections.abc import Awaitable, Callable
|
|||
import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
from caldav.objects import Event
|
||||
from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
@ -300,6 +300,12 @@ TEST_ENTITY = "calendar.example"
|
|||
CALENDAR_NAME = "Example"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to set up config entry platforms."""
|
||||
return [Platform.CALENDAR]
|
||||
|
||||
|
||||
@pytest.fixture(name="tz")
|
||||
def mock_tz() -> str | None:
|
||||
"""Fixture to specify the Home Assistant timezone to use during the test."""
|
||||
|
@ -331,18 +337,6 @@ def mock_calendars(calendar_names: list[str]) -> list[Mock]:
|
|||
return [_mock_calendar(name) for name in calendar_names]
|
||||
|
||||
|
||||
@pytest.fixture(name="dav_client", autouse=True)
|
||||
def mock_dav_client(calendars: list[Mock]) -> Mock:
|
||||
"""Fixture to mock the DAVClient."""
|
||||
with patch(
|
||||
"homeassistant.components.caldav.calendar.caldav.DAVClient"
|
||||
) as mock_client:
|
||||
mock_client.return_value.principal.return_value.calendars.return_value = (
|
||||
calendars
|
||||
)
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def get_api_events(
|
||||
hass_client: ClientSessionGenerator,
|
||||
|
@ -1067,10 +1061,7 @@ async def test_get_events_custom_calendars(
|
|||
]
|
||||
],
|
||||
)
|
||||
async def test_calendar_components(
|
||||
hass: HomeAssistant,
|
||||
dav_client: Mock,
|
||||
) -> None:
|
||||
async def test_calendar_components(hass: HomeAssistant) -> None:
|
||||
"""Test that only calendars that support events are created."""
|
||||
|
||||
assert await async_setup_component(hass, "calendar", {"calendar": CALDAV_CONFIG})
|
||||
|
@ -1094,3 +1085,27 @@ async def test_calendar_components(
|
|||
assert state
|
||||
assert state.name == "Calendar 4"
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tz", [UTC])
|
||||
@freeze_time(_local_datetime(17, 30))
|
||||
async def test_setup_config_entry(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Test a calendar entity from a config entry."""
|
||||
assert await setup_integration()
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.name == CALENDAR_NAME
|
||||
assert state.state == STATE_ON
|
||||
assert dict(state.attributes) == {
|
||||
"friendly_name": CALENDAR_NAME,
|
||||
"message": "This is an all day event",
|
||||
"all_day": True,
|
||||
"start_time": "2017-11-27 00:00:00",
|
||||
"end_time": "2017-11-28 00:00:00",
|
||||
"location": "Hamburg",
|
||||
"description": "What a beautiful day",
|
||||
}
|
||||
|
|
284
tests/components/caldav/test_config_flow.py
Normal file
284
tests/components/caldav/test_config_flow.py
Normal file
|
@ -0,0 +1,284 @@
|
|||
"""Test the CalDAV config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from caldav.lib.error import AuthorizationError, DAVError
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.caldav.const import DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from .conftest import TEST_PASSWORD, TEST_URL, TEST_USERNAME
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
f"homeassistant.components.{DOMAIN}.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
async def test_form(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful config flow setup."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == FlowResultType.FORM
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
CONF_VERIFY_SSL: False,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||
assert result2.get("title") == TEST_USERNAME
|
||||
assert result2.get("data") == {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
CONF_VERIFY_SSL: False,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(Exception(), "unknown"),
|
||||
(requests.ConnectionError(), "cannot_connect"),
|
||||
(DAVError(), "cannot_connect"),
|
||||
(AuthorizationError(reason="Unauthorized"), "invalid_auth"),
|
||||
(AuthorizationError(reason="Other"), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_caldav_client_error(
|
||||
hass: HomeAssistant,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
dav_client: Mock,
|
||||
) -> None:
|
||||
"""Test CalDav client errors during configuration flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
dav_client.return_value.principal.side_effect = side_effect
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.FORM
|
||||
assert result2.get("errors") == {"base": expected_error}
|
||||
|
||||
|
||||
async def test_reauth_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauthentication configuration flow."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": config_entry.entry_id,
|
||||
},
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "password-2",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.ABORT
|
||||
assert result2.get("reason") == "reauth_successful"
|
||||
|
||||
# Verify updated configuration entry
|
||||
assert dict(config_entry.data) == {
|
||||
CONF_URL: "https://example.com/url-1",
|
||||
CONF_USERNAME: "username-1",
|
||||
CONF_PASSWORD: "password-2",
|
||||
CONF_VERIFY_SSL: True,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reauth_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
dav_client: Mock,
|
||||
) -> None:
|
||||
"""Test a failure during reauthentication configuration flow."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": config_entry.entry_id,
|
||||
},
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
dav_client.return_value.principal.side_effect = DAVError
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "password-2",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.FORM
|
||||
assert result2.get("errors") == {"base": "cannot_connect"}
|
||||
|
||||
# Complete the form and it succeeds this time
|
||||
dav_client.return_value.principal.side_effect = None
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_PASSWORD: "password-3",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.ABORT
|
||||
assert result2.get("reason") == "reauth_successful"
|
||||
|
||||
# Verify updated configuration entry
|
||||
assert dict(config_entry.data) == {
|
||||
CONF_URL: "https://example.com/url-1",
|
||||
CONF_USERNAME: "username-1",
|
||||
CONF_PASSWORD: "password-3",
|
||||
CONF_VERIFY_SSL: True,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_input"),
|
||||
[
|
||||
{
|
||||
CONF_URL: f"{TEST_URL}/different-path",
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: f"{TEST_USERNAME}-different-user",
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_multiple_config_entries(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
) -> None:
|
||||
"""Test multiple configuration entries with unique settings."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == FlowResultType.FORM
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.CREATE_ENTRY
|
||||
assert result2.get("title") == user_input[CONF_USERNAME]
|
||||
assert result2.get("data") == {
|
||||
**user_input,
|
||||
CONF_VERIFY_SSL: True,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 2
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("user_input"),
|
||||
[
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: TEST_PASSWORD,
|
||||
},
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_USERNAME: TEST_USERNAME,
|
||||
CONF_PASSWORD: f"{TEST_PASSWORD}-different",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_duplicate_config_entries(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
user_input: dict[str, str],
|
||||
) -> None:
|
||||
"""Test multiple configuration entries with the same settings."""
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == FlowResultType.FORM
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2.get("type") == FlowResultType.ABORT
|
||||
assert result2.get("reason") == "already_configured"
|
69
tests/components/caldav/test_init.py
Normal file
69
tests/components/caldav/test_init.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
"""Unit tests for the CalDav integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from caldav.lib.error import AuthorizationError, DAVError
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_load_unload(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test loading and unloading of the config entry."""
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
with patch("homeassistant.components.caldav.config_flow.caldav.DAVClient"):
|
||||
assert await setup_integration()
|
||||
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_state", "expected_flows"),
|
||||
[
|
||||
(Exception(), ConfigEntryState.SETUP_ERROR, []),
|
||||
(requests.ConnectionError(), ConfigEntryState.SETUP_RETRY, []),
|
||||
(DAVError(), ConfigEntryState.SETUP_RETRY, []),
|
||||
(
|
||||
AuthorizationError(reason="Unauthorized"),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
["reauth_confirm"],
|
||||
),
|
||||
(AuthorizationError(reason="Other"), ConfigEntryState.SETUP_ERROR, []),
|
||||
],
|
||||
)
|
||||
async def test_client_failure(
|
||||
hass: HomeAssistant,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry | None,
|
||||
side_effect: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
expected_flows: list[str],
|
||||
) -> None:
|
||||
"""Test CalDAV client failures in setup."""
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.caldav.config_flow.caldav.DAVClient"
|
||||
) as mock_client:
|
||||
mock_client.return_value.principal.side_effect = side_effect
|
||||
assert not await setup_integration()
|
||||
|
||||
assert config_entry.state == expected_state
|
||||
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert [flow.get("step_id") for flow in flows] == expected_flows
|
Loading…
Add table
Reference in a new issue