From 6e5dfa0e9b330a9952792562462b0e637bc1f528 Mon Sep 17 00:00:00 2001 From: On Freund Date: Sun, 26 Nov 2023 18:38:47 +0200 Subject: [PATCH] Add OurGroceries integration (#103387) * Add OurGroceries integration * Handle review comments * Fix coordinator test * Additional review comments * Address code review comments * Remove devices --- CODEOWNERS | 2 + .../components/ourgroceries/__init__.py | 50 ++++ .../components/ourgroceries/config_flow.py | 57 ++++ .../components/ourgroceries/const.py | 3 + .../components/ourgroceries/coordinator.py | 41 +++ .../components/ourgroceries/manifest.json | 9 + .../components/ourgroceries/strings.json | 20 ++ homeassistant/components/ourgroceries/todo.py | 118 +++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ourgroceries/__init__.py | 6 + tests/components/ourgroceries/conftest.py | 68 +++++ .../ourgroceries/test_config_flow.py | 96 +++++++ tests/components/ourgroceries/test_init.py | 55 ++++ tests/components/ourgroceries/test_todo.py | 243 ++++++++++++++++++ 17 files changed, 781 insertions(+) create mode 100644 homeassistant/components/ourgroceries/__init__.py create mode 100644 homeassistant/components/ourgroceries/config_flow.py create mode 100644 homeassistant/components/ourgroceries/const.py create mode 100644 homeassistant/components/ourgroceries/coordinator.py create mode 100644 homeassistant/components/ourgroceries/manifest.json create mode 100644 homeassistant/components/ourgroceries/strings.json create mode 100644 homeassistant/components/ourgroceries/todo.py create mode 100644 tests/components/ourgroceries/__init__.py create mode 100644 tests/components/ourgroceries/conftest.py create mode 100644 tests/components/ourgroceries/test_config_flow.py create mode 100644 tests/components/ourgroceries/test_init.py create mode 100644 tests/components/ourgroceries/test_todo.py diff --git a/CODEOWNERS b/CODEOWNERS index d7c8eca064c..45f5669aebb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -930,6 +930,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oru/ @bvlaicu /homeassistant/components/otbr/ @home-assistant/core /tests/components/otbr/ @home-assistant/core +/homeassistant/components/ourgroceries/ @OnFreund +/tests/components/ourgroceries/ @OnFreund /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /homeassistant/components/ovo_energy/ @timmo001 diff --git a/homeassistant/components/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..d645b8617c2 --- /dev/null +++ b/homeassistant/components/ourgroceries/__init__.py @@ -0,0 +1,50 @@ +"""The OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up OurGroceries from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + data = entry.data + og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) + lists = [] + try: + await og.login() + lists = (await og.get_my_lists())["shoppingLists"] + except (AsyncIOTimeoutError, ClientError) as error: + raise ConfigEntryNotReady from error + except InvalidLoginException: + return False + + coordinator = OurGroceriesDataUpdateCoordinator(hass, og, lists) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ourgroceries/config_flow.py b/homeassistant/components/ourgroceries/config_flow.py new file mode 100644 index 00000000000..a982325fceb --- /dev/null +++ b/homeassistant/components/ourgroceries/config_flow.py @@ -0,0 +1,57 @@ +"""Config flow for OurGroceries integration.""" +from __future__ import annotations + +from asyncio import TimeoutError as AsyncIOTimeoutError +import logging +from typing import Any + +from aiohttp import ClientError +from ourgroceries import OurGroceries +from ourgroceries.exceptions import InvalidLoginException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for OurGroceries.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + og = OurGroceries(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) + try: + await og.login() + except (AsyncIOTimeoutError, ClientError): + errors["base"] = "cannot_connect" + except InvalidLoginException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_USERNAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/ourgroceries/const.py b/homeassistant/components/ourgroceries/const.py new file mode 100644 index 00000000000..ba0ff789522 --- /dev/null +++ b/homeassistant/components/ourgroceries/const.py @@ -0,0 +1,3 @@ +"""Constants for the OurGroceries integration.""" + +DOMAIN = "ourgroceries" diff --git a/homeassistant/components/ourgroceries/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py new file mode 100644 index 00000000000..a4b594c7e86 --- /dev/null +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -0,0 +1,41 @@ +"""The OurGroceries coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from ourgroceries import OurGroceries + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +SCAN_INTERVAL = 60 + +_LOGGER = logging.getLogger(__name__) + + +class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Class to manage fetching OurGroceries data.""" + + def __init__( + self, hass: HomeAssistant, og: OurGroceries, lists: list[dict] + ) -> None: + """Initialize global OurGroceries data updater.""" + self.og = og + self.lists = lists + interval = timedelta(seconds=SCAN_INTERVAL) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + ) + + async def _async_update_data(self) -> dict[str, dict]: + """Fetch data from OurGroceries.""" + return { + sl["id"]: (await self.og.get_list_items(list_id=sl["id"])) + for sl in self.lists + } diff --git a/homeassistant/components/ourgroceries/manifest.json b/homeassistant/components/ourgroceries/manifest.json new file mode 100644 index 00000000000..ec5a5039b39 --- /dev/null +++ b/homeassistant/components/ourgroceries/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ourgroceries", + "name": "OurGroceries", + "codeowners": ["@OnFreund"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ourgroceries", + "iot_class": "cloud_polling", + "requirements": ["ourgroceries==1.5.4"] +} diff --git a/homeassistant/components/ourgroceries/strings.json b/homeassistant/components/ourgroceries/strings.json new file mode 100644 index 00000000000..96dc8b371d1 --- /dev/null +++ b/homeassistant/components/ourgroceries/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py new file mode 100644 index 00000000000..98029b09ba8 --- /dev/null +++ b/homeassistant/components/ourgroceries/todo.py @@ -0,0 +1,118 @@ +"""A todo platform for OurGroceries.""" + +import asyncio + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import OurGroceriesDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the OurGroceries todo platform config entry.""" + coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"]) + for sl in coordinator.lists + ) + + +class OurGroceriesTodoListEntity( + CoordinatorEntity[OurGroceriesDataUpdateCoordinator], TodoListEntity +): + """An OurGroceries TodoListEntity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: OurGroceriesDataUpdateCoordinator, + list_id: str, + list_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._list_id = list_id + self._attr_unique_id = list_id + self._attr_name = list_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + + def _completion_status(item): + if item.get("crossedOffAt", False): + return TodoItemStatus.COMPLETED + return TodoItemStatus.NEEDS_ACTION + + self._attr_todo_items = [ + TodoItem( + summary=item["name"], + uid=item["id"], + status=_completion_status(item), + ) + for item in self.coordinator.data[self._list_id]["list"]["items"] + ] + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.og.add_item_to_list( + self._list_id, item.summary, auto_category=True + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + if item.summary: + api_items = self.coordinator.data[self._list_id]["list"]["items"] + category = next( + api_item["categoryId"] + for api_item in api_items + if api_item["id"] == item.uid + ) + await self.coordinator.og.change_item_on_list( + self._list_id, item.uid, category, item.summary + ) + if item.status is not None: + cross_off = item.status == TodoItemStatus.COMPLETED + await self.coordinator.og.toggle_item_crossed_off( + self._list_id, item.uid, cross_off=cross_off + ) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[ + self.coordinator.og.remove_item_from_list(self._list_id, uid) + for uid in uids + ] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3aa738731b0..fbd0b40551b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -347,6 +347,7 @@ FLOWS = { "opower", "oralb", "otbr", + "ourgroceries", "overkiz", "ovo_energy", "owntracks", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 00ec549fd6d..bfd7a869089 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4152,6 +4152,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "ourgroceries": { + "name": "OurGroceries", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "overkiz": { "name": "Overkiz", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index d1721786f01..4a59ea44c2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1425,6 +1425,9 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5d4d29c74fa..37db0e05351 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1095,6 +1095,9 @@ opower==0.0.39 # homeassistant.components.oralb oralb-ble==0.17.6 +# homeassistant.components.ourgroceries +ourgroceries==1.5.4 + # homeassistant.components.ovo_energy ovoenergy==1.2.0 diff --git a/tests/components/ourgroceries/__init__.py b/tests/components/ourgroceries/__init__.py new file mode 100644 index 00000000000..67fcb439908 --- /dev/null +++ b/tests/components/ourgroceries/__init__.py @@ -0,0 +1,6 @@ +"""Tests for the OurGroceries integration.""" + + +def items_to_shopping_list(items: list) -> dict[dict[list]]: + """Convert a list of items into a shopping list.""" + return {"list": {"items": items}} diff --git a/tests/components/ourgroceries/conftest.py b/tests/components/ourgroceries/conftest.py new file mode 100644 index 00000000000..7f113da2633 --- /dev/null +++ b/tests/components/ourgroceries/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the OurGroceries tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.ourgroceries import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import items_to_shopping_list + +from tests.common import MockConfigEntry + +USERNAME = "test-username" +PASSWORD = "test-password" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ourgroceries.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="ourgroceries_config_entry") +def mock_ourgroceries_config_entry() -> MockConfigEntry: + """Mock ourgroceries configuration.""" + return MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + ) + + +@pytest.fixture(name="items") +def mock_items() -> dict: + """Mock a collection of shopping list items.""" + return [] + + +@pytest.fixture(name="ourgroceries") +def mock_ourgroceries(items: list[dict]) -> AsyncMock: + """Mock the OurGroceries api.""" + og = AsyncMock() + og.login.return_value = True + og.get_my_lists.return_value = { + "shoppingLists": [{"id": "test_list", "name": "Test List"}] + } + og.get_list_items.return_value = items_to_shopping_list(items) + return og + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + ourgroceries: AsyncMock, + ourgroceries_config_entry: MockConfigEntry, +) -> None: + """Mock setup of the ourgroceries integration.""" + ourgroceries_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.ourgroceries.OurGroceries", return_value=ourgroceries + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + yield diff --git a/tests/components/ourgroceries/test_config_flow.py b/tests/components/ourgroceries/test_config_flow.py new file mode 100644 index 00000000000..f9d274125c1 --- /dev/null +++ b/tests/components/ourgroceries/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the OurGroceries config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.ourgroceries.config_flow import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (InvalidLoginException, "invalid_auth"), + (ClientError, "cannot_connect"), + (AsyncIOTimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_error( + hass: HomeAssistant, exception: Exception, error: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle form errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + side_effect=exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + with patch( + "homeassistant.components.ourgroceries.config_flow.OurGroceries.login", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "test-username" + assert result3["data"] == { + "username": "test-username", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/ourgroceries/test_init.py b/tests/components/ourgroceries/test_init.py new file mode 100644 index 00000000000..ef96c5e811c --- /dev/null +++ b/tests/components/ourgroceries/test_init.py @@ -0,0 +1,55 @@ +"""Unit tests for the OurGroceries integration.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.ourgroceries import ( + AsyncIOTimeoutError, + ClientError, + InvalidLoginException, +) +from homeassistant.components.ourgroceries.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, + setup_integration: None, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert ourgroceries_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(ourgroceries_config_entry.entry_id) + assert ourgroceries_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.fixture +def login_with_error(exception, ourgroceries: AsyncMock): + """Fixture to simulate error on login.""" + ourgroceries.login.side_effect = (exception,) + + +@pytest.mark.parametrize( + ("exception", "status"), + [ + (InvalidLoginException, ConfigEntryState.SETUP_ERROR), + (ClientError, ConfigEntryState.SETUP_RETRY), + (AsyncIOTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_init_failure( + hass: HomeAssistant, + login_with_error, + setup_integration: None, + status: ConfigEntryState, + ourgroceries_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert ourgroceries_config_entry.state == status diff --git a/tests/components/ourgroceries/test_todo.py b/tests/components/ourgroceries/test_todo.py new file mode 100644 index 00000000000..65bbff0e601 --- /dev/null +++ b/tests/components/ourgroceries/test_todo.py @@ -0,0 +1,243 @@ +"""Unit tests for the OurGroceries todo platform.""" +from asyncio import TimeoutError as AsyncIOTimeoutError +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.ourgroceries.coordinator import SCAN_INTERVAL +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from . import items_to_shopping_list + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + ("items", "expected_state"), + [ + ([], "0"), + ([{"id": "12345", "name": "Soda"}], "1"), + ([{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}], "0"), + ( + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ], + "2", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a shopping list entity state.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == expected_state + + +async def test_add_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for adding an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + ourgroceries.add_item_to_list = AsyncMock() + # Fake API response when state is refreshed after create + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "add_item", + {"item": "Soda"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + + args = ourgroceries.add_item_to_list.call_args + assert args + assert args.args == ("test_list", "Soda") + assert args.kwargs.get("auto_category") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("items"), [[{"id": "12345", "name": "Soda"}]]) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating the completion status of an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.toggle_item_crossed_off = AsyncMock() + + # Fake API response when state is refreshed after crossing off + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda", "crossedOffAt": 1699107501}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "completed"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is True + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Soda"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "status": "needs_action"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.toggle_item_crossed_off.called + args = ourgroceries.toggle_item_crossed_off.call_args + assert args + assert args.args == ("test_list", "12345") + assert args.kwargs.get("cross_off") is False + + # Verify state is refreshed + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("items"), [[{"id": "12345", "name": "Soda", "categoryId": "test_category"}]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for updating an item summary.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "1" + + ourgroceries.change_item_on_list = AsyncMock() + + # Fake API response when state is refreshed update + ourgroceries.get_list_items.return_value = items_to_shopping_list( + [{"id": "12345", "name": "Milk"}] + ) + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"item": "12345", "rename": "Milk"}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.change_item_on_list + args = ourgroceries.change_item_on_list.call_args + assert args.args == ("test_list", "12345", "test_category", "Milk") + + +@pytest.mark.parametrize( + ("items"), + [ + [ + {"id": "12345", "name": "Soda"}, + {"id": "54321", "name": "Milk"}, + ] + ], +) +async def test_remove_todo_item( + hass: HomeAssistant, + setup_integration: None, + ourgroceries: AsyncMock, +) -> None: + """Test for removing an item.""" + + state = hass.states.get("todo.test_list") + assert state + assert state.state == "2" + + ourgroceries.remove_item_from_list = AsyncMock() + # Fake API response when state is refreshed after remove + ourgroceries.get_list_items.return_value = items_to_shopping_list([]) + + await hass.services.async_call( + TODO_DOMAIN, + "remove_item", + {"item": ["12345", "54321"]}, + target={"entity_id": "todo.test_list"}, + blocking=True, + ) + assert ourgroceries.remove_item_from_list.call_count == 2 + args = ourgroceries.remove_item_from_list.call_args_list + assert args[0].args == ("test_list", "12345") + assert args[1].args == ("test_list", "54321") + + await async_update_entity(hass, "todo.test_list") + state = hass.states.get("todo.test_list") + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("exception"), + [ + (ClientError), + (AsyncIOTimeoutError), + ], +) +async def test_coordinator_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_integration: None, + ourgroceries: AsyncMock, + exception: Exception, +) -> None: + """Test error on coordinator update.""" + state = hass.states.get("todo.test_list") + assert state.state == "0" + + ourgroceries.get_list_items.side_effect = exception + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("todo.test_list") + assert state.state == STATE_UNAVAILABLE