"""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()}, }, )