From 3f8c896cb20526ef59e285b1f0eeb9b4e734efee Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 May 2022 11:22:27 -0700 Subject: [PATCH] Set user friendly name for Google Calendar config entry (#72243) * Set user friendly name for Google Calendar config entry * Add a new auth implementation for use during the config flow --- homeassistant/components/google/api.py | 22 +++++++ .../components/google/config_flow.py | 18 ++++- tests/components/google/conftest.py | 30 +++++++-- tests/components/google/test_config_flow.py | 66 ++++++++++++++++++- 4 files changed, 128 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index dceeb6ba6a4..3ce21fbb03d 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -173,3 +173,25 @@ class ApiAuthImpl(AbstractAuth): """Return a valid access token.""" await self._session.async_ensure_token_valid() return self._session.token["access_token"] + + +class AccessTokenAuthImpl(AbstractAuth): + """Authentication implementation used during config flow, without refresh. + + This exists to allow the config flow to use the API before it has fully + created a config entry required by OAuth2Session. This does not support + refreshing tokens, which is fine since it should have been just created. + """ + + def __init__( + self, + websession: aiohttp.ClientSession, + access_token: str, + ) -> None: + """Init the Google Calendar client library auth implementation.""" + super().__init__(websession) + self._access_token = access_token + + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self._access_token diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index f5e567a5272..3b513f17197 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -4,13 +4,17 @@ from __future__ import annotations import logging from typing import Any +from gcal_sync.api import GoogleCalendarService +from gcal_sync.exceptions import ApiException from oauth2client.client import Credentials from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .api import ( DEVICE_AUTH_CREDS, + AccessTokenAuthImpl, DeviceAuth, DeviceFlow, OAuthError, @@ -130,7 +134,19 @@ class OAuth2FlowHandler( 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) + + calendar_service = GoogleCalendarService( + AccessTokenAuthImpl( + async_get_clientsession(self.hass), data["token"]["access_token"] + ) + ) + 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 + return self.async_create_entry(title=title, data=data) async def async_step_reauth( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index f48996de72a..48badbc3ab5 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -6,6 +6,7 @@ import datetime from typing import Any, Generator, TypeVar from unittest.mock import mock_open, patch +from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL from oauth2client.client import Credentials, OAuth2Credentials import pytest @@ -207,7 +208,9 @@ def mock_events_list( """Fixture to construct a fake event list API response.""" def _put_result( - response: dict[str, Any], calendar_id: str = None, exc: Exception = None + response: dict[str, Any], + calendar_id: str = None, + exc: ClientError | None = None, ) -> None: if calendar_id is None: calendar_id = CALENDAR_ID @@ -240,7 +243,7 @@ def mock_calendars_list( ) -> ApiResult: """Fixture to construct a fake calendar list API response.""" - def _put_result(response: dict[str, Any], exc=None) -> None: + def _result(response: dict[str, Any], exc: ClientError | None = None) -> None: aioclient_mock.get( f"{API_BASE_URL}/users/me/calendarList", json=response, @@ -248,13 +251,32 @@ def mock_calendars_list( ) return - return _put_result + return _result + + +@pytest.fixture +def mock_calendar_get( + aioclient_mock: AiohttpClientMocker, +) -> Callable[[...], None]: + """Fixture for returning a calendar get response.""" + + def _result( + calendar_id: str, response: dict[str, Any], exc: ClientError | None = None + ) -> None: + aioclient_mock.get( + f"{API_BASE_URL}/calendars/{calendar_id}", + json=response, + exc=exc, + ) + return + + return _result @pytest.fixture def mock_insert_event( aioclient_mock: AiohttpClientMocker, -) -> Callable[[..., dict[str, Any]], None]: +) -> Callable[[...], None]: """Fixture for capturing event creation.""" def _expect_result(calendar_id: str = CALENDAR_ID) -> None: diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 88a090773bc..625d1faa937 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -1,9 +1,13 @@ """Test the google config flow.""" +from __future__ import annotations + +from collections.abc import Callable import datetime from typing import Any from unittest.mock import Mock, patch +from aiohttp.client_exceptions import ClientError from oauth2client.client import ( FlowExchangeError, OAuth2Credentials, @@ -27,6 +31,7 @@ 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) @@ -63,6 +68,24 @@ async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: yield mock +@pytest.fixture +async def primary_calendar_error() -> ClientError | None: + """Fixture for tests to inject an error during calendar lookup.""" + return None + + +@pytest.fixture(autouse=True) +async def primary_calendar( + mock_calendar_get: Callable[[...], None], primary_calendar_error: ClientError | None +) -> None: + """Fixture to return the primary calendar.""" + mock_calendar_get( + "primary", + {"id": EMAIL_ADDRESS, "summary": "Personal"}, + exc=primary_calendar_error, + ) + + 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): @@ -99,7 +122,7 @@ async def test_full_flow_yaml_creds( ) assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" + assert result.get("title") == EMAIL_ADDRESS assert "data" in result data = result["data"] assert "token" in data @@ -161,7 +184,7 @@ async def test_full_flow_application_creds( ) assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" + assert result.get("title") == EMAIL_ADDRESS assert "data" in result data = result["data"] assert "token" in data @@ -278,7 +301,7 @@ async def test_exchange_error( ) assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" + assert result.get("title") == EMAIL_ADDRESS assert "data" in result data = result["data"] assert "token" in data @@ -463,3 +486,40 @@ async def test_reauth_flow( } assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.parametrize("primary_calendar_error", [ClientError()]) +async def test_title_lookup_failure( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + component_setup: ComponentSetup, +) -> None: + """Test successful config flow and title fetch fails gracefully.""" + 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") == "Import from configuration.yaml" + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1