"""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 DOMAIN as TODO_DOMAIN from homeassistant.const import 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""" @pytest.fixture def platforms() -> list[Platform]: """Fixture to set up config entry platforms.""" return [Platform.TODO] @pytest.fixture(autouse=True) def set_tz(hass: HomeAssistant) -> None: """Fixture to set timezone with fixed offset year round.""" hass.config.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) @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 config_entry.async_setup(hass) 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 config_entry.async_setup(hass) 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, ), ( {"due_date": "2023-11-18"}, {"status": "NEEDS-ACTION", "summary": "Cheese", "due": date(2023, 11, 18)}, {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"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"}, ), ( {"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 config_entry.async_setup(hass) 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, "add_item", {"item": "Cheese", **item_data}, target={"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 config_entry.async_setup(hass) calendar.save_todo.side_effect = DAVError() with pytest.raises(HomeAssistantError, match="CalDAV save error"): await hass.services.async_call( TODO_DOMAIN, "add_item", {"item": "Cheese"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @pytest.mark.parametrize( ("update_data", "expected_ics", "expected_state", "expected_item"), [ ( {"rename": "Swiss Cheese"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", {**RESULT_ITEM, "summary": "Swiss Cheese"}, ), ( {"status": "needs_action"}, ["SUMMARY:Cheese", "STATUS:NEEDS-ACTION"], "1", RESULT_ITEM, ), ( {"status": "completed"}, ["SUMMARY:Cheese", "STATUS:COMPLETED"], "0", {**RESULT_ITEM, "status": "completed"}, ), ( {"rename": "Swiss Cheese", "status": "needs_action"}, ["SUMMARY:Swiss Cheese", "STATUS:NEEDS-ACTION"], "1", {**RESULT_ITEM, "summary": "Swiss Cheese"}, ), ( {"due_date": "2023-11-18"}, ["SUMMARY:Cheese", "DUE;VALUE=DATE:20231118"], "1", {**RESULT_ITEM, "due": "2023-11-18"}, ), ( {"due_datetime": "2023-11-18T08:30:00-06:00"}, ["SUMMARY:Cheese", "DUE;TZID=America/Regina:20231118T083000"], "1", {**RESULT_ITEM, "due": "2023-11-18T08:30:00-06:00"}, ), ( {"description": "Make sure to get Swiss"}, ["SUMMARY:Cheese", "DESCRIPTION:Make sure to get Swiss"], "1", {**RESULT_ITEM, "description": "Make sure to get Swiss"}, ), ], ids=[ "rename", "status_needs_action", "status_completed", "rename_status", "due_date", "due_datetime", "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_NEEDS_ACTION, calendar, "2") calendar.search = MagicMock(return_value=[item]) await config_entry.async_setup(hass) 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, "update_item", { "item": "Cheese", **update_data, }, target={"entity_id": TEST_ENTITY}, blocking=True, ) assert dav_client.put.call_args ics = dav_client.put.call_args.args[1] for expected in expected_ics: assert expected in ics state = hass.states.get(TEST_ENTITY) assert state assert state.state == expected_state result = await hass.services.async_call( TODO_DOMAIN, "get_items", {}, target={"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 config_entry.async_setup(hass) 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, "update_item", { "item": "Cheese", "status": "completed", }, target={"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 config_entry.async_setup(hass) calendar.todo_by_uid.side_effect = side_effect with pytest.raises(HomeAssistantError, match=match): await hass.services.async_call( TODO_DOMAIN, "update_item", { "item": "Cheese", "status": "completed", }, target={"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 config_entry.async_setup(hass) state = hass.states.get(TEST_ENTITY) assert state assert state.state == "1" def lookup(uid: str) -> Mock: assert uid == "2" or uid == "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, "remove_item", {"item": uids_to_delete}, target={"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 config_entry.async_setup(hass) calendar.todo_by_uid.side_effect = side_effect with pytest.raises(HomeAssistantError, match=match): await hass.services.async_call( TODO_DOMAIN, "remove_item", {"item": "Cheese"}, target={"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 config_entry.async_setup(hass) 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, "remove_item", {"item": "Cheese"}, target={"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 config_entry.async_setup(hass) 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, "remove_item", {"item": "Cheese"}, target={"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 config_entry.async_setup(hass) # 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, "update_item", { "item": "Cheese", "rename": "Milk", }, target={"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"]