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 entriespull/73782/head
parent
1b8dd3368a
commit
d399815bea
|
@ -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:
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue