"""Tests for Google Sheets.""" from collections.abc import Awaitable, Callable, Coroutine import http import time from typing import Any from unittest.mock import Mock, patch from freezegun import freeze_time from google.auth.exceptions import RefreshError from gspread.exceptions import APIError import pytest from requests.models import Response from syrupy.assertion import SnapshotAssertion from homeassistant.components.application_credentials import ( DOMAIN as APPLICATION_CREDENTIALS_DOMAIN, ClientCredential, async_import_client_credential, ) from homeassistant.components.google_sheets.const import DOMAIN from homeassistant.components.google_sheets.services import ( ADD_CREATED_COLUMN, DATA, DATA_CONFIG_ENTRY, ROWS, SERVICE_APPEND_SHEET, SERVICE_GET_SHEET, WORKSHEET, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( HomeAssistantError, OAuth2TokenRequestReauthError, OAuth2TokenRequestTransientError, ServiceValidationError, ) from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, ) from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker TEST_SHEET_ID = "google-sheet-it" type ComponentSetup = Callable[[], Awaitable[None]] @pytest.fixture(name="scopes") def mock_scopes() -> list[str]: """Fixture to set the scopes present in the OAuth token.""" return ["https://www.googleapis.com/auth/drive.file"] @pytest.fixture(name="expires_at") def mock_expires_at() -> int: """Fixture to set the oauth token expiration time.""" return time.time() + 3600 @pytest.fixture(name="config_entry") def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: """Fixture for MockConfigEntry.""" return MockConfigEntry( domain=DOMAIN, unique_id=TEST_SHEET_ID, data={ "auth_implementation": DOMAIN, "token": { "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, "scope": " ".join(scopes), }, }, ) @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, config_entry: MockConfigEntry ) -> Callable[[], Coroutine[Any, Any, None]]: """Fixture for setting up the component.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) await async_import_client_credential( hass, DOMAIN, ClientCredential("client-id", "client-secret"), DOMAIN, ) async def func() -> None: assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return func async def test_setup_success( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: """Test successful setup and unload.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED await hass.config_entries.async_unload(entries[0].entry_id) await hass.async_block_till_done() assert not hass.data.get(DOMAIN) assert entries[0].state is ConfigEntryState.NOT_LOADED @pytest.mark.parametrize( "scopes", [ [], [ "https://www.googleapis.com/auth/drive.file+plus+extra" ], # Required scope is a prefix ["https://www.googleapis.com/auth/drive.readonly"], ], ids=["no_scope", "required_scope_prefix", "other_scope"], ) async def test_missing_required_scopes_requires_reauth( hass: HomeAssistant, setup_integration: ComponentSetup ) -> None: """Test that reauth is invoked when required scopes are not present.""" await setup_integration() 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("expires_at", [time.time() - 3600], ids=["expired"]) async def test_expired_token_refresh_success( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, ) -> None: """Test expired token is refreshed.""" aioclient_mock.post( "https://oauth2.googleapis.com/token", json={ "access_token": "updated-access-token", "refresh_token": "updated-refresh-token", "expires_at": time.time() + 3600, "expires_in": 3600, }, ) await setup_integration() 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"] == "updated-access-token" assert entries[0].data["token"]["expires_in"] == 3600 @pytest.mark.parametrize( ("expires_at", "status", "expected_state"), [ ( time.time() - 3600, http.HTTPStatus.UNAUTHORIZED, ConfigEntryState.SETUP_ERROR, ), ( time.time() - 3600, http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), ], ids=["failure_requires_reauth", "transient_failure"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, setup_integration: ComponentSetup, aioclient_mock: AiohttpClientMocker, status: http.HTTPStatus, expected_state: ConfigEntryState, ) -> None: """Test failure while refreshing token with a transient error.""" aioclient_mock.post( "https://oauth2.googleapis.com/token", status=status, ) await setup_integration() # Verify a transient failure has occurred entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].state is expected_state async def test_setup_oauth_reauth_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test a token refresh reauth error puts the config entry in setup error state.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) await async_import_client_credential( hass, DOMAIN, ClientCredential("client-id", "client-secret"), DOMAIN, ) with ( patch.object(config_entry, "async_start_reauth") as mock_async_start_reauth, patch( "homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid", side_effect=OAuth2TokenRequestReauthError( domain=DOMAIN, request_info=Mock() ), ), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR mock_async_start_reauth.assert_called_once_with(hass, None, None) async def test_setup_oauth_transient_error( hass: HomeAssistant, config_entry: MockConfigEntry ) -> None: """Test a token refresh transient error sets the config entry to retry setup.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, APPLICATION_CREDENTIALS_DOMAIN, {}) await async_import_client_credential( hass, DOMAIN, ClientCredential("client-id", "client-secret"), DOMAIN, ) with patch( "homeassistant.components.google_sheets.OAuth2Session.async_ensure_token_valid", side_effect=OAuth2TokenRequestTransientError( domain=DOMAIN, request_info=Mock() ), ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY @pytest.mark.parametrize( ("add_created_column_param", "expected_row"), [ ({ADD_CREATED_COLUMN: True}, ["bar", "2024-01-15 04:30:45.123456-08:00"]), ({ADD_CREATED_COLUMN: False}, ["bar", ""]), ({}, ["bar", "2024-01-15 04:30:45.123456-08:00"]), ], ids=["created_column_true", "created_column_false", "created_column_default"], ) @freeze_time("2024-01-15 04:30:45.123456-08:00") async def test_append_sheet( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, add_created_column_param: dict[str, bool], expected_row: list[str], ) -> None: """Test created column behavior based on add_created_column parameter.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED with patch("homeassistant.components.google_sheets.services.Client") as mock_client: mock_worksheet = ( mock_client.return_value.open_by_key.return_value.worksheet.return_value ) mock_worksheet.get_values.return_value = [["foo", "created"]] await hass.services.async_call( DOMAIN, SERVICE_APPEND_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "Sheet1", DATA: {"foo": "bar"}, **add_created_column_param, }, blocking=True, ) mock_worksheet.append_rows.assert_called_once() rows_data = mock_worksheet.append_rows.call_args[0][0] assert rows_data[0] == expected_row @freeze_time("2024-01-15 12:30:45.123456") async def test_append_sheet_created_column_uses_configured_time_zone( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test the created column follows the time zone configured in Home Assistant. Home Assistant never changes the process time zone, so the machine the installation happens to run on must not decide what is written here. """ await hass.config.async_set_time_zone("Australia/Sydney") await setup_integration() with patch("homeassistant.components.google_sheets.services.Client") as mock_client: mock_worksheet = ( mock_client.return_value.open_by_key.return_value.worksheet.return_value ) mock_worksheet.get_values.return_value = [["foo", "created"]] await hass.services.async_call( DOMAIN, SERVICE_APPEND_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "Sheet1", DATA: {"foo": "bar"}, ADD_CREATED_COLUMN: True, }, blocking=True, ) rows_data = mock_worksheet.append_rows.call_args[0][0] assert rows_data[0] == ["bar", "2024-01-15 23:30:45.123456+11:00"] async def test_get_sheet( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: """Test service call getting data from a sheet.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED with patch("homeassistant.components.google_sheets.services.Client") as mock_client: worksheet = mock_client.return_value.open_by_key.return_value.worksheet worksheet.return_value.get_values.return_value = [ ["col1", "col2"], ["a", "b"], ["c", "d"], ] response = await hass.services.async_call( DOMAIN, SERVICE_GET_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "Sheet1", ROWS: 2, }, blocking=True, return_response=True, ) assert len(mock_client.mock_calls) == 4 assert response == snapshot async def test_append_sheet_multiple_rows( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test service call appending to a sheet.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED with patch("homeassistant.components.google_sheets.services.Client") as mock_client: await hass.services.async_call( DOMAIN, "append_sheet", { "config_entry": config_entry.entry_id, "worksheet": "Sheet1", "data": [{"foo": "bar"}, {"foo": "bar2"}], }, blocking=True, ) assert len(mock_client.mock_calls) == 8 async def test_append_sheet_defaults_to_first_worksheet( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test append to sheet without naming a worksheet.""" await setup_integration() with patch("homeassistant.components.google_sheets.services.Client") as mock_client: sheet = mock_client.return_value.open_by_key.return_value sheet.sheet1.get_values.return_value = [["foo"]] await hass.services.async_call( DOMAIN, SERVICE_APPEND_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, DATA: {"foo": "bar"}, }, blocking=True, ) sheet.worksheet.assert_not_called() sheet.sheet1.append_rows.assert_called_once() @pytest.mark.parametrize( ("service", "service_data"), [ pytest.param(SERVICE_APPEND_SHEET, {DATA: {"foo": "bar"}}, id="append_sheet"), pytest.param(SERVICE_GET_SHEET, {ROWS: 2}, id="get_sheet"), ], ) async def test_refresh_error_starts_reauth( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, service: str, service_data: dict[str, Any], ) -> None: """Test an expired token while calling the API starts reauthentication.""" await setup_integration() with ( patch( "homeassistant.components.google_sheets.services.Client.request", side_effect=RefreshError, ), pytest.raises(RefreshError), ): await hass.services.async_call( DOMAIN, service, {DATA_CONFIG_ENTRY: config_entry.entry_id, **service_data}, blocking=True, return_response=service == SERVICE_GET_SHEET, ) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" @pytest.mark.parametrize( "failing_call", [ pytest.param("get_values", id="read_columns"), pytest.param("update_cell", id="add_column"), pytest.param("append_rows", id="append_rows"), ], ) async def test_append_sheet_api_error_while_writing( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, failing_call: str, ) -> None: """Test append to sheet reports a failing write as a HomeAssistantError.""" await setup_integration() response = Response() response.status_code = 503 with patch("homeassistant.components.google_sheets.services.Client") as mock_client: mock_worksheet = ( mock_client.return_value.open_by_key.return_value.worksheet.return_value ) mock_worksheet.get_values.return_value = [["foo"]] setattr(mock_worksheet, failing_call, Mock(side_effect=APIError(response))) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_APPEND_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "Sheet1", DATA: {"foo": "bar", "new_column": "baz"}, }, blocking=True, ) async def test_get_sheet_api_error_while_reading( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test get sheet reports a failing read as a HomeAssistantError.""" await setup_integration() response = Response() response.status_code = 503 with patch("homeassistant.components.google_sheets.services.Client") as mock_client: mock_worksheet = ( mock_client.return_value.open_by_key.return_value.worksheet.return_value ) mock_worksheet.get_values.side_effect = APIError(response) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_GET_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "Sheet1", ROWS: 2, }, blocking=True, return_response=True, ) async def test_append_sheet_api_error( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, ) -> None: """Test append to sheet service call API error.""" await setup_integration() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.LOADED response = Response() response.status_code = 503 with ( pytest.raises(HomeAssistantError), patch( "homeassistant.components.google_sheets.services.Client.request", side_effect=APIError(response), ), ): await hass.services.async_call( DOMAIN, "append_sheet", { "config_entry": config_entry.entry_id, "worksheet": "Sheet1", "data": {"foo": "bar"}, }, blocking=True, ) async def test_append_sheet_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, expires_at: int, scopes: list[str], ) -> None: """Test service call with invalid config entries.""" config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=TEST_SHEET_ID + "2", data={ "auth_implementation": DOMAIN, "token": { "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, "scope": " ".join(scopes), }, }, ) config_entry2.add_to_hass(hass) await setup_integration() assert config_entry.state is ConfigEntryState.LOADED assert config_entry2.state is ConfigEntryState.LOADED # Exercise service call on a config entry that does not exist with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, "append_sheet", { "config_entry": config_entry.entry_id + "XXX", "worksheet": "Sheet1", "data": {"foo": "bar"}, }, blocking=True, ) assert err.value.translation_key == "service_config_entry_not_found" # Unload the config entry invoke the service on the unloaded entry id await hass.config_entries.async_unload(config_entry2.entry_id) await hass.async_block_till_done() assert config_entry2.state is ConfigEntryState.NOT_LOADED with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, "append_sheet", { "config_entry": config_entry2.entry_id, "worksheet": "Sheet1", "data": {"foo": "bar"}, }, blocking=True, ) assert err.value.translation_key == "service_config_entry_not_loaded" async def test_get_sheet_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, expires_at: int, scopes: list[str], ) -> None: """Test service call get sheet with invalid config entries.""" config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=TEST_SHEET_ID + "2", data={ "auth_implementation": DOMAIN, "token": { "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, "scope": " ".join(scopes), }, }, ) config_entry2.add_to_hass(hass) await setup_integration() assert config_entry.state is ConfigEntryState.LOADED assert config_entry2.state is ConfigEntryState.LOADED # Exercise service call on a config entry that does not exist with pytest.raises(ServiceValidationError) as err: await hass.services.async_call( DOMAIN, SERVICE_GET_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id + "XXX", WORKSHEET: "Sheet1", ROWS: 2, }, blocking=True, return_response=True, ) assert err.value.translation_key == "service_config_entry_not_found" # Unload the config entry invoke the service on the unloaded entry id await hass.config_entries.async_unload(config_entry2.entry_id) await hass.async_block_till_done() assert config_entry2.state is ConfigEntryState.NOT_LOADED async def test_get_sheet_invalid_worksheet( hass: HomeAssistant, setup_integration: ComponentSetup, config_entry: MockConfigEntry, expires_at: int, scopes: list[str], ) -> None: """Test service call get sheet with invalid config entries.""" config_entry2 = MockConfigEntry( domain=DOMAIN, unique_id=TEST_SHEET_ID + "2", data={ "auth_implementation": DOMAIN, "token": { "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, "scope": " ".join(scopes), }, }, ) config_entry2.add_to_hass(hass) await setup_integration() assert config_entry.state is ConfigEntryState.LOADED assert config_entry2.state is ConfigEntryState.LOADED # Exercise service call on a worksheet that does not exist with patch("homeassistant.components.google_sheets.services.Client") as mock_client: mock_client.return_value.open_by_key.return_value.worksheet.side_effect = ( APIError(Response()) ) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, SERVICE_GET_SHEET, { DATA_CONFIG_ENTRY: config_entry.entry_id, WORKSHEET: "DoesNotExist", ROWS: 2, }, blocking=True, return_response=True, ) async def test_oauth_implementation_not_available( hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: """Test that unavailable OAuth implementation raises ConfigEntryNotReady.""" config_entry.add_to_hass(hass) with patch( "homeassistant.components.google_sheets.async_get_config_entry_implementation", side_effect=ImplementationUnavailableError, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY