"""Tests for the todo integration.""" from collections.abc import Generator from typing import Any from unittest.mock import AsyncMock import pytest import voluptuous as vol from homeassistant.components.todo import ( DOMAIN, TodoItem, TodoItemStatus, TodoListEntity, TodoListEntityFeature, intent as todo_intent, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddEntitiesCallback from tests.common import ( MockConfigEntry, MockModule, MockPlatform, mock_config_flow, mock_integration, mock_platform, ) from tests.typing import WebSocketGenerator TEST_DOMAIN = "test" ITEM_1 = { "uid": "1", "summary": "Item #1", "status": "needs_action", } ITEM_2 = { "uid": "2", "summary": "Item #2", "status": "completed", } class MockFlow(ConfigFlow): """Test flow.""" class MockTodoListEntity(TodoListEntity): """Test todo list entity.""" def __init__(self, items: list[TodoItem] | None = None) -> None: """Initialize entity.""" self._attr_todo_items = items or [] @property def items(self) -> list[TodoItem]: """Return the items in the To-do list.""" return self._attr_todo_items async def async_create_todo_item(self, item: TodoItem) -> None: """Add an item to the To-do list.""" self._attr_todo_items.append(item) async def async_delete_todo_items(self, uids: list[str]) -> None: """Delete an item in the To-do list.""" self._attr_todo_items = [item for item in self.items if item.uid not in uids] @pytest.fixture(autouse=True) def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: """Mock config flow.""" mock_platform(hass, f"{TEST_DOMAIN}.config_flow") with mock_config_flow(TEST_DOMAIN, MockFlow): yield @pytest.fixture(autouse=True) def mock_setup_integration(hass: HomeAssistant) -> None: """Fixture to set up a mock integration.""" async def async_setup_entry_init( hass: HomeAssistant, config_entry: ConfigEntry ) -> bool: """Set up test config entry.""" await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) return True async def async_unload_entry_init( hass: HomeAssistant, config_entry: ConfigEntry, ) -> bool: await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) return True mock_platform(hass, f"{TEST_DOMAIN}.config_flow") mock_integration( hass, MockModule( TEST_DOMAIN, async_setup_entry=async_setup_entry_init, async_unload_entry=async_unload_entry_init, ), ) async def create_mock_platform( hass: HomeAssistant, entities: list[TodoListEntity], ) -> MockConfigEntry: """Create a todo platform with the specified entities.""" async def async_setup_entry_platform( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up test event platform via config entry.""" async_add_entities(entities) mock_platform( hass, f"{TEST_DOMAIN}.{DOMAIN}", MockPlatform(async_setup_entry=async_setup_entry_platform), ) config_entry = MockConfigEntry(domain=TEST_DOMAIN) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry @pytest.fixture(name="test_entity") def mock_test_entity() -> TodoListEntity: """Fixture that creates a test TodoList entity with mock service calls.""" entity1 = MockTodoListEntity( [ TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), ] ) entity1.entity_id = "todo.entity1" entity1._attr_supported_features = ( TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM | TodoListEntityFeature.DELETE_TODO_ITEM | TodoListEntityFeature.MOVE_TODO_ITEM ) entity1.async_create_todo_item = AsyncMock(wraps=entity1.async_create_todo_item) entity1.async_update_todo_item = AsyncMock() entity1.async_delete_todo_items = AsyncMock(wraps=entity1.async_delete_todo_items) entity1.async_move_todo_item = AsyncMock() return entity1 async def test_unload_entry( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test unloading a config entry with a todo entity.""" config_entry = await create_mock_platform(hass, [test_entity]) assert config_entry.state == ConfigEntryState.LOADED state = hass.states.get("todo.entity1") assert state assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.NOT_LOADED state = hass.states.get("todo.entity1") assert not state async def test_list_todo_items( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, test_entity: TodoListEntity, ) -> None: """Test listing items in a To-do list.""" await create_mock_platform(hass, [test_entity]) state = hass.states.get("todo.entity1") assert state assert state.state == "1" assert state.attributes == {"supported_features": 15} client = await hass_ws_client(hass) await client.send_json( {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("success") assert resp.get("result") == { "items": [ ITEM_1, ITEM_2, ] } @pytest.mark.parametrize( ("service_data", "expected_items"), [ ({}, [ITEM_1, ITEM_2]), ( [ {"status": [TodoItemStatus.COMPLETED, TodoItemStatus.NEEDS_ACTION]}, [ITEM_1, ITEM_2], ] ), ( [ {"status": [TodoItemStatus.NEEDS_ACTION]}, [ITEM_1], ] ), ( [ {"status": [TodoItemStatus.COMPLETED]}, [ITEM_2], ] ), ], ) async def test_get_items_service( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, test_entity: TodoListEntity, service_data: dict[str, Any], expected_items: list[dict[str, Any]], ) -> None: """Test listing items in a To-do list from a service call.""" await create_mock_platform(hass, [test_entity]) state = hass.states.get("todo.entity1") assert state assert state.state == "1" assert state.attributes == {"supported_features": 15} result = await hass.services.async_call( DOMAIN, "get_items", service_data, target={"entity_id": "todo.entity1"}, blocking=True, return_response=True, ) assert result == {"todo.entity1": {"items": expected_items}} async def test_unsupported_websocket( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test a To-do list that does not support features.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" await create_mock_platform(hass, [entity1]) client = await hass_ws_client(hass) await client.send_json( { "id": 1, "type": "todo/item/list", "entity_id": "todo.unknown", } ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "not_found" async def test_add_item_service( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test adding an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "add_item", {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_create_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid is None assert item.summary == "New item" assert item.status == TodoItemStatus.NEEDS_ACTION async def test_add_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test adding an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) test_entity.async_create_todo_item.side_effect = HomeAssistantError("Ooops") with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, "add_item", {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @pytest.mark.parametrize( ("item_data", "expected_error"), [ ({}, "required key not provided"), ({"item": ""}, "length of value must be at least 1"), ], ) async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], expected_error: str, ) -> None: """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(vol.Invalid, match=expected_error): await hass.services.async_call( DOMAIN, "add_item", item_data, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_update_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_update_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid == "1" assert item.summary == "Updated item" assert item.status == TodoItemStatus.COMPLETED async def test_update_todo_item_service_by_id_status_only( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "1", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_update_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid == "1" assert item.summary is None assert item.status == TodoItemStatus.COMPLETED async def test_update_todo_item_service_by_id_rename( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "1", "rename": "Updated item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_update_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid == "1" assert item.summary == "Updated item" assert item.status is None async def test_update_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) test_entity.async_update_todo_item.side_effect = HomeAssistantError("Ooops") with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, "update_item", {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_update_todo_item_service_by_summary( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "Item #1", "rename": "Something else", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_update_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid == "1" assert item.summary == "Something else" assert item.status == TodoItemStatus.COMPLETED async def test_update_todo_item_service_by_summary_only_status( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "update_item", {"item": "Item #1", "rename": "Something else"}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_update_todo_item.call_args assert args item = args.kwargs.get("item") assert item assert item.uid == "1" assert item.summary == "Something else" assert item.status is None async def test_update_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test updating an item in a To-do list by summary which is not found.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( DOMAIN, "update_item", {"item": "Item #7", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @pytest.mark.parametrize( ("item_data", "expected_error"), [ ({}, r"required key not provided @ data\['item'\]"), ({"status": "needs_action"}, r"required key not provided @ data\['item'\]"), ({"item": "Item #1"}, "must contain at least one of"), ( {"item": "", "status": "needs_action"}, "length of value must be at least 1", ), ], ) async def test_update_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], expected_error: str, ) -> None: """Test invalid input to the update item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(vol.Invalid, match=expected_error): await hass.services.async_call( DOMAIN, "update_item", item_data, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test removing an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "remove_item", {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args assert args.kwargs.get("uids") == ["1", "2"] async def test_remove_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test removing an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, "remove_item", {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_remove_todo_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test invalid input to the remove item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises( vol.Invalid, match=r"required key not provided @ data\['item'\]" ): await hass.services.async_call( DOMAIN, "remove_item", {}, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_remove_todo_item_service_by_summary( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test removing an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "remove_item", {"item": ["Item #1"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args assert args.kwargs.get("uids") == ["1"] async def test_remove_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test removing an item in a To-do list by summary which is not found.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( DOMAIN, "remove_item", {"item": ["Item #7"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_move_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, hass_ws_client: WebSocketGenerator, ) -> None: """Test moving an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) client = await hass_ws_client() await client.send_json( { "id": 1, "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", "previous_uid": "item-2", } ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("success") args = test_entity.async_move_todo_item.call_args assert args assert args.kwargs.get("uid") == "item-1" assert args.kwargs.get("previous_uid") == "item-2" async def test_move_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, hass_ws_client: WebSocketGenerator, ) -> None: """Test moving an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) test_entity.async_move_todo_item.side_effect = HomeAssistantError("Ooops") client = await hass_ws_client() await client.send_json( { "id": 1, "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", "previous_uid": "item-2", } ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "failed" assert resp.get("error", {}).get("message") == "Ooops" @pytest.mark.parametrize( ("item_data", "expected_status", "expected_error"), [ ( {"entity_id": "todo.unknown", "uid": "item-1"}, "not_found", "Entity not found", ), ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), ( {"entity_id": "todo.entity1", "previous_uid": "item-2"}, "invalid_format", "required key not provided", ), ], ) async def test_move_todo_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, hass_ws_client: WebSocketGenerator, item_data: dict[str, Any], expected_status: str, expected_error: str, ) -> None: """Test invalid input for the move item service.""" await create_mock_platform(hass, [test_entity]) client = await hass_ws_client() await client.send_json( { "id": 1, "type": "todo/item/move", **item_data, } ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == expected_status assert expected_error in resp.get("error", {}).get("message") @pytest.mark.parametrize( ("service_name", "payload"), [ ( "add_item", { "item": "New item", }, ), ( "remove_item", { "item": ["1"], }, ), ( "update_item", { "item": "1", "rename": "Updated item", }, ), ( "remove_completed_items", None, ), ], ) async def test_unsupported_service( hass: HomeAssistant, service_name: str, payload: dict[str, Any] | None, ) -> None: """Test a To-do list that does not support features.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" await create_mock_platform(hass, [entity1]) with pytest.raises( HomeAssistantError, match="does not support this service", ): await hass.services.async_call( DOMAIN, service_name, payload, target={"entity_id": "todo.entity1"}, blocking=True, ) async def test_move_item_unsupported( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test invalid input for the move item service.""" entity1 = TodoListEntity() entity1.entity_id = "todo.entity1" await create_mock_platform(hass, [entity1]) client = await hass_ws_client() await client.send_json( { "id": 1, "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", "previous_uid": "item-2", } ) resp = await client.receive_json() assert resp.get("id") == 1 assert resp.get("error", {}).get("code") == "not_supported" async def test_add_item_intent( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: """Test adding items to lists using an intent.""" await todo_intent.async_setup_intents(hass) entity1 = MockTodoListEntity() entity1._attr_name = "List 1" entity1.entity_id = "todo.list_1" entity2 = MockTodoListEntity() entity2._attr_name = "List 2" entity2.entity_id = "todo.list_2" await create_mock_platform(hass, [entity1, entity2]) # Add to first list response = await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "beer"}, "name": {"value": "list 1"}}, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(entity1.items) == 1 assert len(entity2.items) == 0 assert entity1.items[0].summary == "beer" entity1.items.clear() # Add to second list response = await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "cheese"}, "name": {"value": "List 2"}}, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(entity1.items) == 0 assert len(entity2.items) == 1 assert entity2.items[0].summary == "cheese" # List name is case insensitive response = await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "lIST 2"}}, ) assert response.response_type == intent.IntentResponseType.ACTION_DONE assert len(entity1.items) == 0 assert len(entity2.items) == 2 assert entity2.items[1].summary == "wine" # Missing list with pytest.raises(intent.IntentHandleError): await intent.async_handle( hass, "test", todo_intent.INTENT_LIST_ADD_ITEM, {"item": {"value": "wine"}, "name": {"value": "This list does not exist"}}, ) async def test_remove_completed_items_service( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test remove completed todo items service.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, "remove_completed_items", target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args assert args.kwargs.get("uids") == ["2"] test_entity.async_delete_todo_items.reset_mock() # calling service multiple times will not call the entity method await hass.services.async_call( DOMAIN, "remove_completed_items", target={"entity_id": "todo.entity1"}, blocking=True, ) test_entity.async_delete_todo_items.assert_not_called() async def test_remove_completed_items_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: """Test removing all completed item from a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, "remove_completed_items", target={"entity_id": "todo.entity1"}, blocking=True, )