Add OurGroceries integration (#103387)
* Add OurGroceries integration * Handle review comments * Fix coordinator test * Additional review comments * Address code review comments * Remove devicespull/104557/head
parent
8a1f7b6802
commit
6e5dfa0e9b
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
)
|
|
@ -0,0 +1,3 @@
|
|||
"""Constants for the OurGroceries integration."""
|
||||
|
||||
DOMAIN = "ourgroceries"
|
|
@ -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
|
||||
}
|
|
@ -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"]
|
||||
}
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
|
@ -347,6 +347,7 @@ FLOWS = {
|
|||
"opower",
|
||||
"oralb",
|
||||
"otbr",
|
||||
"ourgroceries",
|
||||
"overkiz",
|
||||
"ovo_energy",
|
||||
"owntracks",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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}}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue