From d399815bea6b05ba85f6beb99a8107320078c9da Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Jun 2022 06:42:41 -0700 Subject: [PATCH] Allow multiple google calendar config entries (#73715) * Support multiple config entries at once * Add test coverage for multiple config entries * Add support for multiple config entries to google config flow * Clear hass.data when unloading config entry * Make google config flow defensive against reuse of the same account * Assign existing google config entries a unique id * Migrate entities to new unique id format * Support muliple accounts per oauth client id * Fix mypy typing errors * Hard fail to keep state consistent, removing graceful degredation * Remove invalid entity regsitry entries --- homeassistant/components/google/__init__.py | 19 ++- homeassistant/components/google/calendar.py | 60 ++++++-- .../components/google/config_flow.py | 31 ++--- homeassistant/components/google/strings.json | 1 + tests/components/google/conftest.py | 25 +++- tests/components/google/test_calendar.py | 124 ++++++++++++++++- tests/components/google/test_config_flow.py | 130 +++++++++++++++--- tests/components/google/test_init.py | 128 ++++++++++++++++- 8 files changed, 459 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index e86f1c43ebf..5553350aa23 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from gcal_sync.api import GoogleCalendarService +from gcal_sync.exceptions import ApiException, AuthException from gcal_sync.model import DateOrDatetime, Event from oauth2client.file import Storage import voluptuous as vol @@ -220,6 +221,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -249,7 +252,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calendar_service = GoogleCalendarService( ApiAuthImpl(async_get_clientsession(hass), session) ) - hass.data[DOMAIN][DATA_SERVICE] = calendar_service + hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service + + if entry.unique_id is None: + try: + primary_calendar = await calendar_service.async_get_calendar("primary") + except AuthException as err: + raise ConfigEntryAuthFailed from err + except ApiException as err: + raise ConfigEntryNotReady from err + else: + hass.config_entries.async_update_entry(entry, unique_id=primary_calendar.id) # Only expose the add event service if we have the correct permissions if get_feature_access(hass, entry) is FeatureAccess.read_write: @@ -271,7 +284,9 @@ def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index e27eb0b1336..3c271a2c3c3 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -23,7 +23,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle @@ -102,15 +106,25 @@ CREATE_EVENT_SCHEMA = vol.All( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the google calendar platform.""" - calendar_service = hass.data[DOMAIN][DATA_SERVICE] + calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] try: result = await calendar_service.async_list_calendars() except ApiException as err: raise PlatformNotReady(str(err)) from err + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + entity_entry_map = { + entity_entry.unique_id: entity_entry for entity_entry in registry_entries + } + # Yaml configuration may override objects from the API calendars = await hass.async_add_executor_job( load_config, hass.config.path(YAML_DEVICES) @@ -126,7 +140,6 @@ async def async_setup_entry( hass, calendar_item.dict(exclude_unset=True) ) new_calendars.append(calendar_info) - # Yaml calendar config may map one calendar to multiple entities with extra options like # offsets or search criteria. num_entities = len(calendar_info[CONF_ENTITIES]) @@ -138,15 +151,44 @@ async def async_setup_entry( "has been imported to the UI, and should now be removed from google_calendars.yaml" ) entity_name = data[CONF_DEVICE_ID] + # The unique id is based on the config entry and calendar id since multiple accounts + # can have a common calendar id (e.g. `en.usa#holiday@group.v.calendar.google.com`). + # When using google_calendars.yaml with multiple entities for a single calendar, we + # have no way to set a unique id. + if num_entities > 1: + unique_id = None + else: + unique_id = f"{config_entry.unique_id}-{calendar_id}" + # Migrate to new unique_id format which supports multiple config entries as of 2022.7 + for old_unique_id in (calendar_id, f"{calendar_id}-{entity_name}"): + if not (entity_entry := entity_entry_map.get(old_unique_id)): + continue + if unique_id: + _LOGGER.debug( + "Migrating unique_id for %s from %s to %s", + entity_entry.entity_id, + old_unique_id, + unique_id, + ) + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=unique_id + ) + else: + _LOGGER.debug( + "Removing entity registry entry for %s from %s", + entity_entry.entity_id, + old_unique_id, + ) + entity_registry.async_remove( + entity_entry.entity_id, + ) entities.append( GoogleCalendarEntity( calendar_service, calendar_id, data, generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), - # The google_calendars.yaml file lets users add multiple entities for - # the same calendar id and needs additional disambiguation - f"{calendar_id}-{entity_name}" if num_entities > 1 else calendar_id, + unique_id, entity_enabled, ) ) @@ -163,7 +205,7 @@ async def async_setup_entry( await hass.async_add_executor_job(append_calendars_to_config) platform = entity_platform.async_get_current_platform() - if get_feature_access(hass, entry) is FeatureAccess.read_write: + if get_feature_access(hass, config_entry) is FeatureAccess.read_write: platform.async_register_entity_service( SERVICE_CREATE_EVENT, CREATE_EVENT_SCHEMA, @@ -180,7 +222,7 @@ class GoogleCalendarEntity(CalendarEntity): calendar_id: str, data: dict[str, Any], entity_id: str, - unique_id: str, + unique_id: str | None, entity_enabled: bool, ) -> None: """Create the Calendar event device.""" diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index be516230d2b..046840075ff 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -59,14 +59,6 @@ class OAuth2FlowHandler( self.external_data = info return await super().async_step_creation(info) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle external yaml configuration.""" - if not self._reauth_config_entry and self._async_current_entries(): - return self.async_abort(reason="already_configured") - return await super().async_step_user(user_input) - async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -135,14 +127,14 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" - existing_entries = self._async_current_entries() - if existing_entries: - assert len(existing_entries) == 1 - entry = existing_entries[0] - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + if self._reauth_config_entry: + self.hass.config_entries.async_update_entry( + self._reauth_config_entry, data=data + ) + await self.hass.config_entries.async_reload( + self._reauth_config_entry.entry_id + ) return self.async_abort(reason="reauth_successful") - calendar_service = GoogleCalendarService( AccessTokenAuthImpl( async_get_clientsession(self.hass), data["token"]["access_token"] @@ -151,11 +143,12 @@ class OAuth2FlowHandler( try: primary_calendar = await calendar_service.async_get_calendar("primary") except ApiException as err: - _LOGGER.debug("Error reading calendar primary calendar: %s", err) - primary_calendar = None - title = primary_calendar.id if primary_calendar else self.flow_impl.name + _LOGGER.error("Error reading primary calendar: %s", err) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(primary_calendar.id) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=title, + title=primary_calendar.id, data=data, options={ CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name, diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 6652806cd0f..3ff75047f70 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -15,6 +15,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 27fb6c993ff..4e251b4b006 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime +import http from typing import Any, Generator, TypeVar from unittest.mock import Mock, mock_open, patch @@ -27,6 +28,7 @@ YieldFixture = Generator[_T, None, None] CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" +EMAIL_ADDRESS = "user@gmail.com" # Entities can either be created based on data directly from the API, or from # the yaml config that overrides the entity name and other settings. A test @@ -53,6 +55,9 @@ TEST_API_CALENDAR = { "defaultReminders": [], } +CLIENT_ID = "client-id" +CLIENT_SECRET = "client-secret" + @pytest.fixture def test_api_calendar(): @@ -148,8 +153,8 @@ def creds( """Fixture that defines creds used in the test.""" return OAuth2Credentials( access_token="ACCESS_TOKEN", - client_id="client-id", - client_secret="client-secret", + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, refresh_token="REFRESH_TOKEN", token_expiry=token_expiry, token_uri="http://example.com", @@ -178,8 +183,15 @@ def config_entry_options() -> dict[str, Any] | None: return None +@pytest.fixture +def config_entry_unique_id() -> str: + """Fixture that returns the default config entry unique id.""" + return EMAIL_ADDRESS + + @pytest.fixture def config_entry( + config_entry_unique_id: str, token_scopes: list[str], config_entry_token_expiry: float, config_entry_options: dict[str, Any] | None, @@ -187,6 +199,7 @@ def config_entry( """Fixture to create a config entry for the integration.""" return MockConfigEntry( domain=DOMAIN, + unique_id=config_entry_unique_id, data={ "auth_implementation": "device_auth", "token": { @@ -271,12 +284,16 @@ def mock_calendar_get( """Fixture for returning a calendar get response.""" def _result( - calendar_id: str, response: dict[str, Any], exc: ClientError | None = None + calendar_id: str, + response: dict[str, Any], + exc: ClientError | None = None, + status: http.HTTPStatus = http.HTTPStatus.OK, ) -> None: aioclient_mock.get( f"{API_BASE_URL}/calendars/{calendar_id}", json=response, exc=exc, + status=status, ) return @@ -315,7 +332,7 @@ def google_config_track_new() -> None: @pytest.fixture def google_config(google_config_track_new: bool | None) -> dict[str, Any]: """Fixture for overriding component config.""" - google_config = {CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-secret"} + google_config = {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET} if google_config_track_new is not None: google_config[CONF_TRACK_NEW] = google_config_track_new return google_config diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 85711014e72..9a0cc2e47fa 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -13,7 +13,9 @@ from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.google.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util @@ -665,3 +667,123 @@ async def test_future_event_offset_update_behavior( state = hass.states.get(TEST_ENTITY) assert state.state == STATE_OFF assert state.attributes["offset_reached"] + + +async def test_unique_id( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, +): + """Test entity is created with a unique id based on the config entry.""" + mock_events_list_items([]) + assert await component_setup() + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "old_unique_id", [CALENDAR_ID, f"{CALENDAR_ID}-we_are_we_are_a_test_calendar"] +) +async def test_unique_id_migration( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + old_unique_id, +): + """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=old_unique_id, + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == {old_unique_id} + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "calendars_config", + [ + [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "backyard_light", + "name": "Backyard Light", + "search": "#Backyard", + }, + { + "device_id": "front_light", + "name": "Front Light", + "search": "#Front", + }, + ], + } + ], + ], +) +async def test_invalid_unique_id_cleanup( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + mock_calendars_yaml, +): + """Test that old unique id format that is not actually unique is removed.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-backyard_light", + config_entry=config_entry, + ) + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-front_light", + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{CALENDAR_ID}-backyard_light", + f"{CALENDAR_ID}-front_light", + } + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert not registry_entries diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index a346b02e6c2..00f50e129e4 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -27,13 +27,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.dt import utcnow -from .conftest import ComponentSetup, YieldFixture +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + EMAIL_ADDRESS, + ComponentSetup, + YieldFixture, +) from tests.common import MockConfigEntry, async_fire_time_changed CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) -EMAIL_ADDRESS = "user@gmail.com" @pytest.fixture(autouse=True) @@ -70,6 +75,12 @@ async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: yield mock +@pytest.fixture +async def primary_calendar_email() -> str: + """Fixture to override the google calendar primary email address.""" + return EMAIL_ADDRESS + + @pytest.fixture async def primary_calendar_error() -> ClientError | None: """Fixture for tests to inject an error during calendar lookup.""" @@ -78,12 +89,14 @@ async def primary_calendar_error() -> ClientError | None: @pytest.fixture(autouse=True) async def primary_calendar( - mock_calendar_get: Callable[[...], None], primary_calendar_error: ClientError | None + mock_calendar_get: Callable[[...], None], + primary_calendar_error: ClientError | None, + primary_calendar_email: str, ) -> None: """Fixture to return the primary calendar.""" mock_calendar_get( "primary", - {"id": EMAIL_ADDRESS, "summary": "Personal"}, + {"id": primary_calendar_email, "summary": "Personal"}, exc=primary_calendar_error, ) @@ -165,7 +178,7 @@ async def test_full_flow_application_creds( assert await component_setup() await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" ) result = await hass.config_entries.flow.async_init( @@ -327,26 +340,107 @@ async def test_exchange_error( assert len(entries) == 1 -async def test_existing_config_entry( +@pytest.mark.parametrize("google_config", [None]) +async def test_duplicate_config_entries( hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], config_entry: MockConfigEntry, component_setup: ComponentSetup, ) -> None: - """Test can't configure when config entry already exists.""" + """Test that the same account cannot be setup twice.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert await component_setup() - + # Start a new config flow using the same credential result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) assert result.get("type") == "abort" assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "google_config,primary_calendar_email", [(None, "another-email@example.com")] +) +async def test_multiple_config_entries( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], + config_entry: MockConfigEntry, + component_setup: ComponentSetup, +) -> None: + """Test that multiple config entries can be set at once.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Start a new config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + assert result.get("type") == "create_entry" + assert result.get("title") == "another-email@example.com" + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + async def test_missing_configuration( hass: HomeAssistant, ) -> None: @@ -385,8 +479,8 @@ async def test_wrong_configuration( config_entry_oauth2_flow.LocalOAuth2Implementation( hass, DOMAIN, - "client-id", - "client-secret", + CLIENT_ID, + CLIENT_SECRET, "http://example/authorize", "http://example/token", ), @@ -499,7 +593,7 @@ async def test_reauth_flow( @pytest.mark.parametrize("primary_calendar_error", [ClientError()]) -async def test_title_lookup_failure( +async def test_calendar_lookup_failure( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, @@ -516,9 +610,7 @@ async def test_title_lookup_failure( assert "description_placeholders" in result assert "url" in result["description_placeholders"] - with patch( - "homeassistant.components.google.async_setup_entry", return_value=True - ) as mock_setup: + with patch("homeassistant.components.google.async_setup_entry", return_value=True): # Run one tick to invoke the credential exchange check now = utcnow() await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) @@ -527,12 +619,8 @@ async def test_title_lookup_failure( flow_id=result["flow_id"] ) - assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" - - assert len(mock_setup.mock_calls) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert result.get("type") == "abort" + assert result.get("reason") == "cannot_connect" async def test_options_flow_triggers_reauth( diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index f9391a82b6a..d9b9ec8ed03 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -26,6 +26,7 @@ from homeassistant.util.dt import utcnow from .conftest import ( CALENDAR_ID, + EMAIL_ADDRESS, TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, @@ -62,6 +63,7 @@ def setup_config_entry( ) -> MockConfigEntry: """Fixture to initialize the config entry.""" config_entry.add_to_hass(hass) + return config_entry @pytest.fixture( @@ -219,11 +221,9 @@ async def test_calendar_yaml_error( assert hass.states.get(TEST_API_ENTITY) -@pytest.mark.parametrize("calendars_config", [[]]) -async def test_found_calendar_from_api( +async def test_init_calendar( hass: HomeAssistant, component_setup: ComponentSetup, - mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, @@ -275,6 +275,59 @@ async def test_load_application_credentials( assert not hass.states.get(TEST_YAML_ENTITY) +async def test_multiple_config_entries( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test finding a calendar from the API.""" + + assert await component_setup() + + config_entry1 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id=EMAIL_ADDRESS + ) + calendar1 = { + **test_api_calendar, + "id": "calendar-id1", + "summary": "Example Calendar 1", + } + + mock_calendars_list({"items": [calendar1]}) + mock_events_list({}, calendar_id="calendar-id1") + config_entry1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry1.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_1") + assert state + assert state.name == "Example Calendar 1" + assert state.state == STATE_OFF + + config_entry2 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id="other-address@example.com" + ) + calendar2 = { + **test_api_calendar, + "id": "calendar-id2", + "summary": "Example Calendar 2", + } + aioclient_mock.clear_requests() + mock_calendars_list({"items": [calendar2]}) + mock_events_list({}, calendar_id="calendar-id2") + config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry2.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_2") + assert state + assert state.name == "Example Calendar 2" + + @pytest.mark.parametrize( "calendars_config_track,expected_state,google_config_track_new", [ @@ -795,3 +848,72 @@ async def test_update_will_reload( ) await hass.async_block_till_done() mock_reload.assert_called_once() + + +@pytest.mark.parametrize("config_entry_unique_id", [None]) +async def test_assign_unique_id( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, +) -> None: + """Test an existing config is updated to have unique id if it does not exist.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {"id": EMAIL_ADDRESS, "summary": "Personal"}, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is ConfigEntryState.LOADED + assert setup_config_entry.unique_id == EMAIL_ADDRESS + + +@pytest.mark.parametrize( + "config_entry_unique_id,request_status,config_entry_status", + [ + (None, http.HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_RETRY), + ( + None, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_assign_unique_id_failure( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, + request_status: http.HTTPStatus, + config_entry_status: ConfigEntryState, +) -> None: + """Test lookup failures during unique id assignment are handled gracefully.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {}, + status=request_status, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is config_entry_status + assert setup_config_entry.unique_id is None