Add support for deleting To-do items in Google Tasks (#102967)

* Add support for deleting To-do items in Google Tasks

* Cleanup multipart test

* Fix comments

* Add additional error checking to increase coverage

* Apply suggestions and fix tests
This commit is contained in:
Allen Porter 2023-11-08 09:13:48 -08:00 committed by GitHub
parent 5901f6f7e7
commit cec617cfbb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 470 additions and 13 deletions

View file

@ -2,6 +2,7 @@
from collections.abc import Awaitable, Callable
from http import HTTPStatus
import json
from typing import Any
from unittest.mock import Mock, patch
@ -13,6 +14,7 @@ from syrupy.assertion import SnapshotAssertion
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.typing import WebSocketGenerator
@ -29,12 +31,30 @@ EMPTY_RESPONSE = {}
LIST_TASKS_RESPONSE = {
"items": [],
}
ERROR_RESPONSE = {
"error": {
"code": 400,
"message": "Invalid task ID",
"errors": [
{"message": "Invalid task ID", "domain": "global", "reason": "invalid"}
],
}
}
CONTENT_ID = "Content-ID"
BOUNDARY = "batch_00972cc8-75bd-11ee-9692-0242ac110002" # Arbitrary uuid
LIST_TASKS_RESPONSE_WATER = {
"items": [
{"id": "some-task-id", "title": "Water", "status": "needsAction"},
],
}
LIST_TASKS_RESPONSE_MULTIPLE = {
"items": [
{"id": "some-task-id-1", "title": "Water", "status": "needsAction"},
{"id": "some-task-id-2", "title": "Milk", "status": "needsAction"},
{"id": "some-task-id-3", "title": "Cheese", "status": "needsAction"},
],
}
@pytest.fixture
@ -88,14 +108,87 @@ def mock_api_responses() -> list[dict | list]:
return []
def create_response_object(api_response: dict | list) -> tuple[Response, bytes]:
"""Create an http response."""
return (
Response({"Content-Type": "application/json"}),
json.dumps(api_response).encode(),
)
def create_batch_response_object(
content_ids: list[str], api_responses: list[dict | list | Response]
) -> tuple[Response, bytes]:
"""Create a batch response in the multipart/mixed format."""
assert len(api_responses) == len(content_ids)
content = []
for api_response in api_responses:
status = 200
body = ""
if isinstance(api_response, Response):
status = api_response.status
else:
body = json.dumps(api_response)
content.extend(
[
f"--{BOUNDARY}",
"Content-Type: application/http",
f"{CONTENT_ID}: {content_ids.pop()}",
"",
f"HTTP/1.1 {status} OK",
"Content-Type: application/json; charset=UTF-8",
"",
body,
]
)
content.append(f"--{BOUNDARY}--")
body = ("\r\n".join(content)).encode()
return (
Response(
{
"Content-Type": f"multipart/mixed; boundary={BOUNDARY}",
"Content-ID": "1",
}
),
body,
)
def create_batch_response_handler(
api_responses: list[dict | list | Response],
) -> Callable[[Any], tuple[Response, bytes]]:
"""Create a fake http2lib response handler that supports generating batch responses.
Multi-part response objects are dynamically generated since they
need to match the Content-ID of the incoming request.
"""
def _handler(url, method, **kwargs) -> tuple[Response, bytes]:
next_api_response = api_responses.pop(0)
if method == "POST" and (body := kwargs.get("body")):
content_ids = [
line[len(CONTENT_ID) + 2 :]
for line in body.splitlines()
if line.startswith(f"{CONTENT_ID}:")
]
if content_ids:
return create_batch_response_object(content_ids, next_api_response)
return create_response_object(next_api_response)
return _handler
@pytest.fixture(name="response_handler")
def mock_response_handler(api_responses: list[dict | list]) -> list:
"""Create a mock http2lib response handler."""
return [create_response_object(api_response) for api_response in api_responses]
@pytest.fixture(autouse=True)
def mock_http_response(api_responses: list[dict | list]) -> Mock:
def mock_http_response(response_handler: list | Callable) -> Mock:
"""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) as mock_response:
with patch("httplib2.Http.request", side_effect=response_handler) as mock_response:
yield mock_response
@ -146,6 +239,29 @@ async def test_get_items(
assert state.state == "1"
@pytest.mark.parametrize(
"response_handler",
[
([(Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR}), b"")]),
],
)
async def test_list_items_server_error(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
hass_ws_client: WebSocketGenerator,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
) -> None:
"""Test an error returned by the server when setting up the platform."""
assert await integration_setup()
await hass_ws_client(hass)
state = hass.states.get("todo.my_tasks")
assert state is None
@pytest.mark.parametrize(
"api_responses",
[
@ -176,6 +292,33 @@ async def test_empty_todo_list(
assert state.state == "0"
@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
ERROR_RESPONSE,
]
],
)
async def test_task_items_error_response(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
hass_ws_client: WebSocketGenerator,
ws_get_items: Callable[[], Awaitable[dict[str, str]]],
) -> None:
"""Test an error while getting todo list items."""
assert await integration_setup()
await hass_ws_client(hass)
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "unavailable"
@pytest.mark.parametrize(
"api_responses",
[
@ -183,7 +326,7 @@ async def test_empty_todo_list(
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE,
EMPTY_RESPONSE, # create
LIST_TASKS_RESPONSE, # refresh after create
LIST_TASKS_RESPONSE, # refresh after delete
]
],
)
@ -216,6 +359,41 @@ async def test_create_todo_list_item(
assert call.kwargs.get("body") == snapshot
@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_WATER,
ERROR_RESPONSE,
]
],
)
async def test_create_todo_list_item_error(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Mock,
snapshot: SnapshotAssertion,
) -> None:
"""Test for an error response when creating a To-do Item."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "1"
with pytest.raises(HomeAssistantError, match="Invalid task ID"):
await hass.services.async_call(
TODO_DOMAIN,
"add_item",
{"item": "Soda"},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)
@pytest.mark.parametrize(
"api_responses",
[
@ -256,6 +434,41 @@ async def test_update_todo_list_item(
assert call.kwargs.get("body") == snapshot
@pytest.mark.parametrize(
"api_responses",
[
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_WATER,
ERROR_RESPONSE, # update fails
]
],
)
async def test_update_todo_list_item_error(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any,
snapshot: SnapshotAssertion,
) -> None:
"""Test for an error response when updating a To-do Item."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "1"
with pytest.raises(HomeAssistantError, match="Invalid task ID"):
await hass.services.async_call(
TODO_DOMAIN,
"update_item",
{"item": "some-task-id", "rename": "Soda", "status": "completed"},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)
@pytest.mark.parametrize(
"api_responses",
[
@ -334,3 +547,170 @@ async def test_partial_update_status(
assert call
assert call.args == snapshot
assert call.kwargs.get("body") == snapshot
@pytest.mark.parametrize(
"response_handler",
[
(
create_batch_response_handler(
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_MULTIPLE,
[EMPTY_RESPONSE, EMPTY_RESPONSE, EMPTY_RESPONSE], # Delete batch
LIST_TASKS_RESPONSE, # refresh after create
]
)
)
],
)
async def test_delete_todo_list_item(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any,
snapshot: SnapshotAssertion,
) -> None:
"""Test for deleting multiple To-do Items."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "3"
await hass.services.async_call(
TODO_DOMAIN,
"remove_item",
{"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)
assert len(mock_http_response.call_args_list) == 4
call = mock_http_response.call_args_list[2]
assert call
assert call.args == snapshot
@pytest.mark.parametrize(
"response_handler",
[
(
create_batch_response_handler(
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_MULTIPLE,
[
EMPTY_RESPONSE,
ERROR_RESPONSE, # one item is a failure
EMPTY_RESPONSE,
],
LIST_TASKS_RESPONSE, # refresh after create
]
)
)
],
)
async def test_delete_partial_failure(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any,
snapshot: SnapshotAssertion,
) -> None:
"""Test for partial failure when deleting multiple To-do Items."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "3"
with pytest.raises(HomeAssistantError, match="Invalid task ID"):
await hass.services.async_call(
TODO_DOMAIN,
"remove_item",
{"item": ["some-task-id-1", "some-task-id-2", "some-task-id-3"]},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)
@pytest.mark.parametrize(
"response_handler",
[
(
create_batch_response_handler(
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_MULTIPLE,
[
"1234-invalid-json",
],
]
)
)
],
)
async def test_delete_invalid_json_response(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any,
snapshot: SnapshotAssertion,
) -> None:
"""Test delete with an invalid json response."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "3"
with pytest.raises(HomeAssistantError, match="unexpected response"):
await hass.services.async_call(
TODO_DOMAIN,
"remove_item",
{"item": ["some-task-id-1"]},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)
@pytest.mark.parametrize(
"response_handler",
[
(
create_batch_response_handler(
[
LIST_TASK_LIST_RESPONSE,
LIST_TASKS_RESPONSE_MULTIPLE,
[Response({"status": HTTPStatus.INTERNAL_SERVER_ERROR})],
]
)
)
],
)
async def test_delete_server_error(
hass: HomeAssistant,
setup_credentials: None,
integration_setup: Callable[[], Awaitable[bool]],
mock_http_response: Any,
snapshot: SnapshotAssertion,
) -> None:
"""Test delete with an invalid json response."""
assert await integration_setup()
state = hass.states.get("todo.my_tasks")
assert state
assert state.state == "3"
with pytest.raises(HomeAssistantError, match="responded with error"):
await hass.services.async_call(
TODO_DOMAIN,
"remove_item",
{"item": ["some-task-id-1"]},
target={"entity_id": "todo.my_tasks"},
blocking=True,
)