1
0
mirror of https://github.com/home-assistant/core synced 2024-07-08 20:17:01 +00:00

Add Google Sheets integration (#77853)

Co-authored-by: Allen Porter <allen@thebends.org>
This commit is contained in:
Robert Hillis 2022-09-14 14:31:54 -04:00 committed by GitHub
parent 996bcbdac6
commit a46982befb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 882 additions and 0 deletions

View File

@ -421,6 +421,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/google_assistant/ @home-assistant/cloud
/tests/components/google_assistant/ @home-assistant/cloud
/homeassistant/components/google_cloud/ @lufton
/homeassistant/components/google_sheets/ @tkdrob
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
/homeassistant/components/govee_ble/ @bdraco

View File

@ -0,0 +1,113 @@
"""Support for Google Sheets."""
from __future__ import annotations
from datetime import datetime
from typing import cast
import aiohttp
from google.auth.exceptions import RefreshError
from google.oauth2.credentials import Credentials
from gspread import Client
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import ConfigEntrySelector
from .const import DATA_CONFIG_ENTRY, DEFAULT_ACCESS, DOMAIN
DATA = "data"
WORKSHEET = "worksheet"
SERVICE_APPEND_SHEET = "append_sheet"
SHEET_SERVICE_SCHEMA = vol.All(
{
vol.Required(DATA_CONFIG_ENTRY): ConfigEntrySelector(),
vol.Optional(WORKSHEET): cv.string,
vol.Required(DATA): dict,
},
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Sheets from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err
if not async_entry_has_scopes(hass, entry):
raise ConfigEntryAuthFailed("Required scopes are not present, reauth required")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = session
await async_setup_service(hass)
return True
def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Verify that the config entry desired scope is present in the oauth token."""
return DEFAULT_ACCESS in entry.data.get(CONF_TOKEN, {}).get("scope", "").split(" ")
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
return True
async def async_setup_service(hass: HomeAssistant) -> None:
"""Add the services for Google Sheets."""
def _append_to_sheet(call: ServiceCall, entry: ConfigEntry) -> None:
"""Run append in the executor."""
service = Client(Credentials(entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
try:
sheet = service.open_by_key(entry.unique_id)
except RefreshError as ex:
entry.async_start_reauth(hass)
raise ex
worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title))
row_data = {"created": str(datetime.now())} | call.data[DATA]
columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), [])
row = [row_data.get(column, "") for column in columns]
for key, value in row_data.items():
if key not in columns:
columns.append(key)
worksheet.update_cell(1, len(columns), key)
row.append(value)
worksheet.append_row(row)
async def append_to_sheet(call: ServiceCall) -> None:
"""Append new line of data to a Google Sheets document."""
entry = cast(
ConfigEntry,
hass.config_entries.async_get_entry(call.data[DATA_CONFIG_ENTRY]),
)
session: OAuth2Session = hass.data[DOMAIN][entry.entry_id]
await session.async_ensure_token_valid()
await hass.async_add_executor_job(_append_to_sheet, call, entry)
hass.services.async_register(
DOMAIN,
SERVICE_APPEND_SHEET,
append_to_sheet,
schema=SHEET_SERVICE_SCHEMA,
)

View File

@ -0,0 +1,27 @@
"""application_credentials platform for Google Sheets."""
import oauth2client
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZATION_SERVER = AuthorizationServer(
oauth2client.GOOGLE_AUTH_URI, oauth2client.GOOGLE_TOKEN_URI
)
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
oauth2client.GOOGLE_AUTH_URI,
oauth2client.GOOGLE_TOKEN_URI,
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_sheets/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
}

View File

@ -0,0 +1,102 @@
"""Config flow for Google Sheets integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
from google.oauth2.credentials import Credentials
from gspread import Client, GSpreadException
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DEFAULT_ACCESS, DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Google Sheets OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
return {
"scope": DEFAULT_ACCESS,
# Add params to ensure we get back a refresh token
"access_type": "offline",
"prompt": "consent",
}
async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Confirm reauth dialog."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
def _async_reauth_entry(self) -> ConfigEntry | None:
"""Return existing entry for reauth."""
if self.source != SOURCE_REAUTH or not (
entry_id := self.context.get("entry_id")
):
return None
return next(
(
entry
for entry in self._async_current_entries()
if entry.entry_id == entry_id
),
None,
)
async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult:
"""Create an entry for the flow, or update existing entry."""
service = Client(Credentials(data[CONF_TOKEN][CONF_ACCESS_TOKEN]))
if entry := self._async_reauth_entry():
_LOGGER.debug("service.open_by_key")
try:
await self.hass.async_add_executor_job(
service.open_by_key,
entry.unique_id,
)
except GSpreadException as err:
_LOGGER.error(
"Could not find spreadsheet '%s': %s", entry.unique_id, str(err)
)
return self.async_abort(reason="open_spreadsheet_failure")
self.hass.config_entries.async_update_entry(entry, data=data)
await self.hass.config_entries.async_reload(entry.entry_id)
return self.async_abort(reason="reauth_successful")
try:
doc = await self.hass.async_add_executor_job(
service.create, "Home Assistant"
)
except GSpreadException as err:
_LOGGER.error("Error creating spreadsheet: %s", str(err))
return self.async_abort(reason="create_spreadsheet_failure")
await self.async_set_unique_id(doc.id)
return self.async_create_entry(
title=DEFAULT_NAME, data=data, description_placeholders={"url": doc.url}
)

View File

@ -0,0 +1,10 @@
"""Constants for Google Sheets integration."""
from __future__ import annotations
from typing import Final
DOMAIN = "google_sheets"
DATA_CONFIG_ENTRY: Final = "config_entry"
DEFAULT_NAME = "Google Sheets"
DEFAULT_ACCESS = "https://www.googleapis.com/auth/drive.file"

View File

@ -0,0 +1,10 @@
{
"domain": "google_sheets",
"name": "Google Sheets",
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google_sheets/",
"requirements": ["gspread==5.5.0"],
"codeowners": ["@tkdrob"],
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,24 @@
append_sheet:
name: Append to Sheet
description: Append data to a worksheet in Google Sheets.
fields:
config_entry:
name: Sheet
description: The sheet to add data to
required: true
selector:
config_entry:
integration: google_sheets
worksheet:
name: Worksheet
description: Name of the worksheet. Defaults to the first one in the document.
example: "Sheet1"
selector:
text:
data:
name: Data
description: Data to be appended to the worksheet. This puts the values on a new row underneath the matching column (key). Any new key is placed on the top of a new column.
required: true
example: '{"hello": world, "cool": True, "count": 5}'
selector:
object:

View File

@ -0,0 +1,31 @@
{
"config": {
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"auth": {
"title": "Link Google Account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details",
"open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details"
},
"create_entry": {
"default": "Successfully authenticated and spreadsheet created at: {url}"
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n"
}
}

View File

@ -0,0 +1,31 @@
{
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Sheets. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n"
},
"config": {
"abort": {
"already_configured": "Account is already configured",
"already_in_progress": "Configuration flow is already in progress",
"cannot_connect": "Failed to connect",
"create_spreadsheet_failure": "Error while creating spreadsheet, see error log for details",
"invalid_access_token": "Invalid access token",
"missing_configuration": "The component is not configured. Please follow the documentation.",
"oauth_error": "Received invalid token data.",
"open_spreadsheet_failure": "Error while opening spreadsheet, see error log for details",
"reauth_successful": "Re-authentication was successful",
"timeout_connect": "Timeout establishing connection",
"unknown": "Unexpected error"
},
"create_entry": {
"default": "Successfully authenticated and spreadsheet created at: {url}"
},
"step": {
"auth": {
"title": "Link Google Account"
},
"pick_implementation": {
"title": "Pick Authentication Method"
}
}
}
}

View File

@ -8,6 +8,7 @@ To update, run python3 -m script.hassfest
APPLICATION_CREDENTIALS = [
"geocaching",
"google",
"google_sheets",
"home_connect",
"lametric",
"lyric",

View File

@ -142,6 +142,7 @@ FLOWS = {
"gogogate2",
"goodwe",
"google",
"google_sheets",
"google_travel_time",
"govee_ble",
"gpslogger",

View File

@ -798,6 +798,9 @@ gridnet==4.0.0
# homeassistant.components.growatt_server
growattServer==1.2.2
# homeassistant.components.google_sheets
gspread==5.5.0
# homeassistant.components.gstreamer
gstreamer-player==1.1.2

View File

@ -0,0 +1,314 @@
"""Test the Google Sheets config flow."""
from collections.abc import Generator
from unittest.mock import Mock, patch
from gspread import GSpreadException
import oauth2client
import pytest
from homeassistant import config_entries
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_sheets.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
SHEET_ID = "google-sheet-id"
TITLE = "Google Sheets"
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(CLIENT_ID, CLIENT_SECRET),
)
@pytest.fixture(autouse=True)
async def mock_client() -> Generator[Mock, None, None]:
"""Fixture to setup a fake spreadsheet client library."""
with patch(
"homeassistant.components.google_sheets.config_flow.Client"
) as mock_client:
yield mock_client
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
setup_credentials,
mock_client,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"google_sheets", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
# Prepare fake client library response when creating the sheet
mock_create = Mock()
mock_create.return_value.id = SHEET_ID
mock_client.return_value.create = mock_create
aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_sheets.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert len(mock_client.mock_calls) == 2
assert result.get("type") == "create_entry"
assert result.get("title") == TITLE
assert "result" in result
assert result.get("result").unique_id == SHEET_ID
assert "token" in result.get("result").data
assert result.get("result").data["token"].get("access_token") == "mock-access-token"
assert (
result.get("result").data["token"].get("refresh_token") == "mock-refresh-token"
)
async def test_create_sheet_error(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
setup_credentials,
mock_client,
) -> None:
"""Test case where creating the spreadsheet fails."""
result = await hass.config_entries.flow.async_init(
"google_sheets", context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
# Prepare fake exception creating the spreadsheet
mock_create = Mock()
mock_create.side_effect = GSpreadException()
mock_client.return_value.create = mock_create
aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == "abort"
assert result.get("reason") == "create_spreadsheet_failure"
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
setup_credentials,
mock_client,
) -> None:
"""Test the reauthentication case updates the existing config entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=SHEET_ID,
data={
"token": {
"access_token": "mock-access-token",
},
},
)
config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
# Config flow will lookup existing key to make sure it still exists
mock_open = Mock()
mock_open.return_value.id = SHEET_ID
mock_client.return_value.open_by_key = mock_open
aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI,
json={
"refresh_token": "mock-refresh-token",
"access_token": "updated-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_sheets.async_setup_entry", return_value=True
) as mock_setup:
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert result.get("type") == "abort"
assert result.get("reason") == "reauth_successful"
assert config_entry.unique_id == SHEET_ID
assert "token" in config_entry.data
# Verify access token is refreshed
assert config_entry.data["token"].get("access_token") == "updated-access-token"
assert config_entry.data["token"].get("refresh_token") == "mock-refresh-token"
async def test_reauth_abort(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
setup_credentials,
mock_client,
) -> None:
"""Test failure case during reauth."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=SHEET_ID,
data={
"token": {
"access_token": "mock-access-token",
},
},
)
config_entry.add_to_hass(hass)
config_entry.async_start_reauth(hass)
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{oauth2client.GOOGLE_AUTH_URI}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=https://www.googleapis.com/auth/drive.file"
"&access_type=offline&prompt=consent"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
# Simulate failure looking up existing spreadsheet
mock_open = Mock()
mock_open.return_value.id = SHEET_ID
mock_open.side_effect = GSpreadException()
mock_client.return_value.open_by_key = mock_open
aioclient_mock.post(
oauth2client.GOOGLE_TOKEN_URI,
json={
"refresh_token": "mock-refresh-token",
"access_token": "updated-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") == "abort"
assert result.get("reason") == "open_spreadsheet_failure"

View File

@ -0,0 +1,214 @@
"""Tests for Google Sheets."""
from collections.abc import Awaitable, Callable, Generator
import http
import time
from unittest.mock import patch
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_sheets import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
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"
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
) -> Generator[ComponentSetup, None, None]:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, "application_credentials", {})
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()
yield func
# Verify clean unload
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
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
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
@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,
scopes: list[str],
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,
scopes: list[str],
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_append_sheet(
hass: HomeAssistant,
setup_integration: ComponentSetup,
config_entry: MockConfigEntry,
) -> 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
with patch("homeassistant.components.google_sheets.Client") as mock_client:
await hass.services.async_call(
DOMAIN,
"append_sheet",
{
"config_entry": config_entry.entry_id,
"worksheet": "Sheet1",
"data": {"foo": "bar"},
},
blocking=True,
)
assert len(mock_client.mock_calls) == 8