mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Update google calendar integration with a config flow (#68010)
* Convert google calendar to config flow and async * Call correct exchange method * Fix async method and reduce unnecessary diffs * Wording improvements * Reduce unnecessary diffs * Run load/update config from executor * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Remove unnecessary updating of unexpected multiple config entries. * Remove unnecessary unique_id checks * Improve readability with comments about device code expiration * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/google/calendar.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Update homeassistant/components/google/api.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * Add comment for when code is none on timeout Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
4988c4683c
commit
7876ffe9e3
@ -1,23 +1,20 @@
|
||||
"""Support for Google - Calendar Event Devices."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from oauth2client.client import (
|
||||
FlowExchangeError,
|
||||
OAuth2DeviceCodeError,
|
||||
OAuth2WebServerFlow,
|
||||
)
|
||||
from httplib2.error import ServerNotFoundError
|
||||
from oauth2client.file import Storage
|
||||
import voluptuous as vol
|
||||
from voluptuous.error import Error as VoluptuousError
|
||||
import yaml
|
||||
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_CLIENT_ID,
|
||||
CONF_CLIENT_SECRET,
|
||||
@ -25,20 +22,28 @@ from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_OFFSET,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .api import GoogleCalendarService
|
||||
from . import config_flow
|
||||
from .api import DeviceAuth, GoogleCalendarService
|
||||
from .const import (
|
||||
CONF_CALENDAR_ACCESS,
|
||||
DATA_CONFIG,
|
||||
DATA_SERVICE,
|
||||
DISCOVER_CALENDAR,
|
||||
DOMAIN,
|
||||
FeatureAccess,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "google"
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
|
||||
CONF_TRACK_NEW = "track_new_calendar"
|
||||
@ -48,7 +53,6 @@ CONF_TRACK = "track"
|
||||
CONF_SEARCH = "search"
|
||||
CONF_IGNORE_AVAILABILITY = "ignore_availability"
|
||||
CONF_MAX_RESULTS = "max_results"
|
||||
CONF_CALENDAR_ACCESS = "calendar_access"
|
||||
|
||||
DEFAULT_CONF_OFFSET = "!!"
|
||||
|
||||
@ -74,27 +78,11 @@ SERVICE_SCAN_CALENDARS = "scan_for_calendars"
|
||||
SERVICE_FOUND_CALENDARS = "found_calendar"
|
||||
SERVICE_ADD_EVENT = "add_event"
|
||||
|
||||
DATA_SERVICE = "service"
|
||||
|
||||
YAML_DEVICES = f"{DOMAIN}_calendars.yaml"
|
||||
|
||||
TOKEN_FILE = f".{DOMAIN}.token"
|
||||
|
||||
|
||||
class FeatureAccess(Enum):
|
||||
"""Class to represent different access scopes."""
|
||||
|
||||
read_only = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
read_write = "https://www.googleapis.com/auth/calendar"
|
||||
|
||||
def __init__(self, scope: str) -> None:
|
||||
"""Init instance."""
|
||||
self._scope = scope
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
"""Google calendar scope for the feature."""
|
||||
return self._scope
|
||||
PLATFORMS = ["calendar"]
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
@ -159,131 +147,79 @@ ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
def do_authentication(
|
||||
hass: HomeAssistant,
|
||||
hass_config: ConfigType,
|
||||
config: ConfigType,
|
||||
storage: Storage,
|
||||
) -> bool:
|
||||
"""Notify user of actions and authenticate.
|
||||
|
||||
Notify user of user_code and verification_url then poll
|
||||
until we have an access token.
|
||||
"""
|
||||
oauth = OAuth2WebServerFlow(
|
||||
client_id=config[CONF_CLIENT_ID],
|
||||
client_secret=config[CONF_CLIENT_SECRET],
|
||||
scope=config[CONF_CALENDAR_ACCESS].scope,
|
||||
redirect_uri="Home-Assistant.io",
|
||||
)
|
||||
try:
|
||||
dev_flow = oauth.step1_get_device_and_user_codes()
|
||||
except OAuth2DeviceCodeError as err:
|
||||
persistent_notification.create(
|
||||
hass,
|
||||
f"Error: {err}<br />You will need to restart hass after fixing." "",
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID,
|
||||
)
|
||||
return False
|
||||
|
||||
persistent_notification.create(
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Google component."""
|
||||
conf = config.get(DOMAIN, {})
|
||||
hass.data[DOMAIN] = {DATA_CONFIG: conf}
|
||||
config_flow.OAuth2FlowHandler.async_register_implementation(
|
||||
hass,
|
||||
(
|
||||
f"In order to authorize Home-Assistant to view your calendars "
|
||||
f'you must visit: <a href="{dev_flow.verification_url}" target="_blank">{dev_flow.verification_url}</a> and enter '
|
||||
f"code: {dev_flow.user_code}"
|
||||
),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID,
|
||||
)
|
||||
|
||||
listener: CALLBACK_TYPE | None = None
|
||||
|
||||
def step2_exchange(now: datetime) -> None:
|
||||
"""Keep trying to validate the user_code until it expires."""
|
||||
_LOGGER.debug("Attempting to validate user code")
|
||||
|
||||
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
|
||||
# object without tzinfo. For the comparison below to work, it needs one.
|
||||
user_code_expiry = dev_flow.user_code_expiry.replace(tzinfo=timezone.utc)
|
||||
|
||||
if now >= user_code_expiry:
|
||||
persistent_notification.create(
|
||||
hass,
|
||||
"Authentication code expired, please restart "
|
||||
"Home-Assistant and try again",
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID,
|
||||
)
|
||||
assert listener
|
||||
listener()
|
||||
return
|
||||
|
||||
try:
|
||||
credentials = oauth.step2_exchange(device_flow_info=dev_flow)
|
||||
except FlowExchangeError:
|
||||
# not ready yet, call again
|
||||
return
|
||||
|
||||
storage.put(credentials)
|
||||
do_setup(hass, hass_config, config)
|
||||
assert listener
|
||||
listener()
|
||||
persistent_notification.create(
|
||||
DeviceAuth(
|
||||
hass,
|
||||
(
|
||||
f"We are all setup now. Check {YAML_DEVICES} for calendars that have "
|
||||
f"been found"
|
||||
),
|
||||
title=NOTIFICATION_TITLE,
|
||||
notification_id=NOTIFICATION_ID,
|
||||
)
|
||||
|
||||
listener = track_utc_time_change(
|
||||
hass, step2_exchange, second=range(1, 60, dev_flow.interval)
|
||||
conf[CONF_CLIENT_ID],
|
||||
conf[CONF_CLIENT_SECRET],
|
||||
),
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Google platform."""
|
||||
|
||||
if not (conf := config.get(DOMAIN, {})):
|
||||
# component is set up by tts platform
|
||||
return True
|
||||
|
||||
# Import credentials from the old token file into the new way as
|
||||
# a ConfigEntry managed by home assistant.
|
||||
storage = Storage(hass.config.path(TOKEN_FILE))
|
||||
hass.data[DOMAIN] = {
|
||||
DATA_SERVICE: GoogleCalendarService(hass, storage),
|
||||
}
|
||||
creds = storage.get()
|
||||
if (
|
||||
not creds
|
||||
or not creds.scopes
|
||||
or conf[CONF_CALENDAR_ACCESS].scope not in creds.scopes
|
||||
):
|
||||
do_authentication(hass, config, conf, storage)
|
||||
else:
|
||||
do_setup(hass, config, conf)
|
||||
creds = await hass.async_add_executor_job(storage.get)
|
||||
if creds and conf[CONF_CALENDAR_ACCESS].scope in creds.scopes:
|
||||
_LOGGER.debug("Importing configuration entry with credentials")
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_IMPORT},
|
||||
data={
|
||||
"creds": creds,
|
||||
},
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Google from a config entry."""
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
assert isinstance(implementation, DeviceAuth)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
required_scope = hass.data[DOMAIN][DATA_CONFIG][CONF_CALENDAR_ACCESS].scope
|
||||
if required_scope not in session.token.get("scope", []):
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Required scopes are not available, reauth required"
|
||||
)
|
||||
calendar_service = GoogleCalendarService(hass, session)
|
||||
hass.data[DOMAIN][DATA_SERVICE] = calendar_service
|
||||
|
||||
await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service)
|
||||
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_services(
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_setup_services(
|
||||
hass: HomeAssistant,
|
||||
hass_config: ConfigType,
|
||||
config: ConfigType,
|
||||
calendar_service: GoogleCalendarService,
|
||||
) -> None:
|
||||
"""Set up the service listeners."""
|
||||
|
||||
created_calendars = set()
|
||||
calendars = load_config(hass.config.path(YAML_DEVICES))
|
||||
calendars = await hass.async_add_executor_job(
|
||||
load_config, hass.config.path(YAML_DEVICES)
|
||||
)
|
||||
|
||||
def _found_calendar(call: ServiceCall) -> None:
|
||||
"""Check if we know about a calendar and generate PLATFORM_DISCOVER."""
|
||||
async def _found_calendar(call: ServiceCall) -> None:
|
||||
calendar = get_calendar_info(hass, call.data)
|
||||
calendar_id = calendar[CONF_CAL_ID]
|
||||
|
||||
@ -294,31 +230,33 @@ def setup_services(
|
||||
# Populate the yaml file with all discovered calendars
|
||||
if calendar_id not in calendars:
|
||||
calendars[calendar_id] = calendar
|
||||
update_config(hass.config.path(YAML_DEVICES), calendar)
|
||||
await hass.async_add_executor_job(
|
||||
update_config, hass.config.path(YAML_DEVICES), calendar
|
||||
)
|
||||
else:
|
||||
# Prefer entity/name information from yaml, overriding api
|
||||
calendar = calendars[calendar_id]
|
||||
async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar)
|
||||
|
||||
discovery.load_platform(
|
||||
hass,
|
||||
Platform.CALENDAR,
|
||||
DOMAIN,
|
||||
calendar,
|
||||
hass_config,
|
||||
)
|
||||
hass.services.async_register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
|
||||
|
||||
def _scan_for_calendars(call: ServiceCall) -> None:
|
||||
async def _scan_for_calendars(call: ServiceCall) -> None:
|
||||
"""Scan for new calendars."""
|
||||
calendars = calendar_service.list_calendars()
|
||||
try:
|
||||
calendars = await calendar_service.async_list_calendars()
|
||||
except ServerNotFoundError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
tasks = []
|
||||
for calendar in calendars:
|
||||
calendar[CONF_TRACK] = config[CONF_TRACK_NEW]
|
||||
hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
|
||||
tasks.append(
|
||||
hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar)
|
||||
)
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
|
||||
hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
|
||||
|
||||
def _add_event(call: ServiceCall) -> None:
|
||||
async def _add_event(call: ServiceCall) -> None:
|
||||
"""Add a new event to calendar."""
|
||||
start = {}
|
||||
end = {}
|
||||
@ -354,7 +292,7 @@ def setup_services(
|
||||
start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)}
|
||||
end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)}
|
||||
|
||||
calendar_service.create_event(
|
||||
await calendar_service.async_create_event(
|
||||
call.data[EVENT_CALENDAR_ID],
|
||||
{
|
||||
"summary": call.data[EVENT_SUMMARY],
|
||||
@ -366,20 +304,11 @@ def setup_services(
|
||||
|
||||
# Only expose the add event service if we have the correct permissions
|
||||
if config.get(CONF_CALENDAR_ACCESS) is FeatureAccess.read_write:
|
||||
hass.services.register(
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
def do_setup(hass: HomeAssistant, hass_config: ConfigType, config: ConfigType) -> None:
|
||||
"""Run the setup after we have everything configured."""
|
||||
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
|
||||
setup_services(hass, hass_config, config, calendar_service)
|
||||
|
||||
# Fetch calendars from the API
|
||||
hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
|
||||
|
||||
|
||||
def get_calendar_info(
|
||||
hass: HomeAssistant, calendar: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
|
@ -2,20 +2,166 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from googleapiclient import discovery as google_discovery
|
||||
from oauth2client.file import Storage
|
||||
import oauth2client
|
||||
from oauth2client.client import (
|
||||
Credentials,
|
||||
DeviceFlowInfo,
|
||||
FlowExchangeError,
|
||||
OAuth2Credentials,
|
||||
OAuth2DeviceCodeError,
|
||||
OAuth2WebServerFlow,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.util import dt
|
||||
|
||||
from .const import CONF_CALENDAR_ACCESS, DATA_CONFIG, DEVICE_AUTH_IMPL, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
EVENT_PAGE_SIZE = 100
|
||||
EXCHANGE_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""OAuth related error."""
|
||||
|
||||
|
||||
class DeviceAuth(config_entry_oauth2_flow.LocalOAuth2Implementation):
|
||||
"""OAuth implementation for Device Auth."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, client_id: str, client_secret: str) -> None:
|
||||
"""Initialize InstalledAppAuth."""
|
||||
super().__init__(
|
||||
hass,
|
||||
DEVICE_AUTH_IMPL,
|
||||
client_id,
|
||||
client_secret,
|
||||
oauth2client.GOOGLE_AUTH_URI,
|
||||
oauth2client.GOOGLE_TOKEN_URI,
|
||||
)
|
||||
|
||||
async def async_resolve_external_data(self, external_data: Any) -> dict:
|
||||
"""Resolve a Google API Credentials object to Home Assistant token."""
|
||||
creds: Credentials = external_data["creds"]
|
||||
return {
|
||||
"access_token": creds.access_token,
|
||||
"refresh_token": creds.refresh_token,
|
||||
"scope": " ".join(creds.scopes),
|
||||
"token_type": "Bearer",
|
||||
"expires_in": creds.token_expiry.timestamp(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceFlow:
|
||||
"""OAuth2 device flow for exchanging a code for an access token."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
oauth_flow: OAuth2WebServerFlow,
|
||||
device_flow_info: DeviceFlowInfo,
|
||||
) -> None:
|
||||
"""Initialize DeviceFlow."""
|
||||
self._hass = hass
|
||||
self._oauth_flow = oauth_flow
|
||||
self._device_flow_info: DeviceFlowInfo = device_flow_info
|
||||
self._exchange_task_unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
@property
|
||||
def verification_url(self) -> str:
|
||||
"""Return the verification url that the user should visit to enter the code."""
|
||||
return self._device_flow_info.verification_url
|
||||
|
||||
@property
|
||||
def user_code(self) -> str:
|
||||
"""Return the code that the user should enter at the verification url."""
|
||||
return self._device_flow_info.user_code
|
||||
|
||||
async def start_exchange_task(
|
||||
self, finished_cb: Callable[[Credentials | None], Awaitable[None]]
|
||||
) -> None:
|
||||
"""Start the device auth exchange flow polling.
|
||||
|
||||
The callback is invoked with the valid credentials or with None on timeout.
|
||||
"""
|
||||
_LOGGER.debug("Starting exchange flow")
|
||||
assert not self._exchange_task_unsub
|
||||
max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS)
|
||||
# For some reason, oauth.step1_get_device_and_user_codes() returns a datetime
|
||||
# object without tzinfo. For the comparison below to work, it needs one.
|
||||
user_code_expiry = self._device_flow_info.user_code_expiry.replace(
|
||||
tzinfo=datetime.timezone.utc
|
||||
)
|
||||
expiration_time = min(user_code_expiry, max_timeout)
|
||||
|
||||
def _exchange() -> Credentials:
|
||||
return self._oauth_flow.step2_exchange(
|
||||
device_flow_info=self._device_flow_info
|
||||
)
|
||||
|
||||
async def _poll_attempt(now: datetime.datetime) -> None:
|
||||
assert self._exchange_task_unsub
|
||||
_LOGGER.debug("Attempting OAuth code exchange")
|
||||
# Note: The callback is invoked with None when the device code has expired
|
||||
creds: Credentials | None = None
|
||||
if now < expiration_time:
|
||||
try:
|
||||
creds = await self._hass.async_add_executor_job(_exchange)
|
||||
except FlowExchangeError:
|
||||
_LOGGER.debug("Token not yet ready; trying again later")
|
||||
return
|
||||
self._exchange_task_unsub()
|
||||
self._exchange_task_unsub = None
|
||||
await finished_cb(creds)
|
||||
|
||||
self._exchange_task_unsub = async_track_time_interval(
|
||||
self._hass,
|
||||
_poll_attempt,
|
||||
datetime.timedelta(seconds=self._device_flow_info.interval),
|
||||
)
|
||||
|
||||
|
||||
async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow:
|
||||
"""Create a new Device flow."""
|
||||
conf = hass.data[DOMAIN][DATA_CONFIG]
|
||||
oauth_flow = OAuth2WebServerFlow(
|
||||
client_id=conf[CONF_CLIENT_ID],
|
||||
client_secret=conf[CONF_CLIENT_SECRET],
|
||||
scope=conf[CONF_CALENDAR_ACCESS].scope,
|
||||
redirect_uri="",
|
||||
)
|
||||
try:
|
||||
device_flow_info = await hass.async_add_executor_job(
|
||||
oauth_flow.step1_get_device_and_user_codes
|
||||
)
|
||||
except OAuth2DeviceCodeError as err:
|
||||
raise OAuthError(str(err)) from err
|
||||
return DeviceFlow(hass, oauth_flow, device_flow_info)
|
||||
|
||||
|
||||
def _async_google_creds(hass: HomeAssistant, token: dict[str, Any]) -> Credentials:
|
||||
"""Convert a Home Assistant token to a Google API Credentials object."""
|
||||
conf = hass.data[DOMAIN][DATA_CONFIG]
|
||||
return OAuth2Credentials(
|
||||
access_token=token["access_token"],
|
||||
client_id=conf[CONF_CLIENT_ID],
|
||||
client_secret=conf[CONF_CLIENT_SECRET],
|
||||
refresh_token=token["refresh_token"],
|
||||
token_expiry=token["expires_at"],
|
||||
token_uri=oauth2client.GOOGLE_TOKEN_URI,
|
||||
scopes=[conf[CONF_CALENDAR_ACCESS].scope],
|
||||
user_agent=None,
|
||||
)
|
||||
|
||||
|
||||
def _api_time_format(time: datetime.datetime | None) -> str | None:
|
||||
@ -26,26 +172,44 @@ def _api_time_format(time: datetime.datetime | None) -> str | None:
|
||||
class GoogleCalendarService:
|
||||
"""Calendar service interface to Google."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, storage: Storage) -> None:
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session
|
||||
) -> None:
|
||||
"""Init the Google Calendar service."""
|
||||
self._hass = hass
|
||||
self._storage = storage
|
||||
self._session = session
|
||||
|
||||
def _get_service(self) -> google_discovery.Resource:
|
||||
"""Get the calendar service from the storage file token."""
|
||||
async def _async_get_service(self) -> google_discovery.Resource:
|
||||
"""Get the calendar service with valid credetnails."""
|
||||
await self._session.async_ensure_token_valid()
|
||||
creds = _async_google_creds(self._hass, self._session.token)
|
||||
return google_discovery.build(
|
||||
"calendar", "v3", credentials=self._storage.get(), cache_discovery=False
|
||||
"calendar", "v3", credentials=creds, cache_discovery=False
|
||||
)
|
||||
|
||||
def list_calendars(self) -> list[dict[str, Any]]:
|
||||
async def async_list_calendars(
|
||||
self,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the list of calendars the user has added to their list."""
|
||||
cal_list = self._get_service().calendarList() # pylint: disable=no-member
|
||||
return cal_list.list().execute()["items"]
|
||||
service = await self._async_get_service()
|
||||
|
||||
def create_event(self, calendar_id: str, event: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create an event."""
|
||||
events = self._get_service().events() # pylint: disable=no-member
|
||||
return events.insert(calendarId=calendar_id, body=event).execute()
|
||||
def _list_calendars() -> list[dict[str, Any]]:
|
||||
cal_list = service.calendarList() # pylint: disable=no-member
|
||||
return cal_list.list().execute()["items"]
|
||||
|
||||
return await self._hass.async_add_executor_job(_list_calendars)
|
||||
|
||||
async def async_create_event(
|
||||
self, calendar_id: str, event: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Return the list of calendars the user has added to their list."""
|
||||
service = await self._async_get_service()
|
||||
|
||||
def _create_event() -> dict[str, Any]:
|
||||
events = service.events() # pylint: disable=no-member
|
||||
return events.insert(calendarId=calendar_id, body=event).execute()
|
||||
|
||||
return await self._hass.async_add_executor_job(_create_event)
|
||||
|
||||
async def async_list_events(
|
||||
self,
|
||||
@ -56,33 +220,20 @@ class GoogleCalendarService:
|
||||
page_token: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Return the list of events."""
|
||||
return await self._hass.async_add_executor_job(
|
||||
self.list_events,
|
||||
calendar_id,
|
||||
start_time,
|
||||
end_time,
|
||||
search,
|
||||
page_token,
|
||||
)
|
||||
service = await self._async_get_service()
|
||||
|
||||
def list_events(
|
||||
self,
|
||||
calendar_id: str,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
search: str | None = None,
|
||||
page_token: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""Return the list of events."""
|
||||
events = self._get_service().events() # pylint: disable=no-member
|
||||
result = events.list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=_api_time_format(start_time if start_time else dt.now()),
|
||||
timeMax=_api_time_format(end_time),
|
||||
q=search,
|
||||
maxResults=EVENT_PAGE_SIZE,
|
||||
pageToken=page_token,
|
||||
singleEvents=True, # Flattens recurring events
|
||||
orderBy="startTime",
|
||||
).execute()
|
||||
return (result["items"], result.get("nextPageToken"))
|
||||
def _list_events() -> tuple[list[dict[str, Any]], str | None]:
|
||||
events = service.events() # pylint: disable=no-member
|
||||
result = events.list(
|
||||
calendarId=calendar_id,
|
||||
timeMin=_api_time_format(start_time if start_time else dt.now()),
|
||||
timeMax=_api_time_format(end_time),
|
||||
q=search,
|
||||
maxResults=EVENT_PAGE_SIZE,
|
||||
pageToken=page_token,
|
||||
singleEvents=True, # Flattens recurring events
|
||||
orderBy="startTime",
|
||||
).execute()
|
||||
return (result["items"], result.get("nextPageToken"))
|
||||
|
||||
return await self._hass.async_add_executor_job(_list_events)
|
||||
|
@ -14,11 +14,13 @@ from homeassistant.components.calendar import (
|
||||
calculate_offset,
|
||||
is_offset_reached,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import (
|
||||
@ -29,8 +31,10 @@ from . import (
|
||||
DATA_SERVICE,
|
||||
DEFAULT_CONF_OFFSET,
|
||||
DOMAIN,
|
||||
SERVICE_SCAN_CALENDARS,
|
||||
)
|
||||
from .api import GoogleCalendarService
|
||||
from .const import DISCOVER_CALENDAR
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -48,19 +52,39 @@ TRANSPARENCY = "transparency"
|
||||
OPAQUE = "opaque"
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
disc_info: DiscoveryInfoType | None = None,
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up the calendar platform for event devices."""
|
||||
if disc_info is None:
|
||||
return
|
||||
"""Set up the google calendar platform."""
|
||||
|
||||
if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
|
||||
return
|
||||
@callback
|
||||
def async_discover(discovery_info: dict[str, Any]) -> None:
|
||||
_async_setup_entities(
|
||||
hass,
|
||||
entry,
|
||||
async_add_entities,
|
||||
discovery_info,
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover)
|
||||
)
|
||||
|
||||
# Look for any new calendars
|
||||
try:
|
||||
await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True)
|
||||
except HomeAssistantError as err:
|
||||
# This can happen if there's a connection error during setup.
|
||||
raise PlatformNotReady(str(err)) from err
|
||||
|
||||
|
||||
@callback
|
||||
def _async_setup_entities(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
disc_info: dict[str, Any],
|
||||
) -> None:
|
||||
calendar_service = hass.data[DOMAIN][DATA_SERVICE]
|
||||
entities = []
|
||||
for data in disc_info[CONF_ENTITIES]:
|
||||
@ -74,7 +98,7 @@ def setup_platform(
|
||||
)
|
||||
entities.append(entity)
|
||||
|
||||
add_entities(entities, True)
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class GoogleCalendarEventDevice(CalendarEventDevice):
|
||||
@ -144,10 +168,10 @@ class GoogleCalendarEventDevice(CalendarEventDevice):
|
||||
return event_list
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self) -> None:
|
||||
async def async_update(self) -> None:
|
||||
"""Get the latest data."""
|
||||
try:
|
||||
items, _ = self._calendar_service.list_events(
|
||||
items, _ = await self._calendar_service.async_list_events(
|
||||
self._calendar_id, search=self._search
|
||||
)
|
||||
except ServerNotFoundError as err:
|
||||
|
128
homeassistant/components/google/config_flow.py
Normal file
128
homeassistant/components/google/config_flow.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Config flow for Google integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from oauth2client.client import Credentials
|
||||
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .api import DeviceFlow, OAuthError, async_create_device_flow
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle Google Calendars OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Set up instance."""
|
||||
super().__init__()
|
||||
self._reauth = False
|
||||
self._device_flow: DeviceFlow | None = None
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_step_import(self, info: dict[str, Any]) -> FlowResult:
|
||||
"""Import existing auth from Nest."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
implementations = await config_entry_oauth2_flow.async_get_implementations(
|
||||
self.hass, self.DOMAIN
|
||||
)
|
||||
assert len(implementations) == 1
|
||||
self.flow_impl = list(implementations.values())[0]
|
||||
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 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:
|
||||
"""Create an entry for auth."""
|
||||
# The default behavior from the parent class is to redirect the
|
||||
# user with an external step. When using the device flow, we instead
|
||||
# prompt the user to visit a URL and enter a code. The device flow
|
||||
# background task will poll the exchange endpoint to get valid
|
||||
# creds or until a timeout is complete.
|
||||
if user_input is not None:
|
||||
return self.async_show_progress_done(next_step_id="creation")
|
||||
|
||||
if not self._device_flow:
|
||||
_LOGGER.debug("Creating DeviceAuth flow")
|
||||
try:
|
||||
device_flow = await async_create_device_flow(self.hass)
|
||||
except OAuthError as err:
|
||||
_LOGGER.error("Error initializing device flow: %s", str(err))
|
||||
return self.async_abort(reason="oauth_error")
|
||||
self._device_flow = device_flow
|
||||
|
||||
async def _exchange_finished(creds: Credentials | None) -> None:
|
||||
self.external_data = {"creds": creds} # is None on timeout/expiration
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_configure(
|
||||
flow_id=self.flow_id, user_input={}
|
||||
)
|
||||
)
|
||||
|
||||
await device_flow.start_exchange_task(_exchange_finished)
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id="auth",
|
||||
description_placeholders={
|
||||
"url": self._device_flow.verification_url,
|
||||
"user_code": self._device_flow.user_code,
|
||||
},
|
||||
progress_action="exchange",
|
||||
)
|
||||
|
||||
async def async_step_creation(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle external yaml configuration."""
|
||||
if self.external_data.get("creds") is None:
|
||||
return self.async_abort(reason="code_expired")
|
||||
return await super().async_step_creation(user_input)
|
||||
|
||||
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)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
return self.async_create_entry(title=self.flow_impl.name, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
self._reauth = True
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reauth dialog."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
30
homeassistant/components/google/const.py
Normal file
30
homeassistant/components/google/const.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Constants for google integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
DOMAIN = "google"
|
||||
DEVICE_AUTH_IMPL = "device_auth"
|
||||
|
||||
CONF_CALENDAR_ACCESS = "calendar_access"
|
||||
DATA_CALENDARS = "calendars"
|
||||
DATA_SERVICE = "service"
|
||||
DATA_CONFIG = "config"
|
||||
|
||||
DISCOVER_CALENDAR = "google_discover_calendar"
|
||||
|
||||
|
||||
class FeatureAccess(Enum):
|
||||
"""Class to represent different access scopes."""
|
||||
|
||||
read_only = "https://www.googleapis.com/auth/calendar.readonly"
|
||||
read_write = "https://www.googleapis.com/auth/calendar"
|
||||
|
||||
def __init__(self, scope: str) -> None:
|
||||
"""Init instance."""
|
||||
self._scope = scope
|
||||
|
||||
@property
|
||||
def scope(self) -> str:
|
||||
"""Google calendar scope for the feature."""
|
||||
return self._scope
|
@ -1,6 +1,8 @@
|
||||
{
|
||||
"domain": "google",
|
||||
"name": "Google Calendars",
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/calendar.google/",
|
||||
"requirements": [
|
||||
"google-api-python-client==2.38.0",
|
||||
|
31
homeassistant/components/google/strings.json
Normal file
31
homeassistant/components/google/strings.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The Nest integration needs to re-authenticate your account"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Link Google Account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"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.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"progress": {
|
||||
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
|
||||
}
|
||||
}
|
||||
}
|
31
homeassistant/components/google/translations/en.json
Normal file
31
homeassistant/components/google/translations/en.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Account is already configured",
|
||||
"already_in_progress": "Configuration flow is already in progress",
|
||||
"code_expired": "Authentication code expired, please try again.",
|
||||
"invalid_access_token": "Invalid access token",
|
||||
"missing_configuration": "The component is not configured. Please follow the documentation.",
|
||||
"oauth_error": "Received invalid token data.",
|
||||
"reauth_successful": "Re-authentication was successful"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "Successfully authenticated"
|
||||
},
|
||||
"progress": {
|
||||
"exchange": "To link your Google account, visit the [{url}]({url}) and enter code:\n\n{user_code}"
|
||||
},
|
||||
"step": {
|
||||
"auth": {
|
||||
"title": "Link Google Account"
|
||||
},
|
||||
"pick_implementation": {
|
||||
"title": "Pick Authentication Method"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"description": "The Nest integration needs to re-authenticate your account",
|
||||
"title": "Reauthenticate Integration"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -125,6 +125,7 @@ FLOWS = [
|
||||
"goalzero",
|
||||
"gogogate2",
|
||||
"goodwe",
|
||||
"google",
|
||||
"google_travel_time",
|
||||
"gpslogger",
|
||||
"gree",
|
||||
|
@ -18,6 +18,8 @@ from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
|
||||
|
||||
ApiResult = Callable[[dict[str, Any]], None]
|
||||
@ -156,6 +158,25 @@ async def storage() -> YieldFixture[FakeStorage]:
|
||||
yield storage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def config_entry(token_scopes: list[str]) -> MockConfigEntry:
|
||||
"""Fixture to create a config entry for the integration."""
|
||||
token_expiry = utcnow() + datetime.timedelta(days=7)
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": " ".join(token_scopes),
|
||||
"token_type": "Bearer",
|
||||
"expires_at": token_expiry.timestamp(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_token_read(
|
||||
hass: HomeAssistant,
|
||||
|
@ -53,10 +53,15 @@ TEST_EVENT = {
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_test_setup(
|
||||
mock_calendars_yaml, test_api_calendar, mock_calendars_list, mock_token_read
|
||||
hass,
|
||||
mock_calendars_yaml,
|
||||
test_api_calendar,
|
||||
mock_calendars_list,
|
||||
config_entry,
|
||||
):
|
||||
"""Fixture that pulls in the default fixtures for tests in this file."""
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
config_entry.add_to_hass(hass)
|
||||
return
|
||||
|
||||
|
||||
@ -300,12 +305,11 @@ async def test_update_error(
|
||||
assert state.name == TEST_ENTITY_NAME
|
||||
assert state.state == "on"
|
||||
|
||||
# Advance time to avoid throttling
|
||||
# Advance time beyond update/throttle point
|
||||
now += datetime.timedelta(minutes=30)
|
||||
with patch(
|
||||
"homeassistant.components.google.api.google_discovery.build"
|
||||
) as mock, patch("homeassistant.util.utcnow", return_value=now):
|
||||
|
||||
mock.return_value.events.return_value.list.return_value.execute.return_value = {
|
||||
"items": [
|
||||
{
|
||||
@ -417,3 +421,19 @@ async def test_opaque_event(
|
||||
assert response.status == HTTPStatus.OK
|
||||
events = await response.json()
|
||||
assert (len(events) > 0) == expect_visible_event
|
||||
|
||||
|
||||
async def test_scan_calendar_error(
|
||||
hass,
|
||||
calendar_resource,
|
||||
component_setup,
|
||||
test_api_calendar,
|
||||
):
|
||||
"""Test that the calendar update handles a server error."""
|
||||
with patch(
|
||||
"homeassistant.components.google.api.google_discovery.build",
|
||||
side_effect=httplib2.ServerNotFoundError("unit test"),
|
||||
):
|
||||
assert await component_setup()
|
||||
|
||||
assert not hass.states.get(TEST_ENTITY)
|
||||
|
350
tests/components/google/test_config_flow.py
Normal file
350
tests/components/google/test_config_flow.py
Normal file
@ -0,0 +1,350 @@
|
||||
"""Test the google config flow."""
|
||||
|
||||
import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from oauth2client.client import (
|
||||
FlowExchangeError,
|
||||
OAuth2Credentials,
|
||||
OAuth2DeviceCodeError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.google.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .conftest import 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)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def request_setup(current_request_with_host) -> None:
|
||||
"""Request setup."""
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def code_expiration_delta() -> datetime.timedelta:
|
||||
"""Fixture for code expiration time, defaulting to the future."""
|
||||
return datetime.timedelta(minutes=3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_code_flow(
|
||||
code_expiration_delta: datetime.timedelta,
|
||||
) -> YieldFixture[Mock]:
|
||||
"""Fixture for initiating OAuth flow."""
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
|
||||
) as mock_flow:
|
||||
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
|
||||
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
|
||||
yield mock_flow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
|
||||
"""Fixture for mocking out the exchange for credentials."""
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
async def fire_alarm(hass, point_in_time):
|
||||
"""Fire an alarm and wait for callbacks to run."""
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
mock_exchange: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test successful creds setup."""
|
||||
assert await component_setup()
|
||||
|
||||
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") == "Configuration.yaml"
|
||||
assert "data" in result
|
||||
data = result["data"]
|
||||
assert "token" in data
|
||||
data["token"].pop("expires_at")
|
||||
data["token"].pop("expires_in")
|
||||
assert data == {
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "https://www.googleapis.com/auth/calendar",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
}
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
|
||||
async def test_code_error(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test successful creds setup."""
|
||||
assert await component_setup()
|
||||
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
|
||||
side_effect=OAuth2DeviceCodeError("Test Failure"),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == "abort"
|
||||
assert result.get("reason") == "oauth_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
|
||||
async def test_expired_after_exchange(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test successful creds setup."""
|
||||
assert await component_setup()
|
||||
|
||||
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") == "code_expired"
|
||||
|
||||
|
||||
async def test_exchange_error(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
mock_exchange: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test an error while exchanging the code for credentials."""
|
||||
assert await component_setup()
|
||||
|
||||
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()
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
|
||||
side_effect=FlowExchangeError(),
|
||||
):
|
||||
now += CODE_CHECK_ALARM_TIMEDELTA
|
||||
await fire_alarm(hass, now)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Status has not updated, will retry
|
||||
result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"])
|
||||
assert result.get("type") == "progress"
|
||||
assert result.get("step_id") == "auth"
|
||||
|
||||
# Run another tick, which attempts credential exchange again
|
||||
with patch(
|
||||
"homeassistant.components.google.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
now += CODE_CHECK_ALARM_TIMEDELTA
|
||||
await fire_alarm(hass, now)
|
||||
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") == "Configuration.yaml"
|
||||
assert "data" in result
|
||||
data = result["data"]
|
||||
assert "token" in data
|
||||
data["token"].pop("expires_at")
|
||||
data["token"].pop("expires_in")
|
||||
assert data == {
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "https://www.googleapis.com/auth/calendar",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
}
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
|
||||
async def test_existing_config_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test can't configure when config entry already exists."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
assert await component_setup()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == "abort"
|
||||
assert result.get("reason") == "already_configured"
|
||||
|
||||
|
||||
async def test_missing_configuration(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test can't configure when config entry already exists."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result.get("type") == "abort"
|
||||
assert result.get("reason") == "missing_configuration"
|
||||
|
||||
|
||||
async def test_import_config_entry_from_existing_token(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test setup with an existing token file."""
|
||||
assert await component_setup()
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
data = entries[0].data
|
||||
assert "token" in data
|
||||
data["token"].pop("expires_at")
|
||||
data["token"].pop("expires_in")
|
||||
assert data == {
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "https://www.googleapis.com/auth/calendar",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
mock_exchange: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
) -> None:
|
||||
"""Test can't configure when config entry already exists."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {"access_token": "OLD_ACCESS_TOKEN"},
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
|
||||
assert await component_setup()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=config_entry.data
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flow_id=result["flow_id"],
|
||||
user_input={},
|
||||
)
|
||||
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") == "abort"
|
||||
assert result.get("reason") == "reauth_successful"
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
data = entries[0].data
|
||||
assert "token" in data
|
||||
data["token"].pop("expires_at")
|
||||
data["token"].pop("expires_in")
|
||||
assert data == {
|
||||
"auth_implementation": "device_auth",
|
||||
"token": {
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "https://www.googleapis.com/auth/calendar",
|
||||
"token_type": "Bearer",
|
||||
},
|
||||
}
|
||||
|
||||
assert len(mock_setup.mock_calls) == 1
|
@ -6,11 +6,6 @@ import datetime
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
|
||||
from oauth2client.client import (
|
||||
FlowExchangeError,
|
||||
OAuth2Credentials,
|
||||
OAuth2DeviceCodeError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.google import (
|
||||
@ -18,6 +13,7 @@ from homeassistant.components.google import (
|
||||
SERVICE_ADD_EVENT,
|
||||
SERVICE_SCAN_CALENDARS,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.util.dt import utcnow
|
||||
@ -30,73 +26,13 @@ from .conftest import (
|
||||
TEST_YAML_ENTITY_NAME,
|
||||
ApiResult,
|
||||
ComponentSetup,
|
||||
YieldFixture,
|
||||
)
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
# Typing helpers
|
||||
HassApi = Callable[[], Awaitable[dict[str, Any]]]
|
||||
|
||||
CODE_CHECK_INTERVAL = 1
|
||||
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def code_expiration_delta() -> datetime.timedelta:
|
||||
"""Fixture for code expiration time, defaulting to the future."""
|
||||
return datetime.timedelta(minutes=3)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_code_flow(
|
||||
code_expiration_delta: datetime.timedelta,
|
||||
) -> YieldFixture[Mock]:
|
||||
"""Fixture for initiating OAuth flow."""
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
|
||||
) as mock_flow:
|
||||
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
|
||||
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
|
||||
yield mock_flow
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]:
|
||||
"""Fixture for mocking out the exchange for credentials."""
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_notification() -> YieldFixture[Mock]:
|
||||
"""Fixture for capturing persistent notifications."""
|
||||
with patch("homeassistant.components.persistent_notification.create") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
async def fire_alarm(hass, point_in_time):
|
||||
"""Fire an alarm and wait for callbacks to run."""
|
||||
with patch("homeassistant.util.dt.utcnow", return_value=point_in_time):
|
||||
async_fire_time_changed(hass, point_in_time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", [{}])
|
||||
async def test_setup_config_empty(
|
||||
hass: HomeAssistant,
|
||||
component_setup: ComponentSetup,
|
||||
mock_notification: Mock,
|
||||
):
|
||||
"""Test setup component with an empty configuruation."""
|
||||
assert await component_setup()
|
||||
|
||||
mock_notification.assert_not_called()
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
|
||||
def assert_state(actual: State | None, expected: State | None) -> None:
|
||||
"""Assert that the two states are equal."""
|
||||
@ -108,118 +44,29 @@ def assert_state(actual: State | None, expected: State | None) -> None:
|
||||
assert actual.attributes == expected.attributes
|
||||
|
||||
|
||||
async def test_init_success(
|
||||
@pytest.fixture
|
||||
def setup_config_entry(
|
||||
hass: HomeAssistant, config_entry: MockConfigEntry
|
||||
) -> MockConfigEntry:
|
||||
"""Fixture to initialize the config entry."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
mock_exchange: Mock,
|
||||
mock_notification: Mock,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_calendars_yaml: None,
|
||||
component_setup: ComponentSetup,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test successful creds setup."""
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
assert await component_setup()
|
||||
"""Test load and unload of a ConfigEntry."""
|
||||
await component_setup()
|
||||
|
||||
# Run one tick to invoke the credential exchange check
|
||||
now = utcnow()
|
||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(TEST_YAML_ENTITY)
|
||||
assert state
|
||||
assert state.name == TEST_YAML_ENTITY_NAME
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
mock_notification.assert_called()
|
||||
assert "We are all setup now" in mock_notification.call_args[0][1]
|
||||
|
||||
|
||||
async def test_code_error(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
mock_notification: Mock,
|
||||
) -> None:
|
||||
"""Test loading the integration with no existing credentials."""
|
||||
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
|
||||
side_effect=OAuth2DeviceCodeError("Test Failure"),
|
||||
):
|
||||
assert await component_setup()
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
mock_notification.assert_called()
|
||||
assert "Error: Test Failure" in mock_notification.call_args[0][1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)])
|
||||
async def test_expired_after_exchange(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
mock_notification: Mock,
|
||||
) -> None:
|
||||
"""Test loading the integration with no existing credentials."""
|
||||
|
||||
assert await component_setup()
|
||||
|
||||
now = utcnow()
|
||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
mock_notification.assert_called()
|
||||
assert (
|
||||
"Authentication code expired, please restart Home-Assistant and try again"
|
||||
in mock_notification.call_args[0][1]
|
||||
)
|
||||
|
||||
|
||||
async def test_exchange_error(
|
||||
hass: HomeAssistant,
|
||||
mock_code_flow: Mock,
|
||||
component_setup: ComponentSetup,
|
||||
mock_notification: Mock,
|
||||
) -> None:
|
||||
"""Test an error while exchanging the code for credentials."""
|
||||
|
||||
with patch(
|
||||
"oauth2client.client.OAuth2WebServerFlow.step2_exchange",
|
||||
side_effect=FlowExchangeError(),
|
||||
):
|
||||
assert await component_setup()
|
||||
|
||||
now = utcnow()
|
||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
mock_notification.assert_called()
|
||||
assert "In order to authorize Home-Assistant" in mock_notification.call_args[0][1]
|
||||
|
||||
|
||||
async def test_existing_token(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_notification: Mock,
|
||||
) -> None:
|
||||
"""Test setup with an existing token file."""
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
assert await component_setup()
|
||||
|
||||
state = hass.states.get(TEST_YAML_ENTITY)
|
||||
assert state
|
||||
assert state.name == TEST_YAML_ENTITY_NAME
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
mock_notification.assert_not_called()
|
||||
assert await hass.config_entries.async_unload(entry.entry_id)
|
||||
assert entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -228,80 +75,61 @@ async def test_existing_token(
|
||||
async def test_existing_token_missing_scope(
|
||||
hass: HomeAssistant,
|
||||
token_scopes: list[str],
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_notification: Mock,
|
||||
mock_code_flow: Mock,
|
||||
mock_exchange: Mock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup where existing token does not have sufficient scopes."""
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
config_entry.add_to_hass(hass)
|
||||
assert await component_setup()
|
||||
|
||||
# Run one tick to invoke the credential exchange check
|
||||
now = utcnow()
|
||||
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
|
||||
assert len(mock_exchange.mock_calls) == 1
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
state = hass.states.get(TEST_YAML_ENTITY)
|
||||
assert state
|
||||
assert state.name == TEST_YAML_ENTITY_NAME
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
# No notifications on success
|
||||
mock_notification.assert_called()
|
||||
assert "We are all setup now" in mock_notification.call_args[0][1]
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert len(flows) == 1
|
||||
assert flows[0]["step_id"] == "reauth_confirm"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]])
|
||||
async def test_calendar_yaml_missing_required_fields(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
calendars_config: list[dict[str, Any]],
|
||||
mock_calendars_yaml: None,
|
||||
mock_notification: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup with a missing schema fields, ignores the error and continues."""
|
||||
assert await component_setup()
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
mock_notification.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]])
|
||||
async def test_invalid_calendar_yaml(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
calendars_config: list[dict[str, Any]],
|
||||
mock_calendars_yaml: None,
|
||||
mock_notification: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup with missing entity id fields fails to setup the integration."""
|
||||
|
||||
"""Test setup with missing entity id fields fails to setup the config entry."""
|
||||
# Integration fails to setup
|
||||
assert not await component_setup()
|
||||
assert await component_setup()
|
||||
|
||||
# XXX No config entries
|
||||
|
||||
assert not hass.states.get(TEST_YAML_ENTITY)
|
||||
|
||||
mock_notification.assert_not_called()
|
||||
|
||||
|
||||
async def test_calendar_yaml_error(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_notification: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test setup with yaml file not found."""
|
||||
|
||||
mock_calendars_list({"items": [test_api_calendar]})
|
||||
|
||||
with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()):
|
||||
@ -344,12 +172,12 @@ async def test_calendar_yaml_error(
|
||||
)
|
||||
async def test_track_new(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_calendars_yaml: None,
|
||||
expected_state: State,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test behavior of configuration.yaml settings for tracking new calendars not in the config."""
|
||||
|
||||
@ -363,11 +191,11 @@ async def test_track_new(
|
||||
@pytest.mark.parametrize("calendars_config", [[]])
|
||||
async def test_found_calendar_from_api(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test finding a calendar from the API."""
|
||||
|
||||
@ -402,13 +230,13 @@ async def test_found_calendar_from_api(
|
||||
)
|
||||
async def test_calendar_config_track_new(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_yaml: None,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
calendars_config_track: bool,
|
||||
expected_state: State,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test calendar config that overrides whether or not a calendar is tracked."""
|
||||
|
||||
@ -421,11 +249,11 @@ async def test_calendar_config_track_new(
|
||||
|
||||
async def test_add_event(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_insert_event: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call that adds an event."""
|
||||
|
||||
@ -471,7 +299,6 @@ async def test_add_event(
|
||||
)
|
||||
async def test_add_event_date_in_x(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
@ -479,6 +306,7 @@ async def test_add_event_date_in_x(
|
||||
date_fields: dict[str, Any],
|
||||
start_timedelta: datetime.timedelta,
|
||||
end_timedelta: datetime.timedelta,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call that adds an event with various time ranges."""
|
||||
|
||||
@ -514,10 +342,10 @@ async def test_add_event_date_in_x(
|
||||
|
||||
async def test_add_event_date(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
mock_insert_event: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call that sets a date range."""
|
||||
|
||||
@ -554,11 +382,11 @@ async def test_add_event_date(
|
||||
|
||||
async def test_add_event_date_time(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
mock_insert_event: Mock,
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test service call that adds an event with a date time range."""
|
||||
|
||||
@ -601,10 +429,10 @@ async def test_add_event_date_time(
|
||||
|
||||
async def test_scan_calendars(
|
||||
hass: HomeAssistant,
|
||||
mock_token_read: None,
|
||||
component_setup: ComponentSetup,
|
||||
mock_calendars_list: ApiResult,
|
||||
test_api_calendar: dict[str, Any],
|
||||
setup_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test finding a calendar from the API."""
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user