core/tests/components/caldav/test_todo.py

810 lines
22 KiB
Python

"""The tests for the webdav todo component."""
from datetime import UTC, date, datetime
from typing import Any
from unittest.mock import MagicMock, Mock
from caldav.lib.error import DAVError, NotFoundError
from caldav.objects import Todo
import pytest
from homeassistant.components.todo import (
ATTR_DESCRIPTION,
ATTR_DUE_DATE,
ATTR_DUE_DATETIME,
ATTR_ITEM,
ATTR_RENAME,
ATTR_STATUS,
DOMAIN as TODO_DOMAIN,
TodoServices,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
CALENDAR_NAME = "My Tasks"
ENTITY_NAME = "My tasks"
TEST_ENTITY = "todo.my_tasks"
SUPPORTED_FEATURES = 119
TODO_NO_STATUS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:1
DTSTAMP:20231125T000000Z
SUMMARY:Milk
END:VTODO
END:VCALENDAR"""
TODO_NEEDS_ACTION = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:2
DTSTAMP:20171125T000000Z
SUMMARY:Cheese
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR"""
RESULT_ITEM = {
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
}
TODO_COMPLETED = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:3
DTSTAMP:20231125T000000Z
SUMMARY:Wine
STATUS:COMPLETED
END:VTODO
END:VCALENDAR"""
TODO_NO_SUMMARY = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:4
DTSTAMP:20171126T000000Z
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR"""
TODO_ALL_FIELDS = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//E-Corp.//CalDAV Client//EN
BEGIN:VTODO
UID:2
DTSTAMP:20171125T000000Z
SUMMARY:Cheese
DESCRIPTION:Any kind will do
STATUS:NEEDS-ACTION
DUE:20171126
END:VTODO
END:VCALENDAR"""
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to set up config entry platforms."""
return [Platform.TODO]
@pytest.fixture(autouse=True)
async def set_tz(hass: HomeAssistant) -> None:
"""Fixture to set timezone with fixed offset year round."""
await hass.config.async_set_time_zone("America/Regina")
@pytest.fixture(name="todos")
def mock_todos() -> list[str]:
"""Fixture to return VTODO objects for the calendar."""
return []
@pytest.fixture(name="supported_components")
def mock_supported_components() -> list[str]:
"""Fixture to set supported components of the calendar."""
return ["VTODO"]
@pytest.fixture(name="calendar")
def mock_calendar(supported_components: list[str]) -> Mock:
"""Fixture to create the primary calendar for the test."""
calendar = Mock()
calendar.search = MagicMock(return_value=[])
calendar.name = CALENDAR_NAME
calendar.get_supported_components = MagicMock(return_value=supported_components)
return calendar
def create_todo(calendar: Mock, idx: str, ics: str) -> Todo:
"""Create a caldav Todo object."""
return Todo(client=None, url=f"{idx}.ics", data=ics, parent=calendar, id=idx)
@pytest.fixture(autouse=True)
def mock_search_items(calendar: Mock, todos: list[str]) -> None:
"""Fixture to add search results to the test calendar."""
calendar.search.return_value = [
create_todo(calendar, str(idx), item) for idx, item in enumerate(todos)
]
@pytest.fixture(name="calendars")
def mock_calendars(calendar: Mock) -> list[Mock]:
"""Fixture to create calendars for the test."""
return [calendar]
@pytest.fixture(autouse=True)
async def mock_add_to_hass(
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Fixture to add the ConfigEntry."""
config_entry.add_to_hass(hass)
IGNORE_COMPONENTS = ["BEGIN", "END", "DTSTAMP", "PRODID", "UID", "VERSION"]
def compact_ics(ics: str) -> list[str]:
"""Pull out parts of the rfc5545 content useful for assertions in tests."""
return [
line
for line in ics.split("\n")
if line and not any(filter(line.startswith, IGNORE_COMPONENTS))
]
@pytest.mark.parametrize(
("todos", "expected_state"),
[
([], "0"),
(
[TODO_NEEDS_ACTION],
"1",
),
(
[TODO_NO_STATUS],
"1",
),
([TODO_COMPLETED], "0"),
([TODO_NO_STATUS, TODO_NEEDS_ACTION, TODO_COMPLETED], "2"),
([TODO_NO_SUMMARY], "0"),
],
ids=(
"empty",
"needs_action",
"no_status",
"completed",
"all",
"no_summary",
),
)
async def test_todo_list_state(
hass: HomeAssistant,
config_entry: MockConfigEntry,
expected_state: str,
) -> None:
"""Test a calendar entity from a config entry."""
await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.name == ENTITY_NAME
assert state.state == expected_state
assert dict(state.attributes) == {
"friendly_name": ENTITY_NAME,
"supported_features": SUPPORTED_FEATURES,
}
@pytest.mark.parametrize(
("supported_components", "has_entity"),
[([], False), (["VTODO"], True), (["VEVENT"], False), (["VEVENT", "VTODO"], True)],
)
async def test_supported_components(
hass: HomeAssistant,
config_entry: MockConfigEntry,
has_entity: bool,
) -> None:
"""Test a calendar supported components matches VTODO."""
await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(TEST_ENTITY)
assert (state is not None) == has_entity
@pytest.mark.parametrize(
("item_data", "expcted_save_args", "expected_item"),
[
(
{},
{"status": "NEEDS-ACTION", "summary": "Cheese"},
RESULT_ITEM,
),
(
{ATTR_DUE_DATE: "2023-11-18"},
{"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)},
{**RESULT_ITEM, "due": "2023-11-18"},
),
(
{ATTR_DUE_DATETIME: "2023-11-18T08:30:00-06:00"},
{
"status": "NEEDS-ACTION",
"summary": "Cheese",
"due": datetime(2023, 11, 18, 14, 30, 00, tzinfo=UTC),
},
{**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"},
),
(
{ATTR_DESCRIPTION: "Make sure to get Swiss"},
{
"status": "NEEDS-ACTION",
"summary": "Cheese",
"description": "Make sure to get Swiss",
},
{**RESULT_ITEM, "description": "Make sure to get Swiss"},
),
],
ids=[
"summary",
"due_date",
"due_datetime",
"description",
],
)
async def test_add_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
item_data: dict[str, Any],
expcted_save_args: dict[str, Any],
expected_item: dict[str, Any],
) -> None:
"""Test adding an item to the list."""
calendar.search.return_value = []
await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "0"
# Simulate return value for the state update after the service call
calendar.search.return_value = [create_todo(calendar, "2", TODO_NEEDS_ACTION)]
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
{ATTR_ITEM: "Cheese", **item_data},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
assert calendar.save_todo.call_args
assert calendar.save_todo.call_args.kwargs == expcted_save_args
# Verify state was updated
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "1"
async def test_add_item_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendar: Mock,
) -> None:
"""Test failure when adding an item to the list."""
await hass.config_entries.async_setup(config_entry.entry_id)
calendar.save_todo.side_effect = DAVError()
with pytest.raises(HomeAssistantError, match="CalDAV save error"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.ADD_ITEM,
{ATTR_ITEM: "Cheese"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
@pytest.mark.parametrize(
("update_data", "expected_ics", "expected_state", "expected_item"),
[
(
{ATTR_RENAME: "Swiss Cheese"},
[
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Swiss Cheese",
],
"1",
{
"uid": "2",
"summary": "Swiss Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
),
(
{ATTR_STATUS: "needs_action"},
[
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
),
(
{ATTR_STATUS: "completed"},
[
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:COMPLETED",
"SUMMARY:Cheese",
],
"0",
{
"uid": "2",
"summary": "Cheese",
"status": "completed",
"description": "Any kind will do",
"due": "2017-11-26",
},
),
(
{ATTR_RENAME: "Swiss Cheese", ATTR_STATUS: "needs_action"},
[
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Swiss Cheese",
],
"1",
{
"uid": "2",
"summary": "Swiss Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2017-11-26",
},
),
(
{ATTR_DUE_DATE: "2023-11-18"},
[
"DESCRIPTION:Any kind will do",
"DUE;VALUE=DATE:20231118",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2023-11-18",
},
),
(
{ATTR_DUE_DATETIME: "2023-11-18T08:30:00-06:00"},
[
"DESCRIPTION:Any kind will do",
"DUE;TZID=America/Regina:20231118T083000",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
"due": "2023-11-18T08:30:00-06:00",
},
),
(
{ATTR_DUE_DATETIME: None},
[
"DESCRIPTION:Any kind will do",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"description": "Any kind will do",
},
),
(
{ATTR_DESCRIPTION: "Make sure to get Swiss"},
[
"DESCRIPTION:Make sure to get Swiss",
"DUE;VALUE=DATE:20171126",
"STATUS:NEEDS-ACTION",
"SUMMARY:Cheese",
],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"due": "2017-11-26",
"description": "Make sure to get Swiss",
},
),
(
{ATTR_DESCRIPTION: None},
["DUE;VALUE=DATE:20171126", "STATUS:NEEDS-ACTION", "SUMMARY:Cheese"],
"1",
{
"uid": "2",
"summary": "Cheese",
"status": "needs_action",
"due": "2017-11-26",
},
),
],
ids=[
"rename",
"status_needs_action",
"status_completed",
"rename_status",
"due_date",
"due_datetime",
"clear_due_date",
"description",
"clear_description",
],
)
async def test_update_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
update_data: dict[str, Any],
expected_ics: list[str],
expected_state: str,
expected_item: dict[str, Any],
) -> None:
"""Test updating an item on the list."""
item = Todo(dav_client, None, TODO_ALL_FIELDS, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "1"
calendar.todo_by_uid = MagicMock(return_value=item)
dav_client.put.return_value.status = 204
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ITEM: "Cheese",
**update_data,
},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
assert dav_client.put.call_args
ics = dav_client.put.call_args.args[1]
assert compact_ics(ics) == expected_ics
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == expected_state
result = await hass.services.async_call(
TODO_DOMAIN,
TodoServices.GET_ITEMS,
{},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
return_response=True,
)
assert result == {TEST_ENTITY: {"items": [expected_item]}}
async def test_update_item_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
) -> None:
"""Test failure when updating an item on the list."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
calendar.todo_by_uid = MagicMock(return_value=item)
dav_client.put.side_effect = DAVError()
with pytest.raises(HomeAssistantError, match="CalDAV save error"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ITEM: "Cheese",
ATTR_STATUS: "completed",
},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
@pytest.mark.parametrize(
("side_effect", "match"),
[(DAVError, "CalDAV lookup error"), (NotFoundError, "Could not find")],
)
async def test_update_item_lookup_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
side_effect: Any,
match: str,
) -> None:
"""Test failure when looking up an item to update."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
calendar.todo_by_uid.side_effect = side_effect
with pytest.raises(HomeAssistantError, match=match):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ITEM: "Cheese",
ATTR_STATUS: "completed",
},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
@pytest.mark.parametrize(
("uids_to_delete", "expect_item1_delete_called", "expect_item2_delete_called"),
[
([], False, False),
(["Cheese"], True, False),
(["Wine"], False, True),
(["Wine", "Cheese"], True, True),
],
ids=("none", "item1-only", "item2-only", "both-items"),
)
async def test_remove_item(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
uids_to_delete: list[str],
expect_item1_delete_called: bool,
expect_item2_delete_called: bool,
) -> None:
"""Test removing an item on the list."""
item1 = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
item2 = Todo(dav_client, None, TODO_COMPLETED, calendar, "3")
calendar.search = MagicMock(return_value=[item1, item2])
await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get(TEST_ENTITY)
assert state
assert state.state == "1"
def lookup(uid: str) -> Mock:
assert uid in ("2", "3")
if uid == "2":
return item1
return item2
calendar.todo_by_uid = Mock(side_effect=lookup)
item1.delete = Mock()
item2.delete = Mock()
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ITEM: uids_to_delete},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
assert item1.delete.called == expect_item1_delete_called
assert item2.delete.called == expect_item2_delete_called
@pytest.mark.parametrize(
("todos", "side_effect", "match"),
[
([TODO_NEEDS_ACTION], DAVError, "CalDAV lookup error"),
([TODO_NEEDS_ACTION], NotFoundError, "Could not find"),
],
)
async def test_remove_item_lookup_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
calendar: Mock,
side_effect: Any,
match: str,
) -> None:
"""Test failure while removing an item from the list."""
await hass.config_entries.async_setup(config_entry.entry_id)
calendar.todo_by_uid.side_effect = side_effect
with pytest.raises(HomeAssistantError, match=match):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ITEM: "Cheese"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
async def test_remove_item_failure(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
) -> None:
"""Test removing an item on the list."""
item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
def lookup(uid: str) -> Mock:
return item
calendar.todo_by_uid = Mock(side_effect=lookup)
dav_client.delete.return_value.status = 500
with pytest.raises(HomeAssistantError, match="CalDAV delete error"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ITEM: "Cheese"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
async def test_remove_item_not_found(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
) -> None:
"""Test removing an item on the list."""
item = Todo(dav_client, "2.ics", TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
def lookup(uid: str) -> Mock:
return item
calendar.todo_by_uid.side_effect = NotFoundError()
with pytest.raises(HomeAssistantError, match="Could not find"):
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.REMOVE_ITEM,
{ATTR_ITEM: "Cheese"},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
async def test_subscribe(
hass: HomeAssistant,
config_entry: MockConfigEntry,
dav_client: Mock,
calendar: Mock,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test subscription to item updates."""
item = Todo(dav_client, None, TODO_NEEDS_ACTION, calendar, "2")
calendar.search = MagicMock(return_value=[item])
await hass.config_entries.async_setup(config_entry.entry_id)
# Subscribe and get the initial list
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "todo/item/subscribe",
"entity_id": TEST_ENTITY,
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
subscription_id = msg["id"]
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
items = msg["event"].get("items")
assert items
assert len(items) == 1
assert items[0]["summary"] == "Cheese"
assert items[0]["status"] == "needs_action"
assert items[0]["uid"]
calendar.todo_by_uid = MagicMock(return_value=item)
dav_client.put.return_value.status = 204
# Reflect update for state refresh after update
calendar.search.return_value = [
Todo(
dav_client, None, TODO_NEEDS_ACTION.replace("Cheese", "Milk"), calendar, "2"
)
]
await hass.services.async_call(
TODO_DOMAIN,
TodoServices.UPDATE_ITEM,
{
ATTR_ITEM: "Cheese",
ATTR_RENAME: "Milk",
},
target={ATTR_ENTITY_ID: TEST_ENTITY},
blocking=True,
)
# Verify update is published
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
items = msg["event"].get("items")
assert items
assert len(items) == 1
assert items[0]["summary"] == "Milk"
assert items[0]["status"] == "needs_action"
assert items[0]["uid"]