core/tests/components/google/test_init.py

498 lines
14 KiB
Python

"""The tests for the Google Calendar component."""
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 yaml
from homeassistant.components.google import (
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.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
async def code_expiration_delta() -> datetime.timedelta:
"""Fixture for code expiration time, defaulting to the future."""
return datetime.timedelta(minutes=3)
@pytest.fixture
async def mock_code_flow(
code_expiration_delta: datetime.timedelta,
) -> YieldFixture[Mock]:
"""Fixture for initiating OAuth flow."""
with patch(
"oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes",
) as mock_flow:
mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta
mock_flow.return_value.interval = CODE_CHECK_INTERVAL
yield mock_flow
@pytest.fixture
async def 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,
)
@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
@pytest.fixture
async def mock_token_read(
hass: HomeAssistant,
creds: OAuth2Credentials,
) -> None:
"""Fixture to populate an existing token file."""
with patch("homeassistant.components.google.Storage.get", return_value=creds):
yield
@pytest.fixture
async def calendars_config() -> list[dict[str, Any]]:
"""Fixture for tests to override default calendar configuration."""
return [
{
"cal_id": CALENDAR_ID,
"entities": [
{
"device_id": "backyard_light",
"name": "Backyard Light",
"search": "#Backyard",
"track": True,
}
],
}
]
@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(
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": {},
},
)
@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()},
},
)