Add Google tasks integration, with initial read-only To-do list (#102629)

* Add Google Tasks integration

* Update tests and unique id

* Revert devcontainer change

* Increase test coverage

* Apply suggestions from code review

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove ternary

* Fix JSON

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Allen Porter 2023-10-24 21:30:29 -07:00 committed by GitHub
parent fb13d9ce7c
commit 0cb0e3ceeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 750 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Tests for the Google Tasks integration."""

View file

@ -0,0 +1,91 @@
"""Test fixtures for Google Tasks."""
from collections.abc import Awaitable, Callable
import time
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.google_tasks.const import DOMAIN, OAUTH2_SCOPES
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
FAKE_ACCESS_TOKEN = "some-access-token"
FAKE_REFRESH_TOKEN = "some-refresh-token"
FAKE_AUTH_IMPL = "conftest-imported-cred"
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return []
@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="token_entry")
def mock_token_entry(expires_at: int) -> dict[str, Any]:
"""Fixture for OAuth 'token' data for a ConfigEntry."""
return {
"access_token": FAKE_ACCESS_TOKEN,
"refresh_token": FAKE_REFRESH_TOKEN,
"scope": " ".join(OAUTH2_SCOPES),
"token_type": "Bearer",
"expires_at": expires_at,
}
@pytest.fixture(name="config_entry")
def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry:
"""Fixture for a config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
"auth_implementation": DOMAIN,
"token": token_entry,
},
)
@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(name="integration_setup")
async def mock_integration_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
platforms: list[str],
) -> Callable[[], Awaitable[bool]]:
"""Fixture to set up the integration."""
config_entry.add_to_hass(hass)
async def run() -> bool:
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
result = await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return result
return run

View file

@ -0,0 +1,66 @@
"""Test the Google Tasks config flow."""
from unittest.mock import patch
from homeassistant import config_entries
from homeassistant.components.google_tasks.const import (
DOMAIN,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock,
current_request_with_host,
setup_credentials,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
"google_tasks", 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"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
"&scope=https://www.googleapis.com/auth/tasks"
"&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"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
with patch(
"homeassistant.components.google_tasks.async_setup_entry", return_value=True
) as mock_setup:
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

View file

@ -0,0 +1,99 @@
"""Tests for Google Tasks."""
from collections.abc import Awaitable, Callable
import http
import time
import pytest
from homeassistant.components.google_tasks import DOMAIN
from homeassistant.components.google_tasks.const import OAUTH2_TOKEN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_setup(
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Test successful setup and unload."""
assert config_entry.state is ConfigEntryState.NOT_LOADED
await integration_setup()
assert config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert not hass.services.async_services().get(DOMAIN)
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
async def test_expired_token_refresh_success(
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
setup_credentials: None,
) -> None:
"""Test expired token is refreshed."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"access_token": "updated-access-token",
"refresh_token": "updated-refresh-token",
"expires_at": time.time() + 3600,
"expires_in": 3600,
},
)
await integration_setup()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data["token"]["access_token"] == "updated-access-token"
assert config_entry.data["token"]["expires_in"] == 3600
@pytest.mark.parametrize(
("expires_at", "status", "expected_state"),
[
(
time.time() - 3600,
http.HTTPStatus.UNAUTHORIZED,
ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future
),
(
time.time() - 3600,
http.HTTPStatus.INTERNAL_SERVER_ERROR,
ConfigEntryState.SETUP_RETRY,
),
],
ids=["unauthorized", "internal_server_error"],
)
async def test_expired_token_refresh_failure(
hass: HomeAssistant,
integration_setup: Callable[[], Awaitable[bool]],
aioclient_mock: AiohttpClientMocker,
config_entry: MockConfigEntry,
setup_credentials: None,
status: http.HTTPStatus,
expected_state: ConfigEntryState,
) -> None:
"""Test failure while refreshing token with a transient error."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
status=status,
)
await integration_setup()
assert config_entry.state is expected_state

View file

@ -0,0 +1,165 @@
"""Tests for Google Tasks todo platform."""
from collections.abc import Awaitable, Callable
import json
from unittest.mock import patch
from httplib2 import Response
import pytest
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.typing import WebSocketGenerator
ENTITY_ID = "todo.my_tasks"
LIST_TASK_LIST_RESPONSE = {
"items": [
{
"id": "task-list-id-1",
"title": "My tasks",
},
]
}
@pytest.fixture
def platforms() -> list[str]:
"""Fixture to specify platforms to test."""
return [Platform.TODO]
@pytest.fixture
def ws_req_id() -> Callable[[], int]:
"""Fixture for incremental websocket requests."""
id = 0
def next_id() -> int:
nonlocal id
id += 1
return id
return next_id
@pytest.fixture
async def ws_get_items(
hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int]
) -> Callable[[], Awaitable[dict[str, str]]]:
"""Fixture to fetch items from the todo websocket."""
async def get() -> list[dict[str, str]]:
# Fetch items using To-do platform
client = await hass_ws_client()
id = ws_req_id()
await client.send_json(
{
"id": id,
"type": "todo/item/list",
"entity_id": ENTITY_ID,
}
)
resp = await client.receive_json()
assert resp.get("id") == id
assert resp.get("success")
return resp.get("result", {}).get("items", [])
return get
@pytest.fixture(name="api_responses")
def mock_api_responses() -> list[dict | list]:
"""Fixture for API responses to return during test."""
return []
@pytest.fixture(autouse=True)
def mock_http_response(api_responses: list[dict | list]) -> None:
"""Fixture to fake out http2lib responses."""
responses = [
(Response({}), bytes(json.dumps(api_response), encoding="utf-8"))
for api_response in api_responses
]
with patch("httplib2.Http.request", side_effect=responses):
yield
@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
{
"items": [
{"id": "task-1", "title": "Task 1", "status": "needsAction"},
{"id": "task-2", "title": "Task 2", "status": "completed"},
],
},
]
],
)
async def test_get_items(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
hass_ws_client: WebSocketGenerator,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
) -> None:
"""Test getting todo list items."""
assert await integration_setup()
await hass_ws_client(hass)
items = await ws_get_items()
assert items == [
{
"uid": "task-1",
"summary": "Task 1",
"status": "needs_action",
},
{
"uid": "task-2",
"summary": "Task 2",
"status": "completed",
},
]
# State reflect that one task needs action
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "1"
@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
{
"items": [],
},
]
],
)
async def test_empty_todo_list(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
hass_ws_client: WebSocketGenerator,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
) -> None:
"""Test getting todo list items."""
assert await integration_setup()
await hass_ws_client(hass)
items = await ws_get_items()
assert items == []
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "0"