diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py index 6fe9a8d4d19..d62ff3eb5ce 100644 --- a/homeassistant/components/caldav/__init__.py +++ b/homeassistant/components/caldav/__init__.py @@ -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) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 14c9626c264..73764d60419 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -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: diff --git a/homeassistant/components/caldav/config_flow.py b/homeassistant/components/caldav/config_flow.py new file mode 100644 index 00000000000..f2fa51c7f60 --- /dev/null +++ b/homeassistant/components/caldav/config_flow.py @@ -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, + ) diff --git a/homeassistant/components/caldav/const.py b/homeassistant/components/caldav/const.py new file mode 100644 index 00000000000..7a94a74c7a1 --- /dev/null +++ b/homeassistant/components/caldav/const.py @@ -0,0 +1,5 @@ +"""Constands for CalDAV.""" + +from typing import Final + +DOMAIN: Final = "caldav" diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 92e2f7e67d8..a7365515758 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -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"], diff --git a/homeassistant/components/caldav/strings.json b/homeassistant/components/caldav/strings.json new file mode 100644 index 00000000000..64fdf466b30 --- /dev/null +++ b/homeassistant/components/caldav/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 48864fef3af..b7f112783ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -78,6 +78,7 @@ FLOWS = { "bsblan", "bthome", "buienradar", + "caldav", "canary", "cast", "cert_expiry", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f834f71bb07..be9d1f1bf5d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -765,7 +765,7 @@ "caldav": { "name": "CalDAV", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "canary": { diff --git a/tests/components/caldav/conftest.py b/tests/components/caldav/conftest.py new file mode 100644 index 00000000000..1c773d49166 --- /dev/null +++ b/tests/components/caldav/conftest.py @@ -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 diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index b7c9ed32244..023dae3facd 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -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", + } diff --git a/tests/components/caldav/test_config_flow.py b/tests/components/caldav/test_config_flow.py new file mode 100644 index 00000000000..6af7d5c670c --- /dev/null +++ b/tests/components/caldav/test_config_flow.py @@ -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" diff --git a/tests/components/caldav/test_init.py b/tests/components/caldav/test_init.py new file mode 100644 index 00000000000..a37815a007c --- /dev/null +++ b/tests/components/caldav/test_init.py @@ -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