"""Test Alexa Devices todo entities.""" import asyncio from dataclasses import replace from typing import Any from unittest.mock import AsyncMock, patch from aioamazondevices import CannotAuthenticate, CannotConnect from aioamazondevices.exceptions import CannotRetrieveData from aioamazondevices.structures import ( AmazonListEvent, AmazonListEventType, AmazonListInfo, AmazonListItem, AmazonListItemStatus, AmazonListType, ) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.components.todo import ( DOMAIN as TODO_DOMAIN, TodoItemStatus, TodoServices, ) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util import slugify from . import setup_integration from .const import TEST_USERNAME from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform LIST_ENTITY_ID_PREFIX = f"{TODO_DOMAIN.lower()}.{slugify(TEST_USERNAME)}_" MOCK_SHOPPING_LIST_ENTITY_ID = f"{LIST_ENTITY_ID_PREFIX}shopping_list" MOCK_TODO_LIST_ENTITY_ID = f"{LIST_ENTITY_ID_PREFIX}to_do_list" MOCK_CUSTOM_LIST_ENTITY_ID = f"{LIST_ENTITY_ID_PREFIX}concerts" MOCK_SHOPPING_LIST = AmazonListInfo( id="shopping_list_id", name=None, list_type=AmazonListType.SHOP ) MOCK_TODO_LIST = AmazonListInfo( id="todo_list_id", name=None, list_type=AmazonListType.TODO ) MOCK_CUSTOM_LIST = AmazonListInfo( id="custom_list_id", name="Concerts", list_type=AmazonListType.CUSTOM ) @pytest.fixture def mock_todo_lists(): """Mock todo lists.""" return [ MOCK_SHOPPING_LIST, MOCK_TODO_LIST, MOCK_CUSTOM_LIST, ] @pytest.fixture def mock_todo_items(): """Mock todo items.""" return { "shopping_list_id": { "item_1": AmazonListItem( id="item_1", name="Bubble tea", status=AmazonListItemStatus.ACTIVE, version=1, ), }, "todo_list_id": { "item_2": AmazonListItem( id="item_2", name="Task 1", status=AmazonListItemStatus.ACTIVE, version=1, ), "item_3": AmazonListItem( id="item_3", name="Task 2", status=AmazonListItemStatus.COMPLETE, version=2, ), }, "custom_list_id": { "item_4": AmazonListItem( id="item_4", name="TWICE", status=AmazonListItemStatus.ACTIVE, version=1, ), "item_5": AmazonListItem( id="item_5", name="BTS", status=AmazonListItemStatus.COMPLETE, version=2, ), }, } async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, mock_todo_lists: list[AmazonListInfo], mock_todo_items: dict[str, Any], ) -> None: """Test all entities.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.TODO]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_add_todo_item( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], ) -> None: """Test adding a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists list_items: dict[str, AmazonListItem] = {} mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(list_items) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID assert hass.states.get(entity_id).state == "0" # Amazon has the item from the moment the call returns mock_amazon_devices_client.add_todo_list_item = AsyncMock( side_effect=lambda list_id, name: list_items.update( { "item_6": AmazonListItem( id="item_6", name=name, status=AmazonListItemStatus.ACTIVE, version=1, ) } ) ) await hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, {ATTR_ENTITY_ID: entity_id, "item": "New Task"}, blocking=True, ) mock_amazon_devices_client.add_todo_list_item.assert_called_once_with( "todo_list_id", "New Task" ) assert hass.states.get(entity_id).state == "1" async def test_concurrent_writes_keep_the_newest_answer( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], ) -> None: """Test a slow read of an older list does not overwrite a newer one.""" mock_amazon_devices_client.todo_lists = mock_todo_lists list_items: dict[str, AmazonListItem] = {} mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(list_items) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID def add_item(list_id: str, name: str) -> None: list_items[name] = AmazonListItem( id=name, name=name, status=AmazonListItemStatus.ACTIVE, version=1 ) mock_amazon_devices_client.add_todo_list_item = AsyncMock(side_effect=add_item) # Hold the first read until both items have been written released = asyncio.Event() reads = 0 async def read_items(list_id: str) -> dict[str, AmazonListItem]: nonlocal reads reads += 1 items = dict(list_items) if reads == 1: await released.wait() return items mock_amazon_devices_client.get_todo_list_items = AsyncMock(side_effect=read_items) writes = asyncio.gather( *[ hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, {ATTR_ENTITY_ID: entity_id, "item": item}, blocking=True, ) for item in ("First task", "Second task") ] ) await asyncio.sleep(0) released.set() await writes assert hass.states.get(entity_id).state == "2" async def test_pushed_event_survives_a_refresh( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], ) -> None: """Test an event arriving during a read back is not lost by that read.""" mock_amazon_devices_client.todo_lists = mock_todo_lists list_items: dict[str, AmazonListItem] = {} mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(list_items) ) await setup_integration(hass, mock_config_entry) coordinator = mock_config_entry.runtime_data entity_id = MOCK_TODO_LIST_ENTITY_ID def add_item(list_id: str, name: str) -> None: list_items[name] = AmazonListItem( id=name, name=name, status=AmazonListItemStatus.ACTIVE, version=1 ) mock_amazon_devices_client.add_todo_list_item = AsyncMock(side_effect=add_item) # Hold the read back until the event has been handled released = asyncio.Event() async def read_items(list_id: str) -> dict[str, AmazonListItem]: items = dict(list_items) await released.wait() return items mock_amazon_devices_client.get_todo_list_items = AsyncMock(side_effect=read_items) write = asyncio.create_task( hass.services.async_call( TODO_DOMAIN, TodoServices.ADD_ITEM, {ATTR_ENTITY_ID: entity_id, "item": "Written task"}, blocking=True, ) ) await asyncio.sleep(0) # Alexa reports an item of its own while the read back is in flight pushed = asyncio.create_task( coordinator.todo_event_handler( AmazonListEvent( list_id="todo_list_id", item_id="item_6", type=AmazonListEventType.CREATED, items=AmazonListItem( id="item_6", name="Spoken task", status=AmazonListItemStatus.ACTIVE, version=1, ), ) ) ) await asyncio.sleep(0) released.set() await write await pushed assert hass.states.get(entity_id).state == "2" async def test_delete_todo_item( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], mock_todo_items: dict[str, Any], ) -> None: """Test deleting a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID assert hass.states.get(entity_id).state == "1" # Amazon has dropped the item from the moment the call returns mock_amazon_devices_client.delete_todo_list_item = AsyncMock( side_effect=lambda list_id, item_id, version: mock_todo_items[list_id].pop( item_id ) ) # Delete item_2 await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_ITEM, {ATTR_ENTITY_ID: entity_id, "item": ["item_2"]}, blocking=True, ) mock_amazon_devices_client.delete_todo_list_item.assert_called_once_with( "todo_list_id", "item_2", 1 ) assert hass.states.get(entity_id).state == "0" async def test_delete_todo_items_partial_failure( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], mock_todo_items: dict[str, Any], ) -> None: """Test a delete that went through is not left in the cache by a later failure.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID assert hass.states.get(entity_id).state == "1" # Amazon drops item_2 and then stops answering def delete_item(list_id: str, item_id: str, version: int) -> None: if item_id == "item_3": raise CannotConnect mock_todo_items[list_id].pop(item_id) mock_amazon_devices_client.delete_todo_list_item = AsyncMock( side_effect=delete_item ) with pytest.raises(HomeAssistantError): await hass.services.async_call( TODO_DOMAIN, TodoServices.REMOVE_ITEM, {ATTR_ENTITY_ID: entity_id, "item": ["item_2", "item_3"]}, blocking=True, ) # Reading the list back worked, so the entity stays usable assert hass.states.get(entity_id).state == "0" async def test_update_todo_item( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], mock_todo_items: dict[str, Any], ) -> None: """Test updating a todo item.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID # Update item_2 (ACTIVE -> COMPLETE) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, { ATTR_ENTITY_ID: entity_id, "item": "item_2", "status": TodoItemStatus.COMPLETED, }, blocking=True, ) mock_amazon_devices_client.set_todo_list_item_checked_status.assert_called_once_with( "todo_list_id", "item_2", True, 1 ) mock_amazon_devices_client.rename_todo_list_item.assert_not_called() # Rename item_2 mock_amazon_devices_client.set_todo_list_item_checked_status.reset_mock() await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, { ATTR_ENTITY_ID: entity_id, "item": "item_2", "rename": "Renamed Task", }, blocking=True, ) mock_amazon_devices_client.rename_todo_list_item.assert_called_once_with( "todo_list_id", "item_2", "Renamed Task", 1 ) mock_amazon_devices_client.set_todo_list_item_checked_status.assert_not_called() # Both status and rename changed mock_amazon_devices_client.set_todo_list_item_checked_status.reset_mock() mock_amazon_devices_client.rename_todo_list_item.reset_mock() await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, { ATTR_ENTITY_ID: entity_id, "item": "item_2", "rename": "Both Changed", "status": TodoItemStatus.COMPLETED, }, blocking=True, ) mock_amazon_devices_client.set_todo_list_item_checked_status.assert_called_once_with( "todo_list_id", "item_2", True, 1 ) mock_amazon_devices_client.rename_todo_list_item.assert_called_once_with( "todo_list_id", "item_2", "Both Changed", 2 ) # Neither status nor name changed -> no API calls mock_amazon_devices_client.set_todo_list_item_checked_status.reset_mock() mock_amazon_devices_client.rename_todo_list_item.reset_mock() await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, { ATTR_ENTITY_ID: entity_id, "item": "item_2", "rename": "Task 1", "status": TodoItemStatus.NEEDS_ACTION, }, blocking=True, ) mock_amazon_devices_client.set_todo_list_item_checked_status.assert_not_called() mock_amazon_devices_client.rename_todo_list_item.assert_not_called() async def test_update_todo_item_refreshes_state( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], mock_todo_items: dict[str, Any], ) -> None: """Test the entity reflects an updated item once the call returns.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock( side_effect=lambda list_id: dict(mock_todo_items.get(list_id, {})) ) await setup_integration(hass, mock_config_entry) entity_id = MOCK_TODO_LIST_ENTITY_ID assert hass.states.get(entity_id).state == "1" # Amazon has the item checked from the moment the call returns def check_item(list_id: str, item_id: str, checked: bool, version: int) -> None: mock_todo_items[list_id][item_id] = replace( mock_todo_items[list_id][item_id], status=AmazonListItemStatus.COMPLETE ) mock_amazon_devices_client.set_todo_list_item_checked_status = AsyncMock( side_effect=check_item ) await hass.services.async_call( TODO_DOMAIN, TodoServices.UPDATE_ITEM, { ATTR_ENTITY_ID: entity_id, "item": "item_2", "status": TodoItemStatus.COMPLETED, }, blocking=True, ) assert hass.states.get(entity_id).state == "0" @pytest.mark.parametrize( ("initial_lists", "updated_lists"), [ ([MOCK_TODO_LIST], [MOCK_TODO_LIST, MOCK_CUSTOM_LIST]), # Add a list ([MOCK_TODO_LIST, MOCK_CUSTOM_LIST], [MOCK_TODO_LIST]), # Remove a list ( [MOCK_TODO_LIST, MOCK_SHOPPING_LIST, MOCK_CUSTOM_LIST], [], ), # Remove all lists ], ) async def test_dynamic_entities( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, initial_lists: list[AmazonListInfo], updated_lists: list[AmazonListInfo], ) -> None: """Test entities are dynamically created and deleted.""" def get_entity_id(alexa_list: AmazonListInfo) -> str: if alexa_list.list_type == AmazonListType.SHOP: return MOCK_SHOPPING_LIST_ENTITY_ID if alexa_list.list_type == AmazonListType.TODO: return MOCK_TODO_LIST_ENTITY_ID return MOCK_CUSTOM_LIST_ENTITY_ID # Start with the initial set of lists from the Amazon client. mock_amazon_devices_client.todo_lists = list(initial_lists) mock_amazon_devices_client.get_todo_list_items = AsyncMock(return_value={}) await setup_integration(hass, mock_config_entry) initial_entity_ids = [get_entity_id(alexa_list) for alexa_list in initial_lists] updated_entity_ids = [get_entity_id(alexa_list) for alexa_list in updated_lists] # Confirm the initially expected entities exist. for entity_id in initial_entity_ids: assert hass.states.get(entity_id) is not None # Update the Amazon client to return the new list set. mock_amazon_devices_client.todo_lists = list(updated_lists) freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() # Confirm the entities in the updated set exist. for entity_id in updated_entity_ids: assert hass.states.get(entity_id) is not None # Confirm removed entities are no longer present. for entity_id in set(initial_entity_ids) - set(updated_entity_ids): assert hass.states.get(entity_id) is None async def test_dynamic_add_list_and_add_item( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, ) -> None: """Test adding a new list and then adding an item to it.""" mock_amazon_devices_client.todo_lists = [] mock_amazon_devices_client.get_todo_list_items = AsyncMock(return_value={}) await setup_integration(hass, mock_config_entry) assert hass.states.get(MOCK_TODO_LIST_ENTITY_ID) is None mock_amazon_devices_client.todo_lists = [MOCK_TODO_LIST] freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() # Send CREATED event for new item in the newly created list (imitate Amazon server) new_item_id = "item_1" new_item = AmazonListItem( id=new_item_id, name="New Task", status=AmazonListItemStatus.ACTIVE, version=1, ) created_event = AmazonListEvent( list_id=MOCK_TODO_LIST.id, item_id=new_item_id, type=AmazonListEventType.CREATED, items=new_item, ) coordinator = mock_config_entry.runtime_data await coordinator.todo_event_handler(created_event) assert hass.states.get(MOCK_TODO_LIST_ENTITY_ID) is not None async def test_todo_event_handler( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, mock_todo_lists: list[AmazonListInfo], ) -> None: """Test todo event handler.""" mock_amazon_devices_client.todo_lists = mock_todo_lists mock_amazon_devices_client.get_todo_list_items = AsyncMock(return_value={}) await setup_integration(hass, mock_config_entry) coordinator = mock_config_entry.runtime_data # Seed _todo_list_items list_id = "todo_list_id" item_id = "item_1" item = AmazonListItem( id=item_id, name="Original Task", status=AmazonListItemStatus.ACTIVE, version=1, ) coordinator.todo_list_items[list_id] = {item_id: item} # Test CREATED new_item_id = "item_2" new_item = AmazonListItem( id=new_item_id, name="New Task", status=AmazonListItemStatus.ACTIVE, version=1, ) created_event = AmazonListEvent( list_id=list_id, item_id=new_item_id, type=AmazonListEventType.CREATED, items=new_item, ) await coordinator.todo_event_handler(created_event) assert coordinator.todo_list_items[list_id][new_item_id] == new_item # Test UPDATED updated_item = AmazonListItem( id=item_id, name="Updated Task", status=AmazonListItemStatus.COMPLETE, version=2, ) updated_event = AmazonListEvent( list_id=list_id, item_id=item_id, type=AmazonListEventType.UPDATED, items=updated_item, ) await coordinator.todo_event_handler(updated_event) assert coordinator.todo_list_items[list_id][item_id] == updated_item # Test DELETED deleted_event = AmazonListEvent( list_id=list_id, item_id=item_id, type=AmazonListEventType.DELETED, items=None, ) await coordinator.todo_event_handler(deleted_event) assert item_id not in coordinator.todo_list_items[list_id] @pytest.mark.parametrize( ("side_effect", "expected_state"), [ pytest.param( CannotAuthenticate, ConfigEntryState.SETUP_ERROR, id="cannot_authenticate", ), pytest.param( CannotConnect, ConfigEntryState.LOADED, id="cannot_connect", ), pytest.param( CannotRetrieveData, ConfigEntryState.LOADED, id="cannot_retrieve_data", ), ], ) async def test_sync_todo_list_items_error( hass: HomeAssistant, mock_amazon_devices_client: AsyncMock, mock_config_entry: MockConfigEntry, side_effect: type[Exception], expected_state: ConfigEntryState, ) -> None: """Test syncing todo list items handles errors without blocking setup.""" mock_amazon_devices_client.get_todo_list_items.side_effect = side_effect mock_amazon_devices_client.todo_lists = [ AmazonListInfo(id="shopping_list_id", name=None, list_type=AmazonListType.SHOP) ] mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is expected_state