1
mirror of https://github.com/home-assistant/core synced 2024-09-03 08:14:07 +02:00

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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