"""The tests for the Google Calendar component.""" from __future__ import annotations from collections.abc import Awaitable, Callable import datetime import http import time from typing import Any from unittest.mock import Mock, patch import zoneinfo from aiohttp.client_exceptions import ClientError import pytest import voluptuous as vol from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT from homeassistant.components.google.calendar import SERVICE_CREATE_EVENT from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.util.dt import utcnow from .conftest import ( CALENDAR_ID, EMAIL_ADDRESS, TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, ApiResult, ComponentSetup, ) from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() # Typing helpers HassApi = Callable[[], Awaitable[dict[str, Any]]] TEST_EVENT_SUMMARY = "Test Summary" TEST_EVENT_DESCRIPTION = "Test Description" TEST_EVENT_LOCATION = "Test Location" def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" if actual is None or expected is None: assert actual == expected return assert actual.entity_id == expected.entity_id assert actual.state == expected.state assert actual.attributes == expected.attributes @pytest.fixture( params=[ ( DOMAIN, SERVICE_ADD_EVENT, {"calendar_id": CALENDAR_ID}, None, ), ( DOMAIN, SERVICE_CREATE_EVENT, {}, {"entity_id": TEST_API_ENTITY}, ), ( "calendar", SERVICE_CREATE_EVENT, {}, {"entity_id": TEST_API_ENTITY}, ), ], ids=("google.add_event", "google.create_event", "calendar.create_event"), ) def add_event_call_service( hass: HomeAssistant, request: Any, ) -> Callable[dict[str, Any], Awaitable[None]]: """Fixture for calling the add or create event service.""" (domain, service_call, data, target) = request.param async def call_service(params: dict[str, Any]) -> None: await hass.services.async_call( domain, service_call, { **data, **params, "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, }, target=target, blocking=True, ) return call_service async def test_unload_entry( hass: HomeAssistant, component_setup: ComponentSetup, ) -> None: """Test load and unload of a ConfigEntry.""" await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state == ConfigEntryState.NOT_LOADED @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], component_setup: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test setup where existing token does not have sufficient scopes.""" await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.parametrize("config_entry_options", [{CONF_CALENDAR_ACCESS: "read_only"}]) async def test_config_entry_scope_reauth( hass: HomeAssistant, token_scopes: list[str], component_setup: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test setup where the config entry options requires reauth to match the scope.""" await component_setup() assert config_entry.state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]]) async def test_calendar_yaml_missing_required_fields( hass: HomeAssistant, component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, config_entry: MockConfigEntry, ) -> None: """Test setup with a missing schema fields, ignores the error and continues.""" assert not await component_setup() assert config_entry.state is ConfigEntryState.SETUP_ERROR @pytest.mark.parametrize("calendars_config", [[{"missing-cal_id": "invalid-schema"}]]) async def test_invalid_calendar_yaml( hass: HomeAssistant, component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, config_entry: MockConfigEntry, ) -> None: """Test setup with missing entity id fields fails to load the platform.""" assert not await component_setup() assert config_entry.state is ConfigEntryState.SETUP_ERROR async def test_calendar_yaml_error( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, ) -> 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() assert not hass.states.get(TEST_YAML_ENTITY) assert hass.states.get(TEST_API_ENTITY) @pytest.mark.parametrize("calendars_config", [None]) async def test_empty_calendar_yaml( hass: HomeAssistant, component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, ) -> None: """Test an empty yaml file is equivalent to a missing yaml file.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() assert not hass.states.get(TEST_YAML_ENTITY) assert hass.states.get(TEST_API_ENTITY) async def test_init_calendar( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, ) -> 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) assert state assert state.name == TEST_API_ENTITY_NAME assert state.state == STATE_OFF # No yaml config loaded that overwrites the entity name assert not hass.states.get(TEST_YAML_ENTITY) async def test_multiple_config_entries( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, ) -> None: """Test finding a calendar from the API.""" assert await component_setup() config_entry1 = MockConfigEntry( domain=DOMAIN, data=config_entry.data, unique_id=EMAIL_ADDRESS ) calendar1 = { **test_api_calendar, "id": "calendar-id1", "summary": "Example Calendar 1", } mock_calendars_list({"items": [calendar1]}) mock_events_list({}, calendar_id="calendar-id1") config_entry1.add_to_hass(hass) await hass.config_entries.async_setup(config_entry1.entry_id) await hass.async_block_till_done() state = hass.states.get("calendar.example_calendar_1") assert state assert state.state == STATE_OFF assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Example calendar 1" config_entry2 = MockConfigEntry( domain=DOMAIN, data=config_entry.data, unique_id="other-address@example.com" ) calendar2 = { **test_api_calendar, "id": "calendar-id2", "summary": "Example Calendar 2", } aioclient_mock.clear_requests() mock_calendars_list({"items": [calendar2]}) mock_events_list({}, calendar_id="calendar-id2") config_entry2.add_to_hass(hass) await hass.config_entries.async_setup(config_entry2.entry_id) await hass.async_block_till_done() state = hass.states.get("calendar.example_calendar_2") assert state assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Example calendar 2" @pytest.mark.parametrize( ("date_fields", "expected_error", "error_match"), [ ( {}, vol.error.MultipleInvalid, "must contain at least one of start_date, start_date_time, in", ), ( { "start_date": "2022-04-01", }, vol.error.MultipleInvalid, "Start and end dates must both be specified", ), ( { "end_date": "2022-04-02", }, vol.error.MultipleInvalid, "must contain at least one of start_date, start_date_time, in.", ), ( { "start_date_time": "2022-04-01T06:00:00", }, vol.error.MultipleInvalid, "Start and end datetimes must both be specified", ), ( { "end_date_time": "2022-04-02T07:00:00", }, vol.error.MultipleInvalid, "must contain at least one of start_date, start_date_time, in.", ), ( { "start_date": "2022-04-01", "start_date_time": "2022-04-01T06:00:00", "end_date_time": "2022-04-02T07:00:00", }, vol.error.MultipleInvalid, "must contain at most one of start_date, start_date_time, in.", ), ( { "start_date_time": "2022-04-01T06:00:00", "end_date_time": "2022-04-01T07:00:00", "end_date": "2022-04-02", }, vol.error.MultipleInvalid, "Start and end dates must both be specified", ), ( { "start_date": "2022-04-01", "end_date_time": "2022-04-02T07:00:00", }, vol.error.MultipleInvalid, "Start and end dates must both be specified", ), ( { "start_date_time": "2022-04-01T07:00:00", "end_date": "2022-04-02", }, vol.error.MultipleInvalid, "Start and end dates must both be specified", ), ( { "in": { "days": 2, "weeks": 2, } }, vol.error.MultipleInvalid, "two or more values in the same group of exclusion 'event_types'", ), ( { "start_date": "2022-04-01", "end_date": "2022-04-02", "in": { "days": 2, }, }, vol.error.MultipleInvalid, "must contain at most one of start_date, start_date_time, in.", ), ( { "start_date_time": "2022-04-01T07:00:00", "end_date_time": "2022-04-01T07:00:00", "in": { "days": 2, }, }, vol.error.MultipleInvalid, "must contain at most one of start_date, start_date_time, in.", ), ], ids=[ "missing_all", "missing_end_date", "missing_start_date", "missing_end_datetime", "missing_start_datetime", "multiple_start", "multiple_end", "missing_end_date", "missing_end_date_time", "multiple_in", "unexpected_in_with_date", "unexpected_in_with_datetime", ], ) async def test_add_event_invalid_params( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], date_fields: dict[str, Any], expected_error: type[Exception], error_match: str | None, ) -> None: """Test service calls with incorrect fields.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() with pytest.raises(expected_error, match=error_match): await add_event_call_service(date_fields) @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), ), ], ids=["in_days", "in_weeks"], ) 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_events_list: ApiResult, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, aioclient_mock: AiohttpClientMocker, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with various time ranges.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() now = datetime.datetime.now() start_date = now + start_timedelta end_date = now + end_timedelta aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) await add_event_call_service(date_fields) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_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, test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], mock_events_list: ApiResult, aioclient_mock: AiohttpClientMocker, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that sets a date range.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) await add_event_call_service( { "start_date": today.isoformat(), "end_date": end_date.isoformat(), }, ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_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_events_list: ApiResult, aioclient_mock: AiohttpClientMocker, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with a date time range.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) await add_event_call_service( { "start_date_time": start_datetime.isoformat(), "end_date_time": end_datetime.isoformat(), }, ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", }, "end": { "dateTime": end_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", }, } async def test_add_event_failure( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, mock_insert_event: Callable[[..., dict[str, Any]], None], add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service calls with incorrect fields.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() mock_insert_event( calendar_id=CALENDAR_ID, exc=ClientError(), ) with pytest.raises(HomeAssistantError): await add_event_call_service( {"start_date": "2022-05-01", "end_date": "2022-05-02"} ) async def test_add_event_location( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], mock_events_list: ApiResult, aioclient_mock: AiohttpClientMocker, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that sets a location field.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) await add_event_call_service( { "start_date": today.isoformat(), "end_date": end_date.isoformat(), "location": TEST_EVENT_LOCATION, }, ) assert len(aioclient_mock.mock_calls) == 1 assert aioclient_mock.mock_calls[0][2] == { "summary": TEST_EVENT_SUMMARY, "description": TEST_EVENT_DESCRIPTION, "location": TEST_EVENT_LOCATION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @pytest.mark.parametrize( "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, component_setup: ComponentSetup, aioclient_mock: AiohttpClientMocker, ) -> None: """Exercise case in issue #69623 with invalid token expiration persisted.""" # The token is refreshed and new expiration values are returned expires_in = 86400 expires_at = time.time() + expires_in aioclient_mock.post( "https://oauth2.googleapis.com/token", json={ "refresh_token": "some-refresh-token", "access_token": "some-updated-token", "expires_at": expires_at, "expires_in": expires_in, }, ) assert await component_setup() # Verify token expiration values are updated entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED assert entries[0].data["token"]["access_token"] == "some-updated-token" assert entries[0].data["token"]["expires_in"] == expires_in @pytest.mark.parametrize("config_entry_token_expiry", [EXPIRED_TOKEN_TIMESTAMP]) async def test_expired_token_refresh_internal_error( hass: HomeAssistant, component_setup: ComponentSetup, aioclient_mock: AiohttpClientMocker, ) -> None: """Generic errors on reauth are treated as a retryable setup error.""" aioclient_mock.post( "https://oauth2.googleapis.com/token", status=http.HTTPStatus.INTERNAL_SERVER_ERROR, ) await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY @pytest.mark.parametrize( "config_entry_token_expiry", [EXPIRED_TOKEN_TIMESTAMP], ) async def test_expired_token_requires_reauth( hass: HomeAssistant, component_setup: ComponentSetup, aioclient_mock: AiohttpClientMocker, ) -> None: """Test case where reauth is required for token that cannot be refreshed.""" aioclient_mock.post( "https://oauth2.googleapis.com/token", status=http.HTTPStatus.BAD_REQUEST, ) await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_ERROR flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.parametrize( ("calendars_config", "expect_write_calls"), [ ( [ { "cal_id": "ignored", "entities": {"device_id": "existing", "name": "existing"}, } ], True, ), ([], False), ], ids=["has_yaml", "no_yaml"], ) async def test_calendar_yaml_update( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_yaml: Mock, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, calendars_config: dict[str, Any], expect_write_calls: bool, ) -> None: """Test updating the yaml file with a new calendar.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() mock_calendars_yaml().read.assert_called() mock_calendars_yaml().write.called is expect_write_calls state = hass.states.get(TEST_API_ENTITY) assert state assert state.name == TEST_API_ENTITY_NAME assert state.state == STATE_OFF # No yaml config loaded that overwrites the entity name assert not hass.states.get(TEST_YAML_ENTITY) async def test_update_will_reload( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, config_entry: MockConfigEntry, ) -> None: """Test updating config entry options will trigger a reload.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) await component_setup() assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == {} # read_write is default with patch( "homeassistant.config_entries.ConfigEntries.async_reload", return_value=None, ) as mock_reload: # No-op does not reload hass.config_entries.async_update_entry( config_entry, options={CONF_CALENDAR_ACCESS: "read_write"} ) await hass.async_block_till_done() mock_reload.assert_not_called() # Data change does not trigger reload hass.config_entries.async_update_entry( config_entry, data={ **config_entry.data, "example": "field", }, ) await hass.async_block_till_done() mock_reload.assert_not_called() # Reload when options changed hass.config_entries.async_update_entry( config_entry, options={CONF_CALENDAR_ACCESS: "read_only"} ) await hass.async_block_till_done() mock_reload.assert_called_once() @pytest.mark.parametrize("config_entry_unique_id", [None]) async def test_assign_unique_id( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, mock_calendar_get: Callable[[...], None], config_entry: MockConfigEntry, ) -> None: """Test an existing config is updated to have unique id if it does not exist.""" assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.unique_id is None mock_calendar_get( "primary", {"id": EMAIL_ADDRESS, "summary": "Personal", "accessRole": "owner"}, ) mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() assert config_entry.state is ConfigEntryState.LOADED assert config_entry.unique_id == EMAIL_ADDRESS @pytest.mark.parametrize( ("config_entry_unique_id", "request_status", "config_entry_status"), [ (None, http.HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_RETRY), ( None, http.HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR, ), ], ) async def test_assign_unique_id_failure( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], config_entry: MockConfigEntry, mock_events_list: ApiResult, mock_calendar_get: Callable[[...], None], request_status: http.HTTPStatus, config_entry_status: ConfigEntryState, ) -> None: """Test lookup failures during unique id assignment are handled gracefully.""" assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.unique_id is None mock_calendar_get( "primary", {}, status=request_status, ) mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) await component_setup() assert config_entry.state is config_entry_status assert config_entry.unique_id is None async def test_remove_entry( hass: HomeAssistant, mock_calendars_list: ApiResult, component_setup: ComponentSetup, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, ) -> None: """Test load and remove of a ConfigEntry.""" mock_calendars_list({"items": [test_api_calendar]}) mock_events_list({}) assert await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED assert await hass.config_entries.async_remove(entry.entry_id) assert entry.state == ConfigEntryState.NOT_LOADED