mirror of
https://github.com/home-assistant/core.git
synced 2026-08-27 18:14:46 -05:00
Add Remember the Milk todo platform (#180057)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot Autofix powered by AI
parent
38ac696fa4
commit
9feefc0ebe
@@ -1,19 +1,19 @@
|
||||
"""The Remember The Milk integration."""
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from aiortm import AioRTMClient, AioRTMError, Auth, AuthError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_ID,
|
||||
CONF_NAME,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
@@ -24,10 +24,17 @@ from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
|
||||
from .const import CONF_LIST_ID, CONF_SHARED_SECRET, DOMAIN, LOGGER, SUBENTRY_TYPE_LIST
|
||||
from .coordinator import (
|
||||
RememberTheMilkConfigEntry,
|
||||
RememberTheMilkData,
|
||||
RtmTodoCoordinator,
|
||||
)
|
||||
from .entity import RememberTheMilkEntity
|
||||
from .storage import RememberTheMilkConfiguration
|
||||
|
||||
PLATFORMS = [Platform.TODO]
|
||||
|
||||
RTM_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
@@ -52,15 +59,6 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
|
||||
DATA_COMPONENT = "component"
|
||||
DATA_STORAGE = "storage"
|
||||
|
||||
type RememberTheMilkConfigEntry = ConfigEntry[RememberTheMilkData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RememberTheMilkData:
|
||||
"""Runtime data for a Remember The Milk config entry."""
|
||||
|
||||
entity_id: str
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Remember the milk component."""
|
||||
@@ -174,7 +172,14 @@ async def async_setup_entry(
|
||||
token_valid=token_valid,
|
||||
)
|
||||
await component.async_add_entities([entity])
|
||||
entry.runtime_data = RememberTheMilkData(entity_id=entity.entity_id)
|
||||
|
||||
coordinator = RtmTodoCoordinator(hass, entry, client)
|
||||
|
||||
entry.runtime_data = RememberTheMilkData(
|
||||
entity_id=entity.entity_id,
|
||||
client=client,
|
||||
coordinator=coordinator,
|
||||
)
|
||||
|
||||
# The services are registered here for now because they need the account name.
|
||||
# The services will be deprecated when a todo platform is added.
|
||||
@@ -195,9 +200,45 @@ async def async_setup_entry(
|
||||
if not token_valid:
|
||||
raise ConfigEntryAuthFailed("Invalid token")
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
# Keep the coordinator polling even when there are no todo entities so that
|
||||
# lists created later in RTM are discovered and synced to subentries.
|
||||
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: RememberTheMilkConfigEntry
|
||||
) -> None:
|
||||
"""Delete removed lists on the server and reload when subentries change."""
|
||||
data = entry.runtime_data
|
||||
# Coordinator-driven syncs mutate subentries one at a time and schedule a
|
||||
# single reload themselves; skip here to avoid one reload per mutation and
|
||||
# to avoid deleting server lists from an incomplete mid-sync subentry set.
|
||||
if data.coordinator.syncing_subentries:
|
||||
return
|
||||
current_list_ids = {
|
||||
subentry.data[CONF_LIST_ID]
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == SUBENTRY_TYPE_LIST
|
||||
}
|
||||
removed_list_ids = set(data.coordinator.data or {}) - current_list_ids
|
||||
if removed_list_ids:
|
||||
try:
|
||||
timeline_response = await data.client.rtm.timelines.create()
|
||||
for list_id in removed_list_ids:
|
||||
await data.client.rtm.lists.delete(
|
||||
timeline=timeline_response.timeline,
|
||||
list_id=list_id,
|
||||
)
|
||||
except AioRTMError as err:
|
||||
LOGGER.warning("Failed to delete list on Remember The Milk: %s", err)
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: RememberTheMilkConfigEntry
|
||||
) -> bool:
|
||||
@@ -206,4 +247,4 @@ async def async_unload_entry(
|
||||
DATA_COMPONENT
|
||||
]
|
||||
await component.async_remove_entity(entry.runtime_data.entity_id)
|
||||
return True
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -4,10 +4,18 @@ import asyncio
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, override
|
||||
|
||||
from aiortm import AioRTMError, Auth, AuthError
|
||||
from aiortm import AioRTMClient, AioRTMError, Auth, AuthError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_TOKEN, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
@@ -16,7 +24,8 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
|
||||
from .const import CONF_LIST_ID, CONF_SHARED_SECRET, DOMAIN, LOGGER, SUBENTRY_TYPE_LIST
|
||||
from .coordinator import RememberTheMilkData
|
||||
|
||||
TOKEN_TIMEOUT_SEC = 30
|
||||
|
||||
@@ -37,6 +46,14 @@ class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {SUBENTRY_TYPE_LIST: ListSubentryFlowHandler}
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._auth: Auth | None = None
|
||||
@@ -205,3 +222,85 @@ class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
import_info[CONF_API_KEY],
|
||||
import_info[CONF_SHARED_SECRET],
|
||||
)
|
||||
|
||||
|
||||
class ListSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and reconfiguring RTM lists."""
|
||||
|
||||
@property
|
||||
def _client(self) -> AioRTMClient:
|
||||
"""Return the RTM client from the parent entry."""
|
||||
data: RememberTheMilkData = self._get_entry().runtime_data
|
||||
return data.client
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Create a new RTM list."""
|
||||
if self._get_entry().state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
name: str = user_input[CONF_NAME]
|
||||
try:
|
||||
timeline_response = await self._client.rtm.timelines.create()
|
||||
list_response = await self._client.rtm.lists.add(
|
||||
timeline=timeline_response.timeline,
|
||||
name=name,
|
||||
)
|
||||
except AioRTMError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001 pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
new_list_id = list_response.list.id
|
||||
return self.async_create_entry(
|
||||
title=name,
|
||||
data={CONF_LIST_ID: new_list_id},
|
||||
unique_id=str(new_list_id),
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema({vol.Required(CONF_NAME): TextSelector()}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Rename the RTM list on the server and update the sub-entry title."""
|
||||
if self._get_entry().state is not ConfigEntryState.LOADED:
|
||||
return self.async_abort(reason="entry_not_loaded")
|
||||
subentry = self._get_reconfigure_subentry()
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
name: str = user_input[CONF_NAME]
|
||||
try:
|
||||
timeline_response = await self._client.rtm.timelines.create()
|
||||
await self._client.rtm.lists.set_name(
|
||||
timeline=timeline_response.timeline,
|
||||
list_id=subentry.data[CONF_LIST_ID],
|
||||
name=name,
|
||||
)
|
||||
except AioRTMError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001 pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
subentry,
|
||||
title=name,
|
||||
data=subentry.data,
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_NAME): TextSelector()}),
|
||||
{CONF_NAME: subentry.title},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import logging
|
||||
|
||||
CONF_LIST_ID = "list_id"
|
||||
CONF_SHARED_SECRET = "shared_secret"
|
||||
DOMAIN = "remember_the_milk"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
SUBENTRY_TYPE_LIST = "list"
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""DataUpdateCoordinator for the Remember The Milk integration."""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from types import MappingProxyType
|
||||
from typing import override
|
||||
|
||||
from aiortm import AioRTMClient, AioRTMError, AuthError
|
||||
|
||||
from homeassistant.components.todo import TodoItem, TodoItemStatus
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_LIST_ID, DOMAIN, LOGGER, SUBENTRY_TYPE_LIST
|
||||
|
||||
UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class RtmList:
|
||||
"""An RTM list with its name and current tasks."""
|
||||
|
||||
name: str
|
||||
tasks: dict[str, RtmTask]
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class RtmTask:
|
||||
"""An RTM task with its HA representation and note metadata."""
|
||||
|
||||
uid: str
|
||||
todo_item: TodoItem
|
||||
note_id: int | None
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class RememberTheMilkData:
|
||||
"""Runtime data for a Remember The Milk config entry."""
|
||||
|
||||
entity_id: str
|
||||
client: AioRTMClient
|
||||
coordinator: RtmTodoCoordinator
|
||||
|
||||
|
||||
type RememberTheMilkConfigEntry = ConfigEntry[RememberTheMilkData]
|
||||
|
||||
|
||||
class RtmTodoCoordinator(DataUpdateCoordinator[dict[int, RtmList]]):
|
||||
"""Coordinator for updating task data from RTM."""
|
||||
|
||||
config_entry: RememberTheMilkConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: RememberTheMilkConfigEntry,
|
||||
client: AioRTMClient,
|
||||
) -> None:
|
||||
"""Initialize the RTM coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=UPDATE_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
self.syncing_subentries = False
|
||||
|
||||
@override
|
||||
async def _async_update_data(self) -> dict[int, RtmList]:
|
||||
"""Fetch lists and tasks from the RTM API and sync subentries."""
|
||||
try:
|
||||
lists_response, tasks_response = await asyncio.gather(
|
||||
self.client.rtm.lists.get_list(),
|
||||
self.client.rtm.tasks.get_list(),
|
||||
)
|
||||
except AuthError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except AioRTMError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
) from err
|
||||
|
||||
result: dict[int, RtmList] = {
|
||||
lst.id: RtmList(name=lst.name, tasks={})
|
||||
for lst in lists_response.lists
|
||||
if not (lst.smart or lst.archived or lst.locked or lst.deleted)
|
||||
}
|
||||
for task_list in tasks_response.tasks.task_list:
|
||||
if task_list.id not in result:
|
||||
continue
|
||||
for taskseries in task_list.taskseries:
|
||||
for task in taskseries.task:
|
||||
if task.deleted is not None:
|
||||
continue
|
||||
uid = f"{task_list.id}_{taskseries.id}_{task.id}"
|
||||
status = (
|
||||
TodoItemStatus.COMPLETED
|
||||
if task.completed is not None
|
||||
else TodoItemStatus.NEEDS_ACTION
|
||||
)
|
||||
due: date | datetime | None = None
|
||||
if task.due is not None:
|
||||
due = task.due if task.has_due_time else task.due.date()
|
||||
description: str | None = None
|
||||
note_id: int | None = None
|
||||
if taskseries.notes:
|
||||
first_note = taskseries.notes[0]
|
||||
description = first_note.body or None
|
||||
note_id = first_note.id
|
||||
result[task_list.id].tasks[uid] = RtmTask(
|
||||
uid=uid,
|
||||
todo_item=TodoItem(
|
||||
uid=uid,
|
||||
summary=taskseries.name,
|
||||
status=status,
|
||||
due=due,
|
||||
description=description,
|
||||
),
|
||||
note_id=note_id,
|
||||
)
|
||||
# Schedule after return so self.data is set before the sync runs.
|
||||
# The update listener fired by subentry mutations reads coordinator.data,
|
||||
# and eager task start means it can run mid-callback synchronously.
|
||||
self.config_entry.async_create_task(
|
||||
self.hass, self._async_sync_subentries(result), eager_start=False
|
||||
)
|
||||
return result
|
||||
|
||||
async def _async_sync_subentries(self, lists: dict[int, RtmList]) -> None:
|
||||
"""Add, update, or remove list subentries to match the fetched lists."""
|
||||
entry = self.config_entry
|
||||
existing = {
|
||||
subentry.data[CONF_LIST_ID]: subentry
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_LIST)
|
||||
}
|
||||
self.syncing_subentries = True
|
||||
changed = False
|
||||
try:
|
||||
for list_id, rtm_list in lists.items():
|
||||
subentry = existing.get(list_id)
|
||||
if subentry is None:
|
||||
self.hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType({CONF_LIST_ID: list_id}),
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title=rtm_list.name,
|
||||
unique_id=str(list_id),
|
||||
),
|
||||
)
|
||||
changed = True
|
||||
elif subentry.title != rtm_list.name:
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry, subentry, title=rtm_list.name
|
||||
)
|
||||
changed = True
|
||||
for list_id, subentry in existing.items():
|
||||
if list_id not in lists:
|
||||
self.hass.config_entries.async_remove_subentry(
|
||||
entry, subentry.subentry_id
|
||||
)
|
||||
changed = True
|
||||
finally:
|
||||
self.syncing_subentries = False
|
||||
if changed:
|
||||
self.hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
@@ -5,7 +5,7 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_push",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiortm"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["aiortm==0.20.0"]
|
||||
|
||||
@@ -35,6 +35,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"list": {
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "The Remember The Milk account must be loaded before adding or renaming a list.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "List",
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"initiate_flow": {
|
||||
"user": "Add list"
|
||||
},
|
||||
"step": {
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "The new name for this To-do list. The list will also be renamed in your Remember The Milk account."
|
||||
},
|
||||
"description": "Rename the To-do list."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "A name for this To-do list. A new list with this name will also be created in your Remember The Milk account."
|
||||
},
|
||||
"description": "Enter a name for the new Remember The Milk list."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Error communicating with the Remember The Milk API."
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Invalid authentication for the Remember The Milk API."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please restart Home Assistant to try again, or remove the {domain} configuration from your YAML and set the integration up via the UI.",
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""A todo platform for Remember The Milk."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, cast, override
|
||||
|
||||
from aiortm import AioRTMError, AuthError
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
TodoItemStatus,
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_LIST_ID, DOMAIN, SUBENTRY_TYPE_LIST
|
||||
from .coordinator import RememberTheMilkConfigEntry, RtmTodoCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: RememberTheMilkConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the RTM todo platform."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_LIST):
|
||||
async_add_entities(
|
||||
[RtmTodoListEntity(coordinator, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
def handle_api_errors[**_P](
|
||||
func: Callable[_P, Awaitable[None]],
|
||||
) -> Callable[_P, Coroutine[Any, Any, None]]:
|
||||
"""Catch aiortm errors and re-raise as HomeAssistantError."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(*args, **kwargs)
|
||||
except AuthError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
) from err
|
||||
except AioRTMError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class RtmTodoListEntity(CoordinatorEntity[RtmTodoCoordinator], TodoListEntity):
|
||||
"""A Remember The Milk TodoListEntity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_supported_features = (
|
||||
TodoListEntityFeature.CREATE_TODO_ITEM
|
||||
| TodoListEntityFeature.UPDATE_TODO_ITEM
|
||||
| TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
| TodoListEntityFeature.SET_DUE_DATE_ON_ITEM
|
||||
| TodoListEntityFeature.SET_DUE_DATETIME_ON_ITEM
|
||||
| TodoListEntityFeature.SET_DESCRIPTION_ON_ITEM
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: RtmTodoCoordinator,
|
||||
subentry: ConfigSubentry,
|
||||
) -> None:
|
||||
"""Initialize the RtmTodoListEntity."""
|
||||
super().__init__(coordinator=coordinator)
|
||||
self._list_id: int = subentry.data[CONF_LIST_ID]
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Remember The Milk",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def todo_items(self) -> list[TodoItem]:
|
||||
"""Return the To-do items in the To-do list."""
|
||||
rtm_list = self.coordinator.data.get(self._list_id)
|
||||
return [
|
||||
rtm_task.todo_item
|
||||
for rtm_task in (rtm_list.tasks.values() if rtm_list else [])
|
||||
]
|
||||
|
||||
@handle_api_errors
|
||||
@override
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a To-do item."""
|
||||
client = self.coordinator.client
|
||||
timeline_response = await client.rtm.timelines.create()
|
||||
timeline = timeline_response.timeline
|
||||
if TYPE_CHECKING:
|
||||
assert item.summary is not None
|
||||
result = await client.rtm.tasks.add(
|
||||
timeline=timeline,
|
||||
name=item.summary,
|
||||
list_id=self._list_id,
|
||||
parse=True,
|
||||
)
|
||||
taskseries = result.task_list.taskseries[0]
|
||||
task = taskseries.task[0]
|
||||
if item.due is not None:
|
||||
await client.rtm.tasks.set_due_date(
|
||||
timeline=timeline,
|
||||
list_id=result.task_list.id,
|
||||
taskseries_id=taskseries.id,
|
||||
task_id=task.id,
|
||||
due=(
|
||||
item.due if isinstance(item.due, datetime) else item.due.isoformat()
|
||||
),
|
||||
has_due_time=isinstance(item.due, datetime),
|
||||
)
|
||||
if item.description:
|
||||
await client.rtm.tasks.notes.add(
|
||||
timeline=timeline,
|
||||
list_id=result.task_list.id,
|
||||
taskseries_id=taskseries.id,
|
||||
task_id=task.id,
|
||||
title="",
|
||||
text=item.description,
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@handle_api_errors
|
||||
@override
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update a To-do item."""
|
||||
uid = cast(str, item.uid)
|
||||
list_id, taskseries_id, task_id = _parse_uid(uid)
|
||||
rtm_list = self.coordinator.data.get(self._list_id)
|
||||
existing = rtm_list.tasks.get(uid) if rtm_list else None
|
||||
client = self.coordinator.client
|
||||
timeline_response = await client.rtm.timelines.create()
|
||||
timeline = timeline_response.timeline
|
||||
|
||||
if item.summary is not None:
|
||||
await client.rtm.tasks.set_name(
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
name=item.summary,
|
||||
)
|
||||
|
||||
if item.status is not None:
|
||||
if item.status == TodoItemStatus.COMPLETED:
|
||||
await client.rtm.tasks.complete(
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
else:
|
||||
await client.rtm.tasks.uncomplete( # codespell:ignore uncomplete
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
await client.rtm.tasks.set_due_date(
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
due=(
|
||||
item.due
|
||||
if isinstance(item.due, (datetime, type(None)))
|
||||
else item.due.isoformat()
|
||||
),
|
||||
has_due_time=(
|
||||
isinstance(item.due, datetime) if item.due is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
note_id = existing.note_id if existing else None
|
||||
new_description = item.description or None
|
||||
if new_description and note_id is not None:
|
||||
await client.rtm.tasks.notes.edit(
|
||||
timeline=timeline,
|
||||
note_id=note_id,
|
||||
title="",
|
||||
text=new_description,
|
||||
)
|
||||
elif new_description:
|
||||
await client.rtm.tasks.notes.add(
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
title="",
|
||||
text=new_description,
|
||||
)
|
||||
elif (
|
||||
note_id is not None
|
||||
and existing is not None
|
||||
and existing.todo_item.description is not None
|
||||
):
|
||||
await client.rtm.tasks.notes.delete(
|
||||
timeline=timeline,
|
||||
note_id=note_id,
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@handle_api_errors
|
||||
@override
|
||||
async def async_delete_todo_items(self, uids: list[str]) -> None:
|
||||
"""Delete To-do items."""
|
||||
client = self.coordinator.client
|
||||
timeline_response = await client.rtm.timelines.create()
|
||||
timeline = timeline_response.timeline
|
||||
await asyncio.gather(
|
||||
*[
|
||||
client.rtm.tasks.delete(
|
||||
timeline=timeline,
|
||||
list_id=list_id,
|
||||
taskseries_id=taskseries_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
for uid in uids
|
||||
for list_id, taskseries_id, task_id in (_parse_uid(uid),)
|
||||
]
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
|
||||
def _parse_uid(uid: str) -> tuple[int, int, int]:
|
||||
"""Split a task UID into (list_id, taskseries_id, task_id)."""
|
||||
parts = uid.split("_", 2)
|
||||
return int(parts[0]), int(parts[1]), int(parts[2])
|
||||
@@ -6058,7 +6058,7 @@
|
||||
"name": "Remember The Milk",
|
||||
"integration_type": "service",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_push"
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"remote_calendar": {
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Provide common pytest fixtures."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from collections.abc import AsyncGenerator, Callable, Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -20,7 +20,7 @@ def ignore_missing_translations(request: pytest.FixtureRequest) -> list[str]:
|
||||
The services are only registered when the integration is set up, so only
|
||||
ignore them for the test modules that load the integration.
|
||||
"""
|
||||
if request.module.__name__.endswith((".test_entity", ".test_init")):
|
||||
if request.module.__name__.endswith((".test_entity", ".test_init", ".test_todo")):
|
||||
return [
|
||||
f"component.{DOMAIN}.services.{PROFILE}_create_task.",
|
||||
f"component.{DOMAIN}.services.{PROFILE}_complete_task.",
|
||||
@@ -53,19 +53,39 @@ def client_fixture() -> Generator[MagicMock]:
|
||||
timelines = MagicMock()
|
||||
timelines.timeline = 1234
|
||||
client.rtm.timelines.create = AsyncMock(return_value=timelines)
|
||||
response = MagicMock()
|
||||
response.task_list.id = 1
|
||||
response.task_list.taskseries = []
|
||||
task_modified_response = MagicMock()
|
||||
task_modified_response.task_list.id = 1
|
||||
task_modified_response.task_list.taskseries = []
|
||||
task_series = MagicMock()
|
||||
task_series.id = 2
|
||||
task_series.task = []
|
||||
task = MagicMock()
|
||||
task.id = 3
|
||||
task_series.task.append(task)
|
||||
response.task_list.taskseries.append(task_series)
|
||||
client.rtm.tasks.add = AsyncMock(return_value=response)
|
||||
client.rtm.tasks.complete = AsyncMock(return_value=response)
|
||||
client.rtm.tasks.set_name = AsyncMock(return_value=response)
|
||||
task_modified_response.task_list.taskseries.append(task_series)
|
||||
client.rtm.tasks.add = AsyncMock(return_value=task_modified_response)
|
||||
client.rtm.tasks.complete = AsyncMock(return_value=task_modified_response)
|
||||
client.rtm.tasks.uncomplete = AsyncMock( # codespell:ignore uncomplete
|
||||
return_value=task_modified_response
|
||||
)
|
||||
client.rtm.tasks.delete = AsyncMock(return_value=task_modified_response)
|
||||
client.rtm.tasks.set_name = AsyncMock(return_value=task_modified_response)
|
||||
client.rtm.tasks.set_due_date = AsyncMock(return_value=task_modified_response)
|
||||
tasks_response = MagicMock()
|
||||
tasks_response.tasks.task_list = []
|
||||
client.rtm.tasks.get_list = AsyncMock(return_value=tasks_response)
|
||||
note_response = MagicMock()
|
||||
client.rtm.tasks.notes.add = AsyncMock(return_value=note_response)
|
||||
client.rtm.tasks.notes.edit = AsyncMock(return_value=note_response)
|
||||
client.rtm.tasks.notes.delete = AsyncMock(return_value=note_response)
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = []
|
||||
client.rtm.lists.get_list = AsyncMock(return_value=lists_response)
|
||||
list_add_response = MagicMock()
|
||||
list_add_response.list.id = 42
|
||||
client.rtm.lists.add = AsyncMock(return_value=list_add_response)
|
||||
client.rtm.lists.set_name = AsyncMock(return_value=MagicMock())
|
||||
client.rtm.lists.delete = AsyncMock(return_value=MagicMock())
|
||||
|
||||
yield client
|
||||
|
||||
@@ -94,6 +114,47 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_rtm_list_mock() -> Callable[..., MagicMock]:
|
||||
"""Return a factory that creates a single RTM list mock with optional flags."""
|
||||
|
||||
def factory(
|
||||
list_id: int,
|
||||
name: str,
|
||||
*,
|
||||
smart: bool = False,
|
||||
archived: bool = False,
|
||||
locked: bool = False,
|
||||
deleted: bool = False,
|
||||
) -> MagicMock:
|
||||
lst = MagicMock()
|
||||
lst.id = list_id
|
||||
lst.name = name
|
||||
lst.smart = smart
|
||||
lst.archived = archived
|
||||
lst.locked = locked
|
||||
lst.deleted = deleted
|
||||
return lst
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rtm_list_mock(
|
||||
client: MagicMock, make_rtm_list_mock: Callable[..., MagicMock]
|
||||
) -> Callable[[int, str], MagicMock]:
|
||||
"""Return a helper that configures get_list to return a single standard list."""
|
||||
|
||||
def factory(list_id: int, name: str) -> MagicMock:
|
||||
lst = make_rtm_list_mock(list_id, name)
|
||||
response = MagicMock()
|
||||
response.lists = [lst]
|
||||
client.rtm.lists.get_list.return_value = response
|
||||
return lst
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_entry
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'remember_the_milk',
|
||||
'test-subentry-id',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Remember The Milk',
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'My Shopping List',
|
||||
'name_by_user': None,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Test the Remember The Milk config flow."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiortm import AioRTMError, AuthError
|
||||
import pytest
|
||||
@@ -12,7 +12,17 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.remember_the_milk.config_flow import TOKEN_TIMEOUT_SEC
|
||||
from homeassistant.components.remember_the_milk.const import DOMAIN
|
||||
from homeassistant.components.remember_the_milk.const import (
|
||||
CONF_LIST_ID,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_LIST,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigSubentryDataWithId,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -20,7 +30,21 @@ from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
SUBENTRY_ID = "test-subentry-id"
|
||||
LIST_ID = 99
|
||||
NEW_LIST_ID = 42
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ignore_missing_translations(request: pytest.FixtureRequest) -> list[str]:
|
||||
"""Ignore per-account service translations for subentry tests that do real setup."""
|
||||
for marker in request.node.iter_markers("usefixtures"):
|
||||
if "storage" in marker.args:
|
||||
return [
|
||||
f"component.{DOMAIN}.services.{PROFILE}_create_task.",
|
||||
f"component.{DOMAIN}.services.{PROFILE}_complete_task.",
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def get_suggested_value(data_schema: vol.Schema, key: str) -> Any:
|
||||
@@ -31,10 +55,32 @@ def get_suggested_value(data_schema: vol.Schema, key: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_entry_with_subentry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return a loaded mock config entry with one list subentry."""
|
||||
entry = MockConfigEntry(
|
||||
data=CREATE_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
subentries_data=[
|
||||
ConfigSubentryDataWithId(
|
||||
data={CONF_LIST_ID: LIST_ID},
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title="Shopping",
|
||||
unique_id=str(LIST_ID),
|
||||
subentry_id=SUBENTRY_ID,
|
||||
)
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
async def test_successful_flow(
|
||||
hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful flow."""
|
||||
"""Test successful flow creates subentries for existing lists."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@@ -56,6 +102,7 @@ async def test_successful_flow(
|
||||
assert result["data"] == CREATE_ENTRY_DATA
|
||||
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(result["result"].subentries) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -66,11 +113,11 @@ async def test_successful_flow(
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("client")
|
||||
async def test_form_errors(
|
||||
hass: HomeAssistant,
|
||||
client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
exception: Exception,
|
||||
exception: type[Exception],
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test form errors when getting the authentication URL."""
|
||||
@@ -108,6 +155,7 @@ async def test_form_errors(
|
||||
assert result["data"] == CREATE_ENTRY_DATA
|
||||
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(result["result"].subentries) == 0
|
||||
|
||||
|
||||
async def mock_get_token(*args: Any) -> None:
|
||||
@@ -115,6 +163,7 @@ async def mock_get_token(*args: Any) -> None:
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "reason", "timeout"),
|
||||
[
|
||||
@@ -126,8 +175,7 @@ async def mock_get_token(*args: Any) -> None:
|
||||
)
|
||||
async def test_token_abort_reasons(
|
||||
hass: HomeAssistant,
|
||||
client: AsyncMock,
|
||||
side_effect: Exception | Awaitable[None],
|
||||
side_effect: type[Exception] | Awaitable[None],
|
||||
reason: str,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
@@ -160,9 +208,8 @@ async def test_token_abort_reasons(
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
async def test_abort_if_already_configured(
|
||||
hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("client", "config_entry")
|
||||
async def test_abort_if_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test abort if the same username is already configured."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
@@ -184,6 +231,7 @@ async def test_abort_if_already_configured(
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
"source", [config_entries.SOURCE_IMPORT, config_entries.SOURCE_USER]
|
||||
)
|
||||
@@ -200,7 +248,6 @@ async def test_abort_if_already_configured(
|
||||
)
|
||||
async def test_reauth(
|
||||
hass: HomeAssistant,
|
||||
client: AsyncMock,
|
||||
source: str,
|
||||
reauth_unique_id: str,
|
||||
abort_reason: str,
|
||||
@@ -248,9 +295,8 @@ async def test_reauth(
|
||||
assert mock_entry.unique_id == "test-user-id"
|
||||
|
||||
|
||||
async def test_reauth_change_credentials(
|
||||
hass: HomeAssistant, client: AsyncMock
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("client", "mock_setup_entry")
|
||||
async def test_reauth_change_credentials(hass: HomeAssistant) -> None:
|
||||
"""Test reauth flow where the user changes the stored credentials."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN, unique_id=TOKEN_RESPONSE["user"]["id"], data=CREATE_ENTRY_DATA
|
||||
@@ -288,6 +334,7 @@ async def test_reauth_change_credentials(
|
||||
assert mock_entry.unique_id == TOKEN_RESPONSE["user"]["id"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
@@ -298,8 +345,6 @@ async def test_reauth_change_credentials(
|
||||
)
|
||||
async def test_reauth_form_errors(
|
||||
hass: HomeAssistant,
|
||||
client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
exception: type[Exception],
|
||||
error: str,
|
||||
) -> None:
|
||||
@@ -352,6 +397,7 @@ async def test_reauth_form_errors(
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "reason", "timeout"),
|
||||
[
|
||||
@@ -363,7 +409,6 @@ async def test_reauth_form_errors(
|
||||
)
|
||||
async def test_reauth_token_abort(
|
||||
hass: HomeAssistant,
|
||||
client: AsyncMock,
|
||||
side_effect: type[Exception | Awaitable[None]],
|
||||
reason: str,
|
||||
timeout: int,
|
||||
@@ -402,8 +447,10 @@ async def test_reauth_token_abort(
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client")
|
||||
async def test_import_flow(
|
||||
hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test import flow with a valid stored token."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -426,8 +473,10 @@ async def test_import_flow(
|
||||
}
|
||||
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
assert len(result["result"].subentries) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(
|
||||
("token", "side_effect", "reason"),
|
||||
[
|
||||
@@ -462,9 +511,8 @@ async def test_import_flow_abort(
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
async def test_import_flow_username_mismatch(
|
||||
hass: HomeAssistant, client: AsyncMock
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("client")
|
||||
async def test_import_flow_username_mismatch(hass: HomeAssistant) -> None:
|
||||
"""Test import flow aborts when the token username doesn't match the name."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
@@ -480,9 +528,8 @@ async def test_import_flow_username_mismatch(
|
||||
assert result["reason"] == "invalid_auth"
|
||||
|
||||
|
||||
async def test_import_flow_already_configured(
|
||||
hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("client", "config_entry")
|
||||
async def test_import_flow_already_configured(hass: HomeAssistant) -> None:
|
||||
"""Test import flow aborts when the account name is already configured."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
@@ -496,3 +543,230 @@ async def test_import_flow_already_configured(
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_create_new_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test creating a new RTM list creates a subentry."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# After adding the list on the server, reflect it in get_list so the coordinator
|
||||
# sync keeps the newly created subentry rather than removing it on the next refresh.
|
||||
list_add_return = client.rtm.lists.add.return_value
|
||||
|
||||
async def _add_list(*args: object, **kwargs: object) -> MagicMock:
|
||||
rtm_list_mock(NEW_LIST_ID, "Work Tasks")
|
||||
return list_add_return
|
||||
|
||||
client.rtm.lists.add.side_effect = _add_list
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_NAME: "Work Tasks"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Work Tasks"
|
||||
assert result["data"] == {CONF_LIST_ID: NEW_LIST_ID}
|
||||
assert result["unique_id"] == str(NEW_LIST_ID)
|
||||
|
||||
client.rtm.lists.add.assert_called_once_with(
|
||||
timeline=1234,
|
||||
name="Work Tasks",
|
||||
)
|
||||
assert len(config_entry.subentries) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(AioRTMError, "cannot_connect"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_create_error(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
exception: type[Exception],
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test that an API error in the create step shows an error and can recover."""
|
||||
client.rtm.lists.add = AsyncMock(side_effect=exception("server error"))
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], user_input={CONF_NAME: "Failing List"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
# Clear the error and verify the flow recovers to success.
|
||||
list_add_response = MagicMock()
|
||||
list_add_response.list.id = NEW_LIST_ID
|
||||
|
||||
async def _add_list(*args: object, **kwargs: object) -> MagicMock:
|
||||
rtm_list_mock(NEW_LIST_ID, "Failing List")
|
||||
return list_add_response
|
||||
|
||||
client.rtm.lists.add.side_effect = _add_list
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"], user_input={CONF_NAME: "Failing List"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Failing List"
|
||||
assert result["data"] == {CONF_LIST_ID: NEW_LIST_ID}
|
||||
assert result["unique_id"] == str(NEW_LIST_ID)
|
||||
|
||||
|
||||
async def test_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test abort when the parent config entry is not loaded."""
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "entry_not_loaded"
|
||||
|
||||
|
||||
async def test_reconfigure_entry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test abort in reconfigure when the parent config entry is not loaded."""
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry_with_subentry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry.subentry_id},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "entry_not_loaded"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_reconfigure_subentry(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test renaming a subentry via reconfigure."""
|
||||
lst = rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
# Reflect the rename in get_list so the coordinator sync stays idempotent after reload.
|
||||
def _set_name(*args: object, name: str, **kwargs: object) -> MagicMock:
|
||||
lst.name = name
|
||||
return MagicMock()
|
||||
|
||||
client.rtm.lists.set_name.side_effect = _set_name
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
assert subentry.title == "Shopping"
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry_with_subentry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry.subentry_id},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_NAME: "Grocery Shopping"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
assert subentry.title == "Grocery Shopping"
|
||||
assert subentry.data[CONF_LIST_ID] == LIST_ID
|
||||
client.rtm.lists.set_name.assert_called_once_with(
|
||||
timeline=1234, list_id=LIST_ID, name="Grocery Shopping"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(AioRTMError, "cannot_connect"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_reconfigure_error(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
exception: type[Exception],
|
||||
error: str,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that an API error in the reconfigure step shows an error and can recover."""
|
||||
lst = rtm_list_mock(LIST_ID, "Shopping")
|
||||
client.rtm.lists.set_name = AsyncMock(side_effect=exception("server error"))
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(config_entry_with_subentry.entry_id, SUBENTRY_TYPE_LIST),
|
||||
context={"source": SOURCE_RECONFIGURE, "subentry_id": subentry.subentry_id},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_NAME: "Grocery Shopping"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
# Clear the error and verify the flow recovers to success.
|
||||
def _set_name(*args: object, name: str, **kwargs: object) -> MagicMock:
|
||||
lst.name = name
|
||||
return MagicMock()
|
||||
|
||||
client.rtm.lists.set_name.side_effect = _set_name
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_NAME: "Grocery Shopping"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
assert subentry.title == "Grocery Shopping"
|
||||
|
||||
@@ -1,19 +1,35 @@
|
||||
"""Test the Remember The Milk integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from aiortm import AioRTMError, AuthError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.remember_the_milk.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.components.remember_the_milk.const import (
|
||||
CONF_LIST_ID,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_LIST,
|
||||
)
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntryState,
|
||||
ConfigSubentry,
|
||||
ConfigSubentryDataWithId,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import PROFILE
|
||||
from .const import CREATE_ENTRY_DATA, PROFILE
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
LIST_ID = 42
|
||||
NEW_LIST_ID = 100
|
||||
SUBENTRY_ID = "test-subentry-id"
|
||||
|
||||
CONFIG = {
|
||||
"name": "myprofile",
|
||||
@@ -22,10 +38,29 @@ CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
@pytest.fixture
|
||||
def config_entry_with_subentry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return a mock config entry with one list subentry."""
|
||||
entry = MockConfigEntry(
|
||||
data=CREATE_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
subentries_data=[
|
||||
ConfigSubentryDataWithId(
|
||||
data={CONF_LIST_ID: LIST_ID},
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title="Shopping",
|
||||
unique_id=str(LIST_ID),
|
||||
subentry_id=SUBENTRY_ID,
|
||||
)
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_load_unload_config_entry(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test loading and unloading a config entry."""
|
||||
@@ -92,6 +127,36 @@ async def test_import_creates_deprecation_issue(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_state"),
|
||||
[
|
||||
pytest.param(
|
||||
AuthError("Invalid token!"),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
id="auth_error",
|
||||
),
|
||||
pytest.param(
|
||||
AioRTMError("Boom!"),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
id="api_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_update_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test config entry state when the first coordinator refresh fails."""
|
||||
client.rtm.tasks.get_list.side_effect = side_effect
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ignore_missing_translations", [[]])
|
||||
@pytest.mark.usefixtures("client")
|
||||
async def test_import_without_token_creates_issue(
|
||||
@@ -113,3 +178,429 @@ async def test_import_without_token_creates_issue(
|
||||
assert issue_registry.async_get_issue(
|
||||
DOMAIN, "deprecated_yaml_import_issue_invalid_auth"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_remove_subentry_deletes_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that removing a list sub-entry deletes the list on the RTM server."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
|
||||
hass.config_entries.async_remove_subentry(config_entry_with_subentry, SUBENTRY_ID)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.rtm.timelines.create.assert_called_once()
|
||||
client.rtm.lists.delete.assert_called_once_with(
|
||||
timeline=client.rtm.timelines.create.return_value.timeline,
|
||||
list_id=LIST_ID,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_rename_subentry_does_not_delete_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that renaming a sub-entry (no list removed) does not trigger deletion."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
|
||||
subentry = next(iter(config_entry_with_subentry.subentries.values()))
|
||||
hass.config_entries.async_update_subentry(
|
||||
config_entry_with_subentry, subentry, title="Grocery Shopping"
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.rtm.lists.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
pytest.param(AuthError("Invalid token!"), id="auth_error"),
|
||||
pytest.param(AioRTMError("Boom!"), id="api_error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_remove_subentry_delete_list_error(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that a server error when deleting a list is logged and reload still runs."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
|
||||
client.rtm.lists.delete.side_effect = side_effect
|
||||
|
||||
hass.config_entries.async_remove_subentry(config_entry_with_subentry, SUBENTRY_ID)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client.rtm.lists.delete.assert_called_once()
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_creates_subentry_for_new_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that a list on the server creates a subentry and todo entity during first refresh."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 1
|
||||
subentry = next(iter(config_entry.subentries.values()))
|
||||
assert subentry.data[CONF_LIST_ID] == LIST_ID
|
||||
assert subentry.title == "Shopping"
|
||||
assert subentry.unique_id == str(LIST_ID)
|
||||
assert hass.states.get("todo.shopping") is not None
|
||||
client.rtm.lists.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_removes_subentry_when_list_gone(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that a list gone from the server removes the subentry without a server delete."""
|
||||
# rtm.lists.get_list returns empty by default — list 42 has disappeared from the server.
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry_with_subentry.subentries) == 0
|
||||
client.rtm.lists.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_coordinator_updates_subentry_title_on_rename(
|
||||
hass: HomeAssistant,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that a server-side list rename updates the subentry title."""
|
||||
rtm_list_mock(
|
||||
LIST_ID, "Grocery Shopping"
|
||||
) # Different from the subentry title "Shopping"
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
subentry = config_entry_with_subentry.subentries.get(SUBENTRY_ID)
|
||||
assert subentry is not None
|
||||
assert subentry.title == "Grocery Shopping"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_ignores_filtered_lists(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
make_rtm_list_mock: Callable[..., MagicMock],
|
||||
) -> None:
|
||||
"""Test that smart, archived, locked, and deleted lists are not synced as subentries."""
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = [
|
||||
make_rtm_list_mock(1, "Normal"),
|
||||
make_rtm_list_mock(2, "Smart", smart=True),
|
||||
make_rtm_list_mock(3, "Archived", archived=True),
|
||||
make_rtm_list_mock(4, "Locked", locked=True),
|
||||
make_rtm_list_mock(5, "Deleted", deleted=True),
|
||||
]
|
||||
client.rtm.lists.get_list.return_value = lists_response
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 1
|
||||
subentry = next(iter(config_entry.subentries.values()))
|
||||
assert subentry.title == "Normal"
|
||||
assert subentry.data[CONF_LIST_ID] == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_skips_tasks_for_filtered_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
) -> None:
|
||||
"""Test that tasks for a list absent from the lists result (e.g. filtered) are ignored."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
# A task_list whose id is not in the coordinator result (simulates a filtered list).
|
||||
task_list = MagicMock()
|
||||
task_list.id = 999
|
||||
tasks_response = MagicMock()
|
||||
tasks_response.tasks.task_list = [task_list]
|
||||
client.rtm.tasks.get_list.return_value = tasks_response
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_state", "ignore_missing_translations"),
|
||||
[
|
||||
pytest.param(
|
||||
AuthError("Invalid token!"),
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
[
|
||||
f"component.{DOMAIN}.services.{PROFILE}_create_task.",
|
||||
f"component.{DOMAIN}.services.{PROFILE}_complete_task.",
|
||||
],
|
||||
id="auth_error",
|
||||
),
|
||||
pytest.param(
|
||||
AioRTMError("Boom!"),
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
[
|
||||
f"component.{DOMAIN}.services.{PROFILE}_create_task.",
|
||||
f"component.{DOMAIN}.services.{PROFILE}_complete_task.",
|
||||
],
|
||||
id="api_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_lists_fetch_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""Test config entry state when the list fetch in the coordinator fails."""
|
||||
client.rtm.lists.get_list.side_effect = side_effect
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is expected_state
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_does_not_delete_server_removed_list(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that a list removed from the server on a subsequent poll is not deleted.
|
||||
|
||||
The update listener computes lists to delete by comparing the current subentries
|
||||
against the coordinator's last-known server data. If it runs before coordinator.data
|
||||
is updated with the fresh (shorter) list, a server-side removal looks like a
|
||||
user-initiated deletion and the list would be permanently deleted on the server.
|
||||
"""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 1
|
||||
|
||||
# List disappears from the server (e.g. archived) on the next poll.
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = []
|
||||
client.rtm.lists.get_list.return_value = lists_response
|
||||
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert len(config_entry.subentries) == 0
|
||||
client.rtm.lists.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_sync_multiple_new_lists_no_deletion(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
make_rtm_list_mock: Callable[..., MagicMock],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that discovering multiple new lists in one poll doesn't delete any of them.
|
||||
|
||||
Each async_add_subentry call fires the update listener eagerly. Without the
|
||||
syncing_subentries guard, the listener would see an incomplete subentry set
|
||||
mid-sync and wrongly delete the not-yet-added list from the server.
|
||||
"""
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = [make_rtm_list_mock(LIST_ID, "Shopping")]
|
||||
client.rtm.lists.get_list.return_value = lists_response
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 1
|
||||
|
||||
# Two new lists appear simultaneously on the server on the next poll.
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = [
|
||||
make_rtm_list_mock(LIST_ID, "Shopping"),
|
||||
make_rtm_list_mock(LIST_ID + 1, "Work"),
|
||||
make_rtm_list_mock(LIST_ID + 2, "Personal"),
|
||||
]
|
||||
client.rtm.lists.get_list.return_value = lists_response
|
||||
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert len(config_entry.subentries) == 3
|
||||
client.rtm.lists.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_sync_multiple_lists_single_reload(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
make_rtm_list_mock: Callable[..., MagicMock],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that discovering multiple new lists in one poll schedules a single reload."""
|
||||
# Empty list response during setup so no subentries are added and no reload fires.
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 0
|
||||
|
||||
# Three new lists appear simultaneously on the next poll.
|
||||
lists_response = MagicMock()
|
||||
lists_response.lists = [
|
||||
make_rtm_list_mock(LIST_ID, "Shopping"),
|
||||
make_rtm_list_mock(LIST_ID + 1, "Work"),
|
||||
make_rtm_list_mock(LIST_ID + 2, "Personal"),
|
||||
]
|
||||
client.rtm.lists.get_list.return_value = lists_response
|
||||
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload:
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
mock_reload.assert_called_once_with(config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_coordinator_sync_no_changes_no_reload(
|
||||
hass: HomeAssistant,
|
||||
config_entry_with_subentry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that a coordinator poll with unchanged subentries schedules no reload."""
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
await hass.config_entries.async_setup(config_entry_with_subentry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry_with_subentry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry_with_subentry.subentries) == 1
|
||||
|
||||
with patch.object(hass.config_entries, "async_schedule_reload") as mock_reload:
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
mock_reload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_coordinator_polls_when_no_entities(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
rtm_list_mock: Callable[[int, str], MagicMock],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that the coordinator keeps polling even when there are no todo entities.
|
||||
|
||||
When there are no eligible RTM lists there are no subentries and therefore no
|
||||
CoordinatorEntity listeners. Without a permanent listener the coordinator stops
|
||||
scheduling refreshes after the first one, so lists created later in RTM are
|
||||
never discovered.
|
||||
"""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(config_entry.subentries) == 0
|
||||
|
||||
# A new list appears on the server.
|
||||
rtm_list_mock(LIST_ID, "Shopping")
|
||||
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert len(config_entry.subentries) == 1
|
||||
subentry = next(iter(config_entry.subentries.values()))
|
||||
assert subentry.data[CONF_LIST_ID] == LIST_ID
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_update_listener_registered_before_forward(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that a subentry added during platform forwarding triggers a reload.
|
||||
|
||||
The update listener must be registered before async_forward_entry_setups so
|
||||
that a subentry appearing in the reconciliation window (after todo.async_setup_entry
|
||||
enumerated subentries but before the listener is installed) still fires a reload and
|
||||
gets picked up.
|
||||
"""
|
||||
original_forward = hass.config_entries.async_forward_entry_setups
|
||||
|
||||
async def forward_and_add(entry, platforms):
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType({CONF_LIST_ID: NEW_LIST_ID}),
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title="Late List",
|
||||
unique_id=str(NEW_LIST_ID),
|
||||
),
|
||||
)
|
||||
await original_forward(entry, platforms)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hass.config_entries,
|
||||
"async_forward_entry_setups",
|
||||
side_effect=forward_and_add,
|
||||
),
|
||||
patch.object(hass.config_entries, "async_schedule_reload") as mock_reload,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_reload.assert_called_with(config_entry.entry_id)
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Test the Remember The Milk todo platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aiortm import AioRTMError, AuthError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.remember_the_milk.const import (
|
||||
CONF_LIST_ID,
|
||||
DOMAIN,
|
||||
SUBENTRY_TYPE_LIST,
|
||||
)
|
||||
from homeassistant.components.todo import (
|
||||
ATTR_DESCRIPTION,
|
||||
ATTR_DUE_DATE,
|
||||
ATTR_ITEM,
|
||||
ATTR_RENAME,
|
||||
ATTR_STATUS,
|
||||
DOMAIN as TODO_DOMAIN,
|
||||
TodoItemStatus,
|
||||
TodoServices,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentryDataWithId
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CREATE_ENTRY_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
SUBENTRY_ID = "test-subentry-id"
|
||||
LIST_ID = 99
|
||||
ENTITY_ID = "todo.my_shopping_list"
|
||||
|
||||
|
||||
def _make_task_list(
|
||||
list_id: int,
|
||||
taskseries_id: int,
|
||||
task_id: int,
|
||||
name: str,
|
||||
completed: datetime | None = None,
|
||||
deleted: datetime | None = None,
|
||||
due: datetime | None = None,
|
||||
has_due_time: bool = False,
|
||||
notes: list | None = None,
|
||||
) -> MagicMock:
|
||||
"""Build a minimal mock RTM task list response for one task."""
|
||||
task_list = MagicMock()
|
||||
task_list.id = list_id
|
||||
taskseries = MagicMock()
|
||||
taskseries.id = taskseries_id
|
||||
taskseries.name = name
|
||||
taskseries.notes = notes or []
|
||||
task = MagicMock()
|
||||
task.id = task_id
|
||||
task.completed = completed
|
||||
task.deleted = deleted
|
||||
task.due = due
|
||||
task.has_due_time = has_due_time
|
||||
taskseries.task = [task]
|
||||
task_list.taskseries = [taskseries]
|
||||
return task_list
|
||||
|
||||
|
||||
def _set_tasks_response(client: MagicMock, *task_lists: MagicMock) -> None:
|
||||
"""Configure the client to return the given task lists from tasks.get_list."""
|
||||
tasks_response = MagicMock()
|
||||
tasks_response.tasks.task_list = list(task_lists)
|
||||
client.rtm.tasks.get_list.return_value = tasks_response
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _lists_response(rtm_list_mock: Callable[[int, str], MagicMock]) -> None:
|
||||
"""Return list 99 for all todo tests so the list subentry is kept during coordinator sync."""
|
||||
rtm_list_mock(LIST_ID, "My Shopping List")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Return a mock config entry with one list subentry."""
|
||||
entry = MockConfigEntry(
|
||||
data=CREATE_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
subentries_data=[
|
||||
ConfigSubentryDataWithId(
|
||||
data={CONF_LIST_ID: LIST_ID},
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title="My Shopping List",
|
||||
unique_id=str(LIST_ID),
|
||||
subentry_id=SUBENTRY_ID,
|
||||
)
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_entity_state(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that the entity state reflects incomplete task count."""
|
||||
_set_tasks_response(
|
||||
client,
|
||||
_make_task_list(LIST_ID, 10, 1, "Buy milk"),
|
||||
_make_task_list(
|
||||
LIST_ID, 20, 2, "Eggs", completed=datetime(2024, 1, 1, tzinfo=UTC)
|
||||
),
|
||||
_make_task_list(
|
||||
LIST_ID, 30, 3, "Bread", deleted=datetime(2024, 1, 1, tzinfo=UTC)
|
||||
),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
# 1 active ("Buy milk"), 1 completed ("Eggs"), 1 deleted ("Bread" — excluded)
|
||||
assert state.state == "1"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_device_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test that a device entry is created for the todo list entity."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device_by_identifier(
|
||||
(DOMAIN, SUBENTRY_ID), config_entry.entry_id
|
||||
)
|
||||
assert device_entry is not None
|
||||
assert device_entry == snapshot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_create_todo_item(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a todo item calls the RTM tasks.add API."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Buy butter"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.add.assert_called_once_with(
|
||||
timeline=1234,
|
||||
name="Buy butter",
|
||||
list_id=LIST_ID,
|
||||
parse=True,
|
||||
)
|
||||
client.rtm.tasks.get_list.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_create_todo_item_with_due_date(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a todo item with a due date."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Buy butter",
|
||||
ATTR_DUE_DATE: "2024-03-15",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.add.assert_called_once()
|
||||
client.rtm.tasks.set_due_date.assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=1, # from mock response task_list.id
|
||||
taskseries_id=2, # from mock response taskseries.id
|
||||
task_id=3, # from mock response task.id
|
||||
due="2024-03-15",
|
||||
has_due_time=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_create_todo_item_with_description(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a todo item with a description adds a note."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: "Buy butter",
|
||||
ATTR_DESCRIPTION: "Full fat please",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.add.assert_called_once()
|
||||
client.rtm.tasks.notes.add.assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=1,
|
||||
taskseries_id=2,
|
||||
task_id=3,
|
||||
title="",
|
||||
text="Full fat please",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_name",
|
||||
[
|
||||
pytest.param("Buy whole milk", id="different_name"),
|
||||
pytest.param("Buy milk", id="same_name"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_rename(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
new_name: str,
|
||||
) -> None:
|
||||
"""Test renaming a todo item always calls tasks.set_name, even when the name is unchanged."""
|
||||
_set_tasks_response(client, _make_task_list(LIST_ID, 10, 1, "Buy milk"))
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: uid, ATTR_RENAME: new_name},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.set_name.assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=LIST_ID,
|
||||
taskseries_id=10,
|
||||
task_id=1,
|
||||
name=new_name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_completed", "target_status", "expected_api", "not_expected_api"),
|
||||
[
|
||||
pytest.param(
|
||||
None,
|
||||
TodoItemStatus.COMPLETED,
|
||||
"complete",
|
||||
"uncomplete", # codespell:ignore uncomplete
|
||||
id="incomplete_to_complete",
|
||||
),
|
||||
pytest.param(
|
||||
datetime(2024, 1, 1, tzinfo=UTC),
|
||||
TodoItemStatus.NEEDS_ACTION,
|
||||
"uncomplete", # codespell:ignore uncomplete
|
||||
"complete",
|
||||
id="complete_to_incomplete",
|
||||
),
|
||||
pytest.param(
|
||||
datetime(2024, 1, 1, tzinfo=UTC),
|
||||
TodoItemStatus.COMPLETED,
|
||||
"complete",
|
||||
"uncomplete", # codespell:ignore uncomplete
|
||||
id="already_complete",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
TodoItemStatus.NEEDS_ACTION,
|
||||
"uncomplete", # codespell:ignore uncomplete
|
||||
"complete",
|
||||
id="already_incomplete",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_status(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
initial_completed: datetime | None,
|
||||
target_status: TodoItemStatus,
|
||||
expected_api: str,
|
||||
not_expected_api: str,
|
||||
) -> None:
|
||||
"""Test updating item status always calls the correct RTM API, even when unchanged."""
|
||||
_set_tasks_response(
|
||||
client, _make_task_list(LIST_ID, 10, 1, "Buy milk", completed=initial_completed)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: uid, ATTR_STATUS: target_status},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
getattr(client.rtm.tasks, expected_api).assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=LIST_ID,
|
||||
taskseries_id=10,
|
||||
task_id=1,
|
||||
)
|
||||
getattr(client.rtm.tasks, not_expected_api).assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_delete_todo_items(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test deleting todo items calls tasks.delete for each uid."""
|
||||
_set_tasks_response(
|
||||
client,
|
||||
_make_task_list(LIST_ID, 10, 1, "Buy milk"),
|
||||
_make_task_list(LIST_ID, 20, 2, "Buy eggs"),
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid1 = f"{LIST_ID}_10_1"
|
||||
uid2 = f"{LIST_ID}_20_2"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.REMOVE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: [uid1, uid2]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert client.rtm.tasks.delete.call_count == 2
|
||||
client.rtm.tasks.delete.assert_any_call(
|
||||
timeline=1234, list_id=LIST_ID, taskseries_id=10, task_id=1
|
||||
)
|
||||
client.rtm.tasks.delete.assert_any_call(
|
||||
timeline=1234, list_id=LIST_ID, taskseries_id=20, task_id=2
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("initial_due", "new_due_str", "expected_due", "expected_has_due_time"),
|
||||
[
|
||||
pytest.param(None, "2024-03-15", "2024-03-15", False, id="no_due_to_due"),
|
||||
pytest.param(
|
||||
datetime(2024, 3, 15, tzinfo=UTC),
|
||||
"2024-03-15",
|
||||
"2024-03-15",
|
||||
False,
|
||||
id="same_due",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_set_due_date(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
initial_due: datetime | None,
|
||||
new_due_str: str,
|
||||
expected_due: str,
|
||||
expected_has_due_time: bool,
|
||||
) -> None:
|
||||
"""Test updating due date always calls tasks.set_due_date, even when unchanged."""
|
||||
_set_tasks_response(
|
||||
client, _make_task_list(LIST_ID, 10, 1, "Buy milk", due=initial_due)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: uid, ATTR_DUE_DATE: new_due_str},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.set_due_date.assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=LIST_ID,
|
||||
taskseries_id=10,
|
||||
task_id=1,
|
||||
due=expected_due,
|
||||
has_due_time=expected_has_due_time,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_add_description(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test updating a todo item to add a description adds a note."""
|
||||
_set_tasks_response(client, _make_task_list(LIST_ID, 10, 1, "Buy milk"))
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: uid,
|
||||
ATTR_DESCRIPTION: "Organic if possible",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.notes.add.assert_called_once_with(
|
||||
timeline=1234,
|
||||
list_id=LIST_ID,
|
||||
taskseries_id=10,
|
||||
task_id=1,
|
||||
title="",
|
||||
text="Organic if possible",
|
||||
)
|
||||
client.rtm.tasks.notes.edit.assert_not_called()
|
||||
client.rtm.tasks.notes.delete.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_description",
|
||||
[
|
||||
pytest.param("New description", id="different_description"),
|
||||
pytest.param("Old description", id="same_description"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_edit_description(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
new_description: str,
|
||||
) -> None:
|
||||
"""Test updating a todo item description always edits the note, even when unchanged."""
|
||||
note = MagicMock()
|
||||
note.id = 55
|
||||
note.body = "Old description"
|
||||
_set_tasks_response(
|
||||
client, _make_task_list(LIST_ID, 10, 1, "Buy milk", notes=[note])
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: uid, ATTR_DESCRIPTION: new_description},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.notes.edit.assert_called_once_with(
|
||||
timeline=1234,
|
||||
note_id=55,
|
||||
title="",
|
||||
text=new_description,
|
||||
)
|
||||
client.rtm.tasks.notes.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_delete_description(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test clearing a todo item description deletes the note."""
|
||||
note = MagicMock()
|
||||
note.id = 55
|
||||
note.body = "Existing description"
|
||||
_set_tasks_response(
|
||||
client, _make_task_list(LIST_ID, 10, 1, "Buy milk", notes=[note])
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: uid, ATTR_DESCRIPTION: ""},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.notes.delete.assert_called_once_with(
|
||||
timeline=1234,
|
||||
note_id=55,
|
||||
)
|
||||
client.rtm.tasks.notes.add.assert_not_called()
|
||||
client.rtm.tasks.notes.edit.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_update_todo_item_empty_note_preserved(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that an unrelated update does not delete a note with an empty body.
|
||||
|
||||
RTM notes can have a title but an empty body. The coordinator represents those
|
||||
as description=None (because body or None collapses an empty string), but the
|
||||
note still has a note_id. An unrelated update (e.g. completing the task) must
|
||||
not delete that note.
|
||||
"""
|
||||
note = MagicMock()
|
||||
note.id = 55
|
||||
note.body = ""
|
||||
_set_tasks_response(
|
||||
client, _make_task_list(LIST_ID, 10, 1, "Buy milk", notes=[note])
|
||||
)
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
uid = f"{LIST_ID}_10_1"
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.UPDATE_ITEM,
|
||||
{
|
||||
ATTR_ENTITY_ID: ENTITY_ID,
|
||||
ATTR_ITEM: uid,
|
||||
ATTR_STATUS: TodoItemStatus.COMPLETED,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
client.rtm.tasks.notes.delete.assert_not_called()
|
||||
client.rtm.tasks.notes.edit.assert_not_called()
|
||||
client.rtm.tasks.notes.add.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("client", "storage")
|
||||
async def test_setup_skips_non_list_subentries(hass: HomeAssistant) -> None:
|
||||
"""Test that async_setup_entry ignores subentries that are not list subentries."""
|
||||
entry = MockConfigEntry(
|
||||
data=CREATE_ENTRY_DATA,
|
||||
domain=DOMAIN,
|
||||
subentries_data=[
|
||||
ConfigSubentryDataWithId(
|
||||
data={CONF_LIST_ID: LIST_ID},
|
||||
subentry_type=SUBENTRY_TYPE_LIST,
|
||||
title="My Shopping List",
|
||||
unique_id=str(LIST_ID),
|
||||
subentry_id=SUBENTRY_ID,
|
||||
),
|
||||
ConfigSubentryDataWithId(
|
||||
data={},
|
||||
subentry_type="other",
|
||||
title="Other Subentry",
|
||||
unique_id=None,
|
||||
subentry_id="extra-subentry-id",
|
||||
),
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Only the list subentry should produce a todo entity.
|
||||
assert hass.states.get(ENTITY_ID) is not None
|
||||
assert hass.states.get("todo.other_subentry") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "translation_key"),
|
||||
[
|
||||
pytest.param(AuthError("Boom!"), "invalid_auth", id="auth_error"),
|
||||
pytest.param(AioRTMError("Boom!"), "api_error", id="api_error"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("storage")
|
||||
async def test_todo_item_api_errors(
|
||||
hass: HomeAssistant,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test that RTM API errors during a todo operation raise HomeAssistantError."""
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
client.rtm.timelines.create.side_effect = side_effect
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await hass.services.async_call(
|
||||
TODO_DOMAIN,
|
||||
TodoServices.ADD_ITEM,
|
||||
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_ITEM: "Buy butter"},
|
||||
blocking=True,
|
||||
)
|
||||
assert exc_info.value.translation_key == translation_key
|
||||
Reference in New Issue
Block a user