Improve google calendar test coverage to 97% (#65223)

* Improve google calendar test coverage to 97%

* Remove commented out code.

* Remove unnecessary (flaky) checks for token file persistence

* Remove mock code assertions

* Add debug logging to google calendar integration

* Increase alarm time to polling code to reduce flakes

* Setup every test in their own configuration directory

* Mock out filesystem calls to avoid disk dependencies

Update scope checking code to use Storage object rather than text file matching

* Update tests to check entity states when integration is loaded

* Mock out google service in multiple locations

* Update homeassistant/components/google/__init__.py

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

* Update homeassistant/components/google/__init__.py

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

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
pull/65352/head
Allen Porter 2022-01-31 18:14:49 -08:00 committed by GitHub
parent 5289935ac1
commit 88ed2f3b3e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 544 additions and 79 deletions

View File

@ -407,7 +407,6 @@ omit =
homeassistant/components/goodwe/number.py homeassistant/components/goodwe/number.py
homeassistant/components/goodwe/select.py homeassistant/components/goodwe/select.py
homeassistant/components/goodwe/sensor.py homeassistant/components/goodwe/sensor.py
homeassistant/components/google/__init__.py
homeassistant/components/google_cloud/tts.py homeassistant/components/google_cloud/tts.py
homeassistant/components/google_maps/device_tracker.py homeassistant/components/google_maps/device_tracker.py
homeassistant/components/google_pubsub/__init__.py homeassistant/components/google_pubsub/__init__.py

View File

@ -194,6 +194,7 @@ def do_authentication(hass, hass_config, config):
def step2_exchange(now): def step2_exchange(now):
"""Keep trying to validate the user_code until it expires.""" """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 # 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. # object without tzinfo. For the comparison below to work, it needs one.
@ -208,6 +209,7 @@ def do_authentication(hass, hass_config, config):
notification_id=NOTIFICATION_ID, notification_id=NOTIFICATION_ID,
) )
listener() listener()
return
try: try:
credentials = oauth.step2_exchange(device_flow_info=dev_flow) credentials = oauth.step2_exchange(device_flow_info=dev_flow)
@ -247,9 +249,11 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
token_file = hass.config.path(TOKEN_FILE) token_file = hass.config.path(TOKEN_FILE)
if not os.path.isfile(token_file): if not os.path.isfile(token_file):
_LOGGER.debug("Token file does not exist, authenticating for first time")
do_authentication(hass, config, conf) do_authentication(hass, config, conf)
else: else:
if not check_correct_scopes(token_file, conf): if not check_correct_scopes(hass, token_file, conf):
_LOGGER.debug("Existing scopes are not sufficient, re-authenticating")
do_authentication(hass, config, conf) do_authentication(hass, config, conf)
else: else:
do_setup(hass, config, conf) do_setup(hass, config, conf)
@ -257,17 +261,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
def check_correct_scopes(token_file, config): def check_correct_scopes(hass, token_file, config):
"""Check for the correct scopes in file.""" """Check for the correct scopes in file."""
with open(token_file, encoding="utf8") as tokenfile: creds = Storage(token_file).get()
contents = tokenfile.read() if not creds or not creds.scopes:
return False
# Check for quoted scope as our scopes can be subsets of other scopes target_scope = config[CONF_CALENDAR_ACCESS].scope
target_scope = f'"{config.get(CONF_CALENDAR_ACCESS).scope}"' return target_scope in creds.scopes
if target_scope not in contents:
_LOGGER.warning("Please re-authenticate with Google")
return False
return True
def setup_services( def setup_services(
@ -364,6 +364,7 @@ def setup_services(
def do_setup(hass, hass_config, config): def do_setup(hass, hass_config, config):
"""Run the setup after we have everything configured.""" """Run the setup after we have everything configured."""
_LOGGER.debug("Setting up integration")
# Load calendars the user has configured # Load calendars the user has configured
hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES)) hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES))

View File

@ -1,10 +1,20 @@
"""Test configuration and mocks for the google integration.""" """Test configuration and mocks for the google integration."""
from unittest.mock import patch from collections.abc import Callable
from typing import Any, Generator, TypeVar
from unittest.mock import Mock, patch
import pytest import pytest
from homeassistant.components.google import GoogleCalendarService
ApiResult = Callable[[dict[str, Any]], None]
T = TypeVar("T")
YieldFixture = Generator[T, None, None]
CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com"
TEST_CALENDAR = { TEST_CALENDAR = {
"id": "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com", "id": CALENDAR_ID,
"etag": '"3584134138943410"', "etag": '"3584134138943410"',
"timeZone": "UTC", "timeZone": "UTC",
"accessRole": "reader", "accessRole": "reader",
@ -34,3 +44,45 @@ def mock_next_event():
) )
with patch_google_cal as google_cal_data: with patch_google_cal as google_cal_data:
yield google_cal_data yield google_cal_data
@pytest.fixture
def mock_events_list(
google_service: GoogleCalendarService,
) -> Callable[[dict[str, Any]], None]:
"""Fixture to construct a fake event list API response."""
def _put_result(response: dict[str, Any]) -> None:
google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = (
response
)
return
return _put_result
@pytest.fixture
def mock_calendars_list(
google_service: GoogleCalendarService,
) -> ApiResult:
"""Fixture to construct a fake calendar list API response."""
def _put_result(response: dict[str, Any]) -> None:
google_service.return_value.get.return_value.calendarList.return_value.list.return_value.execute.return_value = (
response
)
return
return _put_result
@pytest.fixture
def mock_insert_event(
google_service: GoogleCalendarService,
) -> Mock:
"""Fixture to create a mock to capture new events added to the API."""
insert_mock = Mock()
google_service.return_value.get.return_value.events.return_value.insert = (
insert_mock
)
return insert_mock

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
import copy import copy
from http import HTTPStatus from http import HTTPStatus
from typing import Any from typing import Any
@ -22,7 +21,6 @@ from homeassistant.components.google import (
CONF_TRACK, CONF_TRACK,
DEVICE_SCHEMA, DEVICE_SCHEMA,
SERVICE_SCAN_CALENDARS, SERVICE_SCAN_CALENDARS,
GoogleCalendarService,
do_setup, do_setup,
) )
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
@ -358,21 +356,6 @@ async def test_http_event_api_failure(hass, hass_client, google_service):
assert events == [] assert events == []
@pytest.fixture
def mock_events_list(
google_service: GoogleCalendarService,
) -> Callable[[dict[str, Any]], None]:
"""Fixture to construct a fake event list API response."""
def _put_result(response: dict[str, Any]) -> None:
google_service.return_value.get.return_value.events.return_value.list.return_value.execute.return_value = (
response
)
return
return _put_result
async def test_http_api_event(hass, hass_client, google_service, mock_events_list): async def test_http_api_event(hass, hass_client, google_service, mock_events_list):
"""Test querying the API and fetching events from the server.""" """Test querying the API and fetching events from the server."""
now = dt_util.now() now = dt_util.now()

View File

@ -1,67 +1,497 @@
"""The tests for the Google Calendar component.""" """The tests for the Google Calendar component."""
from unittest.mock import patch from collections.abc import Awaitable, Callable
import datetime
from typing import Any
from unittest.mock import Mock, call, mock_open, patch
from oauth2client.client import (
FlowExchangeError,
OAuth2Credentials,
OAuth2DeviceCodeError,
)
import pytest import pytest
import yaml
import homeassistant.components.google as google from homeassistant.components.google import (
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET DOMAIN,
SERVICE_ADD_EVENT,
GoogleCalendarService,
)
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
from .conftest import CALENDAR_ID, ApiResult, YieldFixture
from tests.common import async_fire_time_changed
# Typing helpers
ComponentSetup = Callable[[], Awaitable[bool]]
HassApi = Callable[[], Awaitable[dict[str, Any]]]
CODE_CHECK_INTERVAL = 1
CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2)
@pytest.fixture(name="google_setup") @pytest.fixture
def mock_google_setup(hass): async def code_expiration_delta() -> datetime.timedelta:
"""Mock the google set up functions.""" """Fixture for code expiration time, defaulting to the future."""
p_auth = patch( return datetime.timedelta(minutes=3)
"homeassistant.components.google.do_authentication", side_effect=google.do_setup
@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 token_scopes() -> list[str]:
"""Fixture for scopes used during test."""
return ["https://www.googleapis.com/auth/calendar"]
@pytest.fixture
async def creds(token_scopes: list[str]) -> OAuth2Credentials:
"""Fixture that defines creds used in the test."""
token_expiry = utcnow() + datetime.timedelta(days=7)
return OAuth2Credentials(
access_token="ACCESS_TOKEN",
client_id="client-id",
client_secret="client-secret",
refresh_token="REFRESH_TOKEN",
token_expiry=token_expiry,
token_uri="http://example.com",
user_agent="n/a",
scopes=token_scopes,
) )
p_service = patch("homeassistant.components.google.GoogleCalendarService.get")
p_discovery = patch("homeassistant.components.google.discovery.load_platform")
p_load = patch("homeassistant.components.google.load_config", return_value={})
p_save = patch("homeassistant.components.google.update_config")
with p_auth, p_load, p_service, p_discovery, p_save:
@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(autouse=True)
async def mock_token_write(hass: HomeAssistant) -> None:
"""Fixture to avoid writing token files to disk."""
with patch(
"homeassistant.components.google.os.path.isfile", return_value=True
), patch("homeassistant.components.google.Storage.put"):
yield yield
async def test_setup_component(hass, google_setup): @pytest.fixture
"""Test setup component.""" async def mock_token_read(
config = {"google": {CONF_CLIENT_ID: "id", CONF_CLIENT_SECRET: "secret"}} hass: HomeAssistant,
creds: OAuth2Credentials,
assert await async_setup_component(hass, "google", config) ) -> None:
"""Fixture to populate an existing token file."""
with patch("homeassistant.components.google.Storage.get", return_value=creds):
yield
async def test_get_calendar_info(hass, test_calendar): @pytest.fixture
"""Test getting the calendar info.""" async def calendars_config() -> list[dict[str, Any]]:
calendar_info = await hass.async_add_executor_job( """Fixture for tests to override default calendar configuration."""
google.get_calendar_info, hass, test_calendar return [
) {
assert calendar_info == { "cal_id": CALENDAR_ID,
"cal_id": "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com", "entities": [
"entities": [ {
{ "device_id": "backyard_light",
"device_id": "we_are_we_are_a_test_calendar", "name": "Backyard Light",
"name": "We are, we are, a... Test Calendar", "search": "#Backyard",
"track": True, "track": True,
"ignore_availability": True, }
} ],
],
}
async def test_found_calendar(hass, google_setup, mock_next_event, test_calendar):
"""Test when a calendar is found."""
config = {
"google": {
CONF_CLIENT_ID: "id",
CONF_CLIENT_SECRET: "secret",
"track_new_calendar": True,
} }
} ]
assert await async_setup_component(hass, "google", config)
assert hass.data[google.DATA_INDEX] == {}
@pytest.fixture
async def mock_calendars_yaml(
hass: HomeAssistant,
calendars_config: list[dict[str, Any]],
) -> None:
"""Fixture that prepares the calendars.yaml file."""
mocked_open_function = mock_open(read_data=yaml.dump(calendars_config))
with patch("homeassistant.components.google.open", mocked_open_function):
yield
@pytest.fixture
async def mock_notification() -> YieldFixture[Mock]:
"""Fixture for capturing persistent notifications."""
with patch("homeassistant.components.persistent_notification.create") as mock:
yield mock
@pytest.fixture
async def config() -> dict[str, Any]:
"""Fixture for overriding component config."""
return {DOMAIN: {CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-ecret"}}
@pytest.fixture
async def component_setup(
hass: HomeAssistant, config: dict[str, Any]
) -> ComponentSetup:
"""Fixture for setting up the integration."""
async def _setup_func() -> bool:
result = await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
return result
return _setup_func
@pytest.fixture
async def google_service() -> YieldFixture[GoogleCalendarService]:
"""Fixture to capture service calls."""
with patch("homeassistant.components.google.GoogleCalendarService") as mock, patch(
"homeassistant.components.google.calendar.GoogleCalendarService", 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("calendar.backyard_light")
async def test_init_success(
hass: HomeAssistant,
google_service: GoogleCalendarService,
mock_code_flow: Mock,
mock_exchange: Mock,
mock_notification: Mock,
mock_calendars_yaml: None,
component_setup: ComponentSetup,
) -> None:
"""Test successful creds setup."""
assert await component_setup()
# Run one tick to invoke the credential exchange check
now = utcnow()
await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA)
state = hass.states.get("calendar.backyard_light")
assert state
assert state.name == "Backyard Light"
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("calendar.backyard_light")
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("calendar.backyard_light")
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("calendar.backyard_light")
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,
google_service: GoogleCalendarService,
mock_calendars_yaml: None,
mock_notification: Mock,
) -> None:
"""Test setup with an existing token file."""
assert await component_setup()
state = hass.states.get("calendar.backyard_light")
assert state
assert state.name == "Backyard Light"
assert state.state == STATE_OFF
mock_notification.assert_not_called()
@pytest.mark.parametrize(
"token_scopes", ["https://www.googleapis.com/auth/calendar.readonly"]
)
async def test_existing_token_missing_scope(
hass: HomeAssistant,
token_scopes: list[str],
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_yaml: None,
mock_notification: Mock,
mock_code_flow: Mock,
mock_exchange: Mock,
) -> None:
"""Test setup where existing token does not have sufficient scopes."""
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
state = hass.states.get("calendar.backyard_light")
assert state
assert state.name == "Backyard Light"
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]
@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,
google_service: GoogleCalendarService,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
) -> None:
"""Test setup with a missing schema fields, ignores the error and continues."""
assert await component_setup()
assert not hass.states.get("calendar.backyard_light")
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,
google_service: GoogleCalendarService,
calendars_config: list[dict[str, Any]],
mock_calendars_yaml: None,
mock_notification: Mock,
) -> None:
"""Test setup with missing entity id fields fails to setup the integration."""
# Integration fails to setup
assert not await component_setup()
assert not hass.states.get("calendar.backyard_light")
mock_notification.assert_not_called()
async def test_found_calendar_from_api(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
) -> None:
"""Test finding a calendar from the API."""
mock_calendars_list({"items": [test_calendar]})
mocked_open_function = mock_open(read_data=yaml.dump([]))
with patch("homeassistant.components.google.open", mocked_open_function):
assert await component_setup()
state = hass.states.get("calendar.we_are_we_are_a_test_calendar")
assert state
assert state.name == "We are, we are, a... Test Calendar"
assert state.state == STATE_OFF
async def test_add_event(
hass: HomeAssistant,
mock_token_read: None,
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
mock_insert_event: Mock,
) -> None:
"""Test service call that adds an event."""
assert await component_setup()
await hass.services.async_call( await hass.services.async_call(
"google", google.SERVICE_FOUND_CALENDARS, test_calendar, blocking=True 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": {},
},
) )
assert hass.data[google.DATA_INDEX].get(test_calendar["id"]) is not None
@pytest.mark.parametrize(
"date_fields,start_timedelta,end_timedelta",
[
(
{"in": {"days": 3}},
datetime.timedelta(days=3),
datetime.timedelta(days=4),
),
(
{"in": {"weeks": 1}},
datetime.timedelta(days=7),
datetime.timedelta(days=8),
),
(
{
"start_date": datetime.date.today().isoformat(),
"end_date": (
datetime.date.today() + datetime.timedelta(days=2)
).isoformat(),
},
datetime.timedelta(days=0),
datetime.timedelta(days=2),
),
],
ids=["in_days", "in_weeks", "explit_date"],
)
async def test_add_event_date_ranges(
hass: HomeAssistant,
mock_token_read: None,
calendars_config: list[dict[str, Any]],
component_setup: ComponentSetup,
google_service: GoogleCalendarService,
mock_calendars_list: ApiResult,
test_calendar: dict[str, Any],
mock_insert_event: Mock,
date_fields: dict[str, Any],
start_timedelta: datetime.timedelta,
end_timedelta: datetime.timedelta,
) -> None:
"""Test service call that adds an event with various time ranges."""
assert await component_setup()
await hass.services.async_call(
DOMAIN,
SERVICE_ADD_EVENT,
{
"calendar_id": CALENDAR_ID,
"summary": "Summary",
"description": "Description",
**date_fields,
},
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()},
},
)