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
This commit is contained in:
Allen Porter 2022-06-21 06:42:41 -07:00 committed by GitHub
parent 1b8dd3368a
commit d399815bea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 459 additions and 59 deletions

View file

@ -8,6 +8,7 @@ from typing import Any
import aiohttp import aiohttp
from gcal_sync.api import GoogleCalendarService from gcal_sync.api import GoogleCalendarService
from gcal_sync.exceptions import ApiException, AuthException
from gcal_sync.model import DateOrDatetime, Event from gcal_sync.model import DateOrDatetime, Event
from oauth2client.file import Storage from oauth2client.file import Storage
import voluptuous as vol 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: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google from a config entry.""" """Set up Google from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {}
implementation = ( implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation( await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry hass, entry
@ -249,7 +252,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
calendar_service = GoogleCalendarService( calendar_service = GoogleCalendarService(
ApiAuthImpl(async_get_clientsession(hass), session) 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 # Only expose the add event service if we have the correct permissions
if get_feature_access(hass, entry) is FeatureAccess.read_write: 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: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """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: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:

View file

@ -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.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import PlatformNotReady 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 import generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import Throttle from homeassistant.util import Throttle
@ -102,15 +106,25 @@ CREATE_EVENT_SCHEMA = vol.All(
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the google calendar platform.""" """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: try:
result = await calendar_service.async_list_calendars() result = await calendar_service.async_list_calendars()
except ApiException as err: except ApiException as err:
raise PlatformNotReady(str(err)) from 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 # Yaml configuration may override objects from the API
calendars = await hass.async_add_executor_job( calendars = await hass.async_add_executor_job(
load_config, hass.config.path(YAML_DEVICES) load_config, hass.config.path(YAML_DEVICES)
@ -126,7 +140,6 @@ async def async_setup_entry(
hass, calendar_item.dict(exclude_unset=True) hass, calendar_item.dict(exclude_unset=True)
) )
new_calendars.append(calendar_info) new_calendars.append(calendar_info)
# Yaml calendar config may map one calendar to multiple entities with extra options like # Yaml calendar config may map one calendar to multiple entities with extra options like
# offsets or search criteria. # offsets or search criteria.
num_entities = len(calendar_info[CONF_ENTITIES]) 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" "has been imported to the UI, and should now be removed from google_calendars.yaml"
) )
entity_name = data[CONF_DEVICE_ID] 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( entities.append(
GoogleCalendarEntity( GoogleCalendarEntity(
calendar_service, calendar_service,
calendar_id, calendar_id,
data, data,
generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass),
# The google_calendars.yaml file lets users add multiple entities for unique_id,
# the same calendar id and needs additional disambiguation
f"{calendar_id}-{entity_name}" if num_entities > 1 else calendar_id,
entity_enabled, entity_enabled,
) )
) )
@ -163,7 +205,7 @@ async def async_setup_entry(
await hass.async_add_executor_job(append_calendars_to_config) await hass.async_add_executor_job(append_calendars_to_config)
platform = entity_platform.async_get_current_platform() 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( platform.async_register_entity_service(
SERVICE_CREATE_EVENT, SERVICE_CREATE_EVENT,
CREATE_EVENT_SCHEMA, CREATE_EVENT_SCHEMA,
@ -180,7 +222,7 @@ class GoogleCalendarEntity(CalendarEntity):
calendar_id: str, calendar_id: str,
data: dict[str, Any], data: dict[str, Any],
entity_id: str, entity_id: str,
unique_id: str, unique_id: str | None,
entity_enabled: bool, entity_enabled: bool,
) -> None: ) -> None:
"""Create the Calendar event device.""" """Create the Calendar event device."""

View file

@ -59,14 +59,6 @@ class OAuth2FlowHandler(
self.external_data = info self.external_data = info
return await super().async_step_creation(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( async def async_step_auth(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
@ -135,14 +127,14 @@ class OAuth2FlowHandler(
async def async_oauth_create_entry(self, data: dict) -> FlowResult: async def async_oauth_create_entry(self, data: dict) -> FlowResult:
"""Create an entry for the flow, or update existing entry.""" """Create an entry for the flow, or update existing entry."""
existing_entries = self._async_current_entries() if self._reauth_config_entry:
if existing_entries: self.hass.config_entries.async_update_entry(
assert len(existing_entries) == 1 self._reauth_config_entry, data=data
entry = existing_entries[0] )
self.hass.config_entries.async_update_entry(entry, data=data) await self.hass.config_entries.async_reload(
await self.hass.config_entries.async_reload(entry.entry_id) self._reauth_config_entry.entry_id
)
return self.async_abort(reason="reauth_successful") return self.async_abort(reason="reauth_successful")
calendar_service = GoogleCalendarService( calendar_service = GoogleCalendarService(
AccessTokenAuthImpl( AccessTokenAuthImpl(
async_get_clientsession(self.hass), data["token"]["access_token"] async_get_clientsession(self.hass), data["token"]["access_token"]
@ -151,11 +143,12 @@ class OAuth2FlowHandler(
try: try:
primary_calendar = await calendar_service.async_get_calendar("primary") primary_calendar = await calendar_service.async_get_calendar("primary")
except ApiException as err: except ApiException as err:
_LOGGER.debug("Error reading calendar primary calendar: %s", err) _LOGGER.error("Error reading primary calendar: %s", err)
primary_calendar = None return self.async_abort(reason="cannot_connect")
title = primary_calendar.id if primary_calendar else self.flow_impl.name await self.async_set_unique_id(primary_calendar.id)
self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=title, title=primary_calendar.id,
data=data, data=data,
options={ options={
CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name, CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name,

View file

@ -15,6 +15,7 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "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%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"code_expired": "Authentication code expired or credential setup is invalid, please try again.", "code_expired": "Authentication code expired or credential setup is invalid, please try again.",

View file

@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import datetime import datetime
import http
from typing import Any, Generator, TypeVar from typing import Any, Generator, TypeVar
from unittest.mock import Mock, mock_open, patch from unittest.mock import Mock, mock_open, patch
@ -27,6 +28,7 @@ YieldFixture = Generator[_T, None, None]
CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" 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 # 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 # the yaml config that overrides the entity name and other settings. A test
@ -53,6 +55,9 @@ TEST_API_CALENDAR = {
"defaultReminders": [], "defaultReminders": [],
} }
CLIENT_ID = "client-id"
CLIENT_SECRET = "client-secret"
@pytest.fixture @pytest.fixture
def test_api_calendar(): def test_api_calendar():
@ -148,8 +153,8 @@ def creds(
"""Fixture that defines creds used in the test.""" """Fixture that defines creds used in the test."""
return OAuth2Credentials( return OAuth2Credentials(
access_token="ACCESS_TOKEN", access_token="ACCESS_TOKEN",
client_id="client-id", client_id=CLIENT_ID,
client_secret="client-secret", client_secret=CLIENT_SECRET,
refresh_token="REFRESH_TOKEN", refresh_token="REFRESH_TOKEN",
token_expiry=token_expiry, token_expiry=token_expiry,
token_uri="http://example.com", token_uri="http://example.com",
@ -178,8 +183,15 @@ def config_entry_options() -> dict[str, Any] | None:
return 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 @pytest.fixture
def config_entry( def config_entry(
config_entry_unique_id: str,
token_scopes: list[str], token_scopes: list[str],
config_entry_token_expiry: float, config_entry_token_expiry: float,
config_entry_options: dict[str, Any] | None, config_entry_options: dict[str, Any] | None,
@ -187,6 +199,7 @@ def config_entry(
"""Fixture to create a config entry for the integration.""" """Fixture to create a config entry for the integration."""
return MockConfigEntry( return MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id=config_entry_unique_id,
data={ data={
"auth_implementation": "device_auth", "auth_implementation": "device_auth",
"token": { "token": {
@ -271,12 +284,16 @@ def mock_calendar_get(
"""Fixture for returning a calendar get response.""" """Fixture for returning a calendar get response."""
def _result( 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: ) -> None:
aioclient_mock.get( aioclient_mock.get(
f"{API_BASE_URL}/calendars/{calendar_id}", f"{API_BASE_URL}/calendars/{calendar_id}",
json=response, json=response,
exc=exc, exc=exc,
status=status,
) )
return return
@ -315,7 +332,7 @@ def google_config_track_new() -> None:
@pytest.fixture @pytest.fixture
def google_config(google_config_track_new: bool | None) -> dict[str, Any]: def google_config(google_config_track_new: bool | None) -> dict[str, Any]:
"""Fixture for overriding component config.""" """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: if google_config_track_new is not None:
google_config[CONF_TRACK_NEW] = google_config_track_new google_config[CONF_TRACK_NEW] = google_config_track_new
return google_config return google_config

View file

@ -13,7 +13,9 @@ from aiohttp.client_exceptions import ClientError
from gcal_sync.auth import API_BASE_URL from gcal_sync.auth import API_BASE_URL
import pytest 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 from homeassistant.helpers.template import DATE_STR_FORMAT
import homeassistant.util.dt as dt_util 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) state = hass.states.get(TEST_ENTITY)
assert state.state == STATE_OFF assert state.state == STATE_OFF
assert state.attributes["offset_reached"] 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

View file

@ -27,13 +27,18 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.util.dt import utcnow 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 from tests.common import MockConfigEntry, async_fire_time_changed
CODE_CHECK_INTERVAL = 1 CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
EMAIL_ADDRESS = "user@gmail.com"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -70,6 +75,12 @@ async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
yield 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 @pytest.fixture
async def primary_calendar_error() -> ClientError | None: async def primary_calendar_error() -> ClientError | None:
"""Fixture for tests to inject an error during calendar lookup.""" """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) @pytest.fixture(autouse=True)
async def primary_calendar( 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: ) -> None:
"""Fixture to return the primary calendar.""" """Fixture to return the primary calendar."""
mock_calendar_get( mock_calendar_get(
"primary", "primary",
{"id": EMAIL_ADDRESS, "summary": "Personal"}, {"id": primary_calendar_email, "summary": "Personal"},
exc=primary_calendar_error, exc=primary_calendar_error,
) )
@ -165,7 +178,7 @@ async def test_full_flow_application_creds(
assert await component_setup() assert await component_setup()
await async_import_client_credential( 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( result = await hass.config_entries.flow.async_init(
@ -327,26 +340,107 @@ async def test_exchange_error(
assert len(entries) == 1 assert len(entries) == 1
async def test_existing_config_entry( @pytest.mark.parametrize("google_config", [None])
async def test_duplicate_config_entries(
hass: HomeAssistant, hass: HomeAssistant,
mock_code_flow: Mock,
mock_exchange: Mock,
config: dict[str, Any],
config_entry: MockConfigEntry, config_entry: MockConfigEntry,
component_setup: ComponentSetup, component_setup: ComponentSetup,
) -> None: ) -> 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) 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) entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1 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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} 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("type") == "abort"
assert result.get("reason") == "already_configured" 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( async def test_missing_configuration(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> None:
@ -385,8 +479,8 @@ async def test_wrong_configuration(
config_entry_oauth2_flow.LocalOAuth2Implementation( config_entry_oauth2_flow.LocalOAuth2Implementation(
hass, hass,
DOMAIN, DOMAIN,
"client-id", CLIENT_ID,
"client-secret", CLIENT_SECRET,
"http://example/authorize", "http://example/authorize",
"http://example/token", "http://example/token",
), ),
@ -499,7 +593,7 @@ async def test_reauth_flow(
@pytest.mark.parametrize("primary_calendar_error", [ClientError()]) @pytest.mark.parametrize("primary_calendar_error", [ClientError()])
async def test_title_lookup_failure( async def test_calendar_lookup_failure(
hass: HomeAssistant, hass: HomeAssistant,
mock_code_flow: Mock, mock_code_flow: Mock,
mock_exchange: Mock, mock_exchange: Mock,
@ -516,9 +610,7 @@ async def test_title_lookup_failure(
assert "description_placeholders" in result assert "description_placeholders" in result
assert "url" in result["description_placeholders"] assert "url" in result["description_placeholders"]
with patch( with patch("homeassistant.components.google.async_setup_entry", return_value=True):
"homeassistant.components.google.async_setup_entry", return_value=True
) as mock_setup:
# Run one tick to invoke the credential exchange check # Run one tick to invoke the credential exchange check
now = utcnow() now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
@ -527,12 +619,8 @@ async def test_title_lookup_failure(
flow_id=result["flow_id"] flow_id=result["flow_id"]
) )
assert result.get("type") == "create_entry" assert result.get("type") == "abort"
assert result.get("title") == "Import from configuration.yaml" assert result.get("reason") == "cannot_connect"
assert len(mock_setup.mock_calls) == 1
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
async def test_options_flow_triggers_reauth( async def test_options_flow_triggers_reauth(

View file

@ -26,6 +26,7 @@ from homeassistant.util.dt import utcnow
from .conftest import ( from .conftest import (
CALENDAR_ID, CALENDAR_ID,
EMAIL_ADDRESS,
TEST_API_ENTITY, TEST_API_ENTITY,
TEST_API_ENTITY_NAME, TEST_API_ENTITY_NAME,
TEST_YAML_ENTITY, TEST_YAML_ENTITY,
@ -62,6 +63,7 @@ def setup_config_entry(
) -> MockConfigEntry: ) -> MockConfigEntry:
"""Fixture to initialize the config entry.""" """Fixture to initialize the config entry."""
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
return config_entry
@pytest.fixture( @pytest.fixture(
@ -219,11 +221,9 @@ async def test_calendar_yaml_error(
assert hass.states.get(TEST_API_ENTITY) assert hass.states.get(TEST_API_ENTITY)
@pytest.mark.parametrize("calendars_config", [[]]) async def test_init_calendar(
async def test_found_calendar_from_api(
hass: HomeAssistant, hass: HomeAssistant,
component_setup: ComponentSetup, component_setup: ComponentSetup,
mock_calendars_yaml: None,
mock_calendars_list: ApiResult, mock_calendars_list: ApiResult,
test_api_calendar: dict[str, Any], test_api_calendar: dict[str, Any],
mock_events_list: ApiResult, mock_events_list: ApiResult,
@ -275,6 +275,59 @@ async def test_load_application_credentials(
assert not hass.states.get(TEST_YAML_ENTITY) 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( @pytest.mark.parametrize(
"calendars_config_track,expected_state,google_config_track_new", "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() await hass.async_block_till_done()
mock_reload.assert_called_once() 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