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:
Allen Porter 2023-11-02 19:48:56 -07:00 committed by GitHub
parent 06c9719cd6
commit a95aa4e15f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 752 additions and 29 deletions

View file

@ -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)

View file

@ -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:

View 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,
)

View file

@ -0,0 +1,5 @@
"""Constands for CalDAV."""
from typing import Final
DOMAIN: Final = "caldav"

View file

@ -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"],

View 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%]"
}
}
}

View file

@ -78,6 +78,7 @@ FLOWS = {
"bsblan",
"bthome",
"buienradar",
"caldav",
"canary",
"cast",
"cert_expiry",

View file

@ -765,7 +765,7 @@
"caldav": {
"name": "CalDAV",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "cloud_polling"
},
"canary": {

View 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

View file

@ -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",
}

View 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"

View 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