Move google calendar integration to aiohttp (#70173)

* Use new aiohttp based google client library in gcal_sync.

* Use base url in tests for shorter string

* Remove unnecessary line of code

* Jump to gcal-sync-0.4.1

* Update tests/components/google/conftest.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update to gcal_sync 0.5.0 incorporating PR feedback

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter
2022-04-20 20:18:24 -07:00
committed by GitHub
parent b8369f79eb
commit 0e0c0ce22b
9 changed files with 239 additions and 286 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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