diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 80c6ce2b811..98eadead101 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -8,7 +8,9 @@ import logging from typing import Any import aiohttp -from httplib2.error import ServerNotFoundError +from gcal_sync.api import GoogleCalendarService +from gcal_sync.exceptions import ApiException +from gcal_sync.model import DateOrDatetime, Event from oauth2client.file import Storage import voluptuous as vol from voluptuous.error import Error as VoluptuousError @@ -31,13 +33,14 @@ from homeassistant.exceptions import ( HomeAssistantError, ) from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession 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.typing import ConfigType from . import config_flow -from .api import DeviceAuth, GoogleCalendarService +from .api import ApiAuthImpl, DeviceAuth from .const import ( CONF_CALENDAR_ACCESS, DATA_CONFIG, @@ -212,7 +215,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed( "Required scopes are not available, reauth required" ) - calendar_service = GoogleCalendarService(hass, session) + calendar_service = GoogleCalendarService( + ApiAuthImpl(async_get_clientsession(hass), session) + ) hass.data[DOMAIN][DATA_SERVICE] = calendar_service await async_setup_services(hass, hass.data[DOMAIN][DATA_CONFIG], calendar_service) @@ -263,11 +268,12 @@ async def async_setup_services( async def _scan_for_calendars(call: ServiceCall) -> None: """Scan for new calendars.""" try: - calendars = await calendar_service.async_list_calendars() - except ServerNotFoundError as err: + result = await calendar_service.async_list_calendars() + except ApiException as err: raise HomeAssistantError(str(err)) from err tasks = [] - for calendar in calendars: + for calendar_item in result.items: + calendar = calendar_item.dict(exclude_unset=True) calendar[CONF_TRACK] = config[CONF_TRACK_NEW] tasks.append( hass.services.async_call(DOMAIN, SERVICE_FOUND_CALENDARS, calendar) @@ -278,8 +284,8 @@ async def async_setup_services( async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" - start = {} - end = {} + start: DateOrDatetime | None = None + end: DateOrDatetime | None = None if EVENT_IN in call.data: if EVENT_IN_DAYS in call.data[EVENT_IN]: @@ -288,8 +294,8 @@ async def async_setup_services( start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) end_in = start_in + timedelta(days=1) - start = {"date": start_in.strftime("%Y-%m-%d")} - end = {"date": end_in.strftime("%Y-%m-%d")} + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) elif EVENT_IN_WEEKS in call.data[EVENT_IN]: now = datetime.now() @@ -297,29 +303,34 @@ async def async_setup_services( start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) end_in = start_in + timedelta(days=1) - start = {"date": start_in.strftime("%Y-%m-%d")} - end = {"date": end_in.strftime("%Y-%m-%d")} + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) elif EVENT_START_DATE in call.data: - start = {"date": str(call.data[EVENT_START_DATE])} - end = {"date": str(call.data[EVENT_END_DATE])} + start = DateOrDatetime(date=call.data[EVENT_START_DATE]) + end = DateOrDatetime(date=call.data[EVENT_END_DATE]) elif EVENT_START_DATETIME in call.data: - start_dt = str( - call.data[EVENT_START_DATETIME].strftime("%Y-%m-%dT%H:%M:%S") + start_dt = call.data[EVENT_START_DATETIME] + end_dt = call.data[EVENT_END_DATETIME] + start = DateOrDatetime( + date_time=start_dt, timezone=str(hass.config.time_zone) + ) + end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone)) + + if start is None or end is None: + raise ValueError( + "Missing required fields to set start or end date/datetime" ) - end_dt = str(call.data[EVENT_END_DATETIME].strftime("%Y-%m-%dT%H:%M:%S")) - start = {"dateTime": start_dt, "timeZone": str(hass.config.time_zone)} - end = {"dateTime": end_dt, "timeZone": str(hass.config.time_zone)} await calendar_service.async_create_event( call.data[EVENT_CALENDAR_ID], - { - "summary": call.data[EVENT_SUMMARY], - "description": call.data[EVENT_DESCRIPTION], - "start": start, - "end": end, - }, + Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ), ) # Only expose the add event service if we have the correct permissions diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 80c66d7af0c..5de563393e1 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -8,13 +8,13 @@ import logging import time from typing import Any -from googleapiclient import discovery as google_discovery +import aiohttp +from gcal_sync.auth import AbstractAuth import oauth2client from oauth2client.client import ( Credentials, DeviceFlowInfo, FlowExchangeError, - OAuth2Credentials, OAuth2DeviceCodeError, OAuth2WebServerFlow, ) @@ -150,95 +150,19 @@ async def async_create_device_flow(hass: HomeAssistant) -> DeviceFlow: 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=datetime.datetime.fromtimestamp(token["expires_at"]), - token_uri=oauth2client.GOOGLE_TOKEN_URI, - scopes=[conf[CONF_CALENDAR_ACCESS].scope], - user_agent=None, - ) - - -def _api_time_format(date_time: datetime.datetime | None) -> str | None: - """Convert a datetime to the api string format.""" - return date_time.isoformat("T") if date_time else None - - -class GoogleCalendarService: - """Calendar service interface to Google.""" +class ApiAuthImpl(AbstractAuth): + """Authentication implementation for google calendar api library.""" def __init__( - self, hass: HomeAssistant, session: config_entry_oauth2_flow.OAuth2Session + self, + websession: aiohttp.ClientSession, + session: config_entry_oauth2_flow.OAuth2Session, ) -> None: - """Init the Google Calendar service.""" - self._hass = hass + """Init the Google Calendar client library auth implementation.""" + super().__init__(websession) self._session = session - async def _async_get_service(self) -> google_discovery.Resource: - """Get the calendar service with valid credetnails.""" + async def async_get_access_token(self) -> str: + """Return a valid access token.""" await self._session.async_ensure_token_valid() - creds = _async_google_creds(self._hass, self._session.token) - - def _build() -> google_discovery.Resource: - return google_discovery.build( - "calendar", "v3", credentials=creds, cache_discovery=False - ) - - return await self._hass.async_add_executor_job(_build) - - async def async_list_calendars( - self, - ) -> list[dict[str, Any]]: - """Return the list of calendars the user has added to their list.""" - service = await self._async_get_service() - - def _list_calendars() -> list[dict[str, Any]]: - cal_list = service.calendarList() - 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() - 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, - 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.""" - service = await self._async_get_service() - - def _list_events() -> tuple[list[dict[str, Any]], str | None]: - events = service.events() - 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) + return self._session.token["access_token"] diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 73071e8a13d..2339dc3f65d 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -2,11 +2,13 @@ from __future__ import annotations import copy -from datetime import date, datetime, timedelta +from datetime import datetime, timedelta import logging from typing import Any -from httplib2 import ServerNotFoundError +from gcal_sync.api import GoogleCalendarService, ListEventsRequest +from gcal_sync.exceptions import ApiException +from gcal_sync.model import Event from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, @@ -22,7 +24,7 @@ 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.util import Throttle, dt +from homeassistant.util import Throttle from . import ( CONF_CAL_ID, @@ -34,7 +36,6 @@ from . import ( DOMAIN, SERVICE_SCAN_CALENDARS, ) -from .api import GoogleCalendarService from .const import DISCOVER_CALENDAR _LOGGER = logging.getLogger(__name__) @@ -147,77 +148,66 @@ class GoogleCalendarEntity(CalendarEntity): """Return the name of the entity.""" return self._name - def _event_filter(self, event: dict[str, Any]) -> bool: + def _event_filter(self, event: Event) -> bool: """Return True if the event is visible.""" if self._ignore_availability: return True - return event.get(TRANSPARENCY, OPAQUE) == OPAQUE + return event.transparency == OPAQUE async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime ) -> list[CalendarEvent]: """Get all events in a specific time frame.""" event_list: list[dict[str, Any]] = [] - page_token: str | None = None + + request = ListEventsRequest( + calendar_id=self._calendar_id, + start_time=start_date, + end_time=end_date, + search=self._search, + ) while True: try: - items, page_token = await self._calendar_service.async_list_events( - self._calendar_id, - start_time=start_date, - end_time=end_date, - search=self._search, - page_token=page_token, - ) - except ServerNotFoundError as err: + result = await self._calendar_service.async_list_events(request) + except ApiException as err: _LOGGER.error("Unable to connect to Google: %s", err) return [] - event_list.extend(filter(self._event_filter, items)) - if not page_token: + event_list.extend(filter(self._event_filter, result.items)) + if not result.page_token: break + request.page_token = result.page_token + return [_get_calendar_event(event) for event in event_list] @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Get the latest data.""" + request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search) try: - items, _ = await self._calendar_service.async_list_events( - self._calendar_id, search=self._search - ) - except ServerNotFoundError as err: + result = await self._calendar_service.async_list_events(request) + except ApiException as err: _LOGGER.error("Unable to connect to Google: %s", err) return # Pick the first visible event and apply offset calculations. - valid_items = filter(self._event_filter, items) + valid_items = filter(self._event_filter, result.items) event = copy.deepcopy(next(valid_items, None)) if event: - (summary, offset) = extract_offset(event.get("summary", ""), self._offset) - event["summary"] = summary + (event.summary, offset) = extract_offset(event.summary, self._offset) self._event = _get_calendar_event(event) self._offset_value = offset else: self._event = None -def _get_date_or_datetime(date_dict: dict[str, str]) -> datetime | date: - """Convert a google calendar API response to a datetime or date object.""" - if "date" in date_dict: - parsed_date = dt.parse_date(date_dict["date"]) - assert parsed_date - return parsed_date - parsed_datetime = dt.parse_datetime(date_dict["dateTime"]) - assert parsed_datetime - return parsed_datetime - - -def _get_calendar_event(event: dict[str, Any]) -> CalendarEvent: +def _get_calendar_event(event: Event) -> CalendarEvent: """Return a CalendarEvent from an API event.""" return CalendarEvent( - summary=event["summary"], - start=_get_date_or_datetime(event["start"]), - end=_get_date_or_datetime(event["end"]), - description=event.get("description"), - location=event.get("location"), + summary=event.summary, + start=event.start.value, + end=event.end.value, + description=event.description, + location=event.location, ) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 07ebaa6f96e..eff69befb37 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -4,11 +4,7 @@ "config_flow": true, "dependencies": ["auth"], "documentation": "https://www.home-assistant.io/integrations/calendar.google/", - "requirements": [ - "google-api-python-client==2.38.0", - "httplib2==0.20.4", - "oauth2client==4.1.3" - ], + "requirements": ["gcal-sync==0.5.0", "oauth2client==4.1.3"], "codeowners": ["@allenporter"], "iot_class": "cloud_polling", "loggers": ["googleapiclient"] diff --git a/requirements_all.txt b/requirements_all.txt index 879d6fa502d..16519a2fb39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -678,6 +678,9 @@ gTTS==2.2.4 # homeassistant.components.garages_amsterdam garages-amsterdam==3.0.0 +# homeassistant.components.google +gcal-sync==0.5.0 + # homeassistant.components.geniushub geniushub-client==0.6.30 @@ -718,9 +721,6 @@ goalzero==0.2.1 # homeassistant.components.goodwe goodwe==0.2.15 -# homeassistant.components.google -google-api-python-client==2.38.0 - # homeassistant.components.google_pubsub google-cloud-pubsub==2.11.0 @@ -827,7 +827,6 @@ homepluscontrol==0.0.5 # homeassistant.components.horizon horimote==0.4.1 -# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 863de8ba499..dc3ba747bb8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -475,6 +475,9 @@ gTTS==2.2.4 # homeassistant.components.garages_amsterdam garages-amsterdam==3.0.0 +# homeassistant.components.google +gcal-sync==0.5.0 + # homeassistant.components.usgs_earthquakes_feed geojson_client==0.6 @@ -509,9 +512,6 @@ goalzero==0.2.1 # homeassistant.components.goodwe goodwe==0.2.15 -# homeassistant.components.google -google-api-python-client==2.38.0 - # homeassistant.components.google_pubsub google-cloud-pubsub==2.11.0 @@ -585,7 +585,6 @@ homematicip==1.0.2 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 -# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index b468762ae8f..9594963008f 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -4,9 +4,9 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime from typing import Any, Generator, TypeVar -from unittest.mock import Mock, mock_open, patch +from unittest.mock import mock_open, patch -from googleapiclient import discovery as google_discovery +from gcal_sync.auth import API_BASE_URL from oauth2client.client import Credentials, OAuth2Credentials import pytest import yaml @@ -18,6 +18,7 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker ApiResult = Callable[[dict[str, Any]], None] ComponentSetup = Callable[[], Awaitable[bool]] @@ -198,22 +199,21 @@ def mock_token_read( storage.put(creds) -@pytest.fixture(autouse=True) -def calendar_resource() -> YieldFixture[google_discovery.Resource]: - """Fixture to mock out the Google discovery API.""" - with patch("homeassistant.components.google.api.google_discovery.build") as mock: - yield mock - - @pytest.fixture def mock_events_list( - calendar_resource: google_discovery.Resource, -) -> Callable[[dict[str, Any]], None]: + aioclient_mock: AiohttpClientMocker, +) -> ApiResult: """Fixture to construct a fake event list API response.""" - def _put_result(response: dict[str, Any]) -> None: - calendar_resource.return_value.events.return_value.list.return_value.execute.return_value = ( - response + def _put_result( + response: dict[str, Any], calendar_id: str = None, exc: Exception = None + ) -> None: + if calendar_id is None: + calendar_id = CALENDAR_ID + aioclient_mock.get( + f"{API_BASE_URL}/calendars/{calendar_id}/events", + json=response, + exc=exc, ) return @@ -235,13 +235,15 @@ def mock_events_list_items( @pytest.fixture def mock_calendars_list( - calendar_resource: google_discovery.Resource, + aioclient_mock: AiohttpClientMocker, ) -> ApiResult: """Fixture to construct a fake calendar list API response.""" - def _put_result(response: dict[str, Any]) -> None: - calendar_resource.return_value.calendarList.return_value.list.return_value.execute.return_value = ( - response + def _put_result(response: dict[str, Any], exc=None) -> None: + aioclient_mock.get( + f"{API_BASE_URL}/users/me/calendarList", + json=response, + exc=exc, ) return @@ -250,12 +252,17 @@ def mock_calendars_list( @pytest.fixture def mock_insert_event( - calendar_resource: google_discovery.Resource, -) -> Mock: - """Fixture to create a mock to capture new events added to the API.""" - insert_mock = Mock() - calendar_resource.return_value.events.return_value.insert = insert_mock - return insert_mock + aioclient_mock: AiohttpClientMocker, +) -> Callable[[..., dict[str, Any]], None]: + """Fixture for capturing event creation.""" + + def _expect_result(calendar_id: str = CALENDAR_ID) -> None: + aioclient_mock.post( + f"{API_BASE_URL}/calendars/{calendar_id}/events", + ) + return + + return _expect_result @pytest.fixture(autouse=True) diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index dda4cddc962..a5721462e7c 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import patch import urllib -import httplib2 +from aiohttp.client_exceptions import ClientError import pytest from homeassistant.const import STATE_OFF, STATE_ON @@ -302,16 +302,19 @@ async def test_missing_summary(hass, mock_events_list_items, component_setup): async def test_update_error( - hass, calendar_resource, component_setup, test_api_calendar + hass, + component_setup, + mock_calendars_list, + mock_events_list, + test_api_calendar, + aioclient_mock, ): """Test that the calendar update handles a server error.""" now = dt_util.now() - with patch("homeassistant.components.google.api.google_discovery.build") as mock: - mock.return_value.calendarList.return_value.list.return_value.execute.return_value = { - "items": [test_api_calendar] - } - mock.return_value.events.return_value.list.return_value.execute.return_value = { + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list( + { "items": [ { **TEST_EVENT, @@ -324,7 +327,8 @@ async def test_update_error( } ] } - assert await component_setup() + ) + assert await component_setup() state = hass.states.get(TEST_ENTITY) assert state.name == TEST_ENTITY_NAME @@ -332,10 +336,11 @@ async def test_update_error( # Advance time to avoid throttling now += datetime.timedelta(minutes=30) - with patch( - "homeassistant.components.google.api.google_discovery.build", - side_effect=httplib2.ServerNotFoundError("unit test"), - ), patch("homeassistant.util.utcnow", return_value=now): + + aioclient_mock.clear_requests() + mock_events_list({}, exc=ClientError()) + + with patch("homeassistant.util.utcnow", return_value=now): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -346,10 +351,10 @@ async def test_update_error( # 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 = { + + aioclient_mock.clear_requests() + mock_events_list( + { "items": [ { **TEST_EVENT, @@ -362,6 +367,9 @@ async def test_update_error( } ] } + ) + + with patch("homeassistant.util.utcnow", return_value=now): async_fire_time_changed(hass, now) await hass.async_block_till_done() @@ -371,8 +379,11 @@ async def test_update_error( assert state.state == "off" -async def test_calendars_api(hass, hass_client, component_setup): +async def test_calendars_api( + hass, hass_client, component_setup, mock_events_list_items +): """Test the Rest API returns the calendar.""" + mock_events_list_items([]) assert await component_setup() client = await hass_client() @@ -388,14 +399,21 @@ async def test_calendars_api(hass, hass_client, component_setup): async def test_http_event_api_failure( - hass, hass_client, calendar_resource, component_setup + hass, + hass_client, + component_setup, + mock_calendars_list, + mock_events_list, + aioclient_mock, ): """Test the Rest API response during a calendar failure.""" + mock_events_list({}) assert await component_setup() client = await hass_client() - calendar_resource.side_effect = httplib2.ServerNotFoundError("unit test") + aioclient_mock.clear_requests() + mock_events_list({}, exc=ClientError()) response = await client.get(upcoming_event_url()) assert response.status == HTTPStatus.OK @@ -493,16 +511,14 @@ async def test_opaque_event( async def test_scan_calendar_error( hass, - calendar_resource, component_setup, test_api_calendar, + mock_calendars_list, ): """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() + + mock_calendars_list({}, exc=ClientError()) + assert await component_setup() assert not hass.states.get(TEST_ENTITY) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 41a9b0a6466..c536ef7ea00 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -6,7 +6,7 @@ import datetime import http import time from typing import Any -from unittest.mock import Mock, call, patch +from unittest.mock import patch import pytest @@ -134,10 +134,12 @@ async def test_calendar_yaml_error( component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, ) -> None: """Test setup with yaml file not found.""" mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) with patch("homeassistant.components.google.open", side_effect=FileNotFoundError()): assert await component_setup() @@ -182,6 +184,7 @@ async def test_track_new( component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, mock_calendars_yaml: None, expected_state: State, setup_config_entry: MockConfigEntry, @@ -189,6 +192,7 @@ async def test_track_new( """Test behavior of configuration.yaml settings for tracking new calendars not in the config.""" mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() state = hass.states.get(TEST_API_ENTITY) @@ -202,11 +206,13 @@ async def test_found_calendar_from_api( mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, ) -> None: """Test finding a calendar from the API.""" mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() state = hass.states.get(TEST_API_ENTITY) @@ -240,6 +246,7 @@ async def test_calendar_config_track_new( component_setup: ComponentSetup, mock_calendars_yaml: None, mock_calendars_list: ApiResult, + mock_events_list: ApiResult, test_api_calendar: dict[str, Any], calendars_config_track: bool, expected_state: State, @@ -248,44 +255,35 @@ async def test_calendar_config_track_new( """Test calendar config that overrides whether or not a calendar is tracked.""" mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() state = hass.states.get(TEST_YAML_ENTITY) assert_state(state, expected_state) -async def test_add_event( +async def test_add_event_missing_required_fields( hass: HomeAssistant, 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.""" + """Test service call that adds an event missing required fields.""" assert await component_setup() - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - }, - blocking=True, - ) - mock_insert_event.assert_called() - assert mock_insert_event.mock_calls[0] == call( - calendarId=CALENDAR_ID, - body={ - "summary": "Summary", - "description": "Description", - "start": {}, - "end": {}, - }, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_EVENT, + { + "calendar_id": CALENDAR_ID, + "summary": "Summary", + "description": "Description", + }, + blocking=True, + ) @pytest.mark.parametrize( @@ -308,17 +306,27 @@ async def test_add_event_date_in_x( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, + mock_insert_event: Callable[[..., dict[str, Any]], None], test_api_calendar: dict[str, Any], - mock_insert_event: Mock, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, setup_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call that adds an event with various time ranges.""" + mock_calendars_list({}) assert await component_setup() + now = datetime.datetime.now() + start_date = now + start_timedelta + end_date = now + end_timedelta + + mock_insert_event( + calendar_id=CALENDAR_ID, + ) + await hass.services.async_call( DOMAIN, SERVICE_ADD_EVENT, @@ -330,38 +338,36 @@ async def test_add_event_date_in_x( }, blocking=True, ) - mock_insert_event.assert_called() - - now = datetime.datetime.now() - start_date = now + start_timedelta - end_date = now + end_timedelta - - assert mock_insert_event.mock_calls[0] == call( - calendarId=CALENDAR_ID, - body={ - "summary": "Summary", - "description": "Description", - "start": {"date": start_date.date().isoformat()}, - "end": {"date": end_date.date().isoformat()}, - }, - ) + assert len(aioclient_mock.mock_calls) == 2 + assert aioclient_mock.mock_calls[1][2] == { + "summary": "Summary", + "description": "Description", + "start": {"date": start_date.date().isoformat()}, + "end": {"date": end_date.date().isoformat()}, + } async def test_add_event_date( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, - mock_insert_event: Mock, + mock_insert_event: Callable[[str, dict[str, Any]], None], setup_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call that sets a date range.""" + mock_calendars_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) + mock_insert_event( + calendar_id=CALENDAR_ID, + ) + await hass.services.async_call( DOMAIN, SERVICE_ADD_EVENT, @@ -374,35 +380,37 @@ async def test_add_event_date( }, blocking=True, ) - mock_insert_event.assert_called() - - assert mock_insert_event.mock_calls[0] == call( - calendarId=CALENDAR_ID, - body={ - "summary": "Summary", - "description": "Description", - "start": {"date": today.isoformat()}, - "end": {"date": end_date.isoformat()}, - }, - ) + assert len(aioclient_mock.mock_calls) == 2 + assert aioclient_mock.mock_calls[1][2] == { + "summary": "Summary", + "description": "Description", + "start": {"date": today.isoformat()}, + "end": {"date": end_date.isoformat()}, + } async def test_add_event_date_time( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, + mock_insert_event: Callable[[str, dict[str, Any]], None], test_api_calendar: dict[str, Any], - mock_insert_event: Mock, setup_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test service call that adds an event with a date time range.""" + mock_calendars_list({}) assert await component_setup() start_datetime = datetime.datetime.now() delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta + mock_insert_event( + calendar_id=CALENDAR_ID, + ) + await hass.services.async_call( DOMAIN, SERVICE_ADD_EVENT, @@ -415,34 +423,32 @@ async def test_add_event_date_time( }, blocking=True, ) - mock_insert_event.assert_called() - - assert mock_insert_event.mock_calls[0] == call( - calendarId=CALENDAR_ID, - body={ - "summary": "Summary", - "description": "Description", - "start": { - "dateTime": start_datetime.isoformat(timespec="seconds"), - "timeZone": "America/Regina", - }, - "end": { - "dateTime": end_datetime.isoformat(timespec="seconds"), - "timeZone": "America/Regina", - }, + assert len(aioclient_mock.mock_calls) == 2 + assert aioclient_mock.mock_calls[1][2] == { + "summary": "Summary", + "description": "Description", + "start": { + "dateTime": start_datetime.isoformat(timespec="seconds"), + "timeZone": "America/Regina", }, - ) + "end": { + "dateTime": end_datetime.isoformat(timespec="seconds"), + "timeZone": "America/Regina", + }, + } async def test_scan_calendars( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test finding a calendar from the API.""" + mock_calendars_list({"items": []}) assert await component_setup() calendar_1 = { @@ -454,7 +460,9 @@ async def test_scan_calendars( "summary": "Calendar 2", } + aioclient_mock.clear_requests() mock_calendars_list({"items": [calendar_1]}) + mock_events_list({}, calendar_id="calendar-id-1") await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) await hass.async_block_till_done() @@ -464,7 +472,10 @@ async def test_scan_calendars( assert state.state == STATE_OFF assert not hass.states.get("calendar.calendar_2") + aioclient_mock.clear_requests() mock_calendars_list({"items": [calendar_1, calendar_2]}) + mock_events_list({}, calendar_id="calendar-id-1") + mock_events_list({}, calendar_id="calendar-id-2") await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) await hass.async_block_till_done()