Add config flow to remember_the_milk (#178808)

This commit is contained in:
Martin Hjelmare
2026-08-22 10:46:16 +02:00
committed by GitHub
parent c32573558b
commit 4130e16977
16 changed files with 1126 additions and 365 deletions
@@ -1,26 +1,33 @@
"""Support to interact with Remember The Milk."""
"""The Remember The Milk integration."""
from rtmapi import Rtm
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.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_ID,
CONF_NAME,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
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 LOGGER
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
from .entity import RememberTheMilkEntity
from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
DOMAIN = "remember_the_milk"
CONF_SHARED_SECRET = "shared_secret"
RTM_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@@ -42,114 +49,161 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
DATA_COMPONENT = "component"
DATA_STORAGE = "storage"
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
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."""
component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
# pylint: disable-next=home-assistant-use-runtime-data
hass.data[DOMAIN] = {}
# pylint: disable-next=home-assistant-use-runtime-data
hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity](
LOGGER, DOMAIN, hass
)
# pylint: disable-next=home-assistant-use-runtime-data
storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass)
await hass.async_add_executor_job(storage.setup)
if DOMAIN not in config:
return True
stored_rtm_config = RememberTheMilkConfiguration(hass)
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
)
else:
_register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
LOGGER.debug("Finished adding all Remember the milk accounts")
for rtm_config in deepcopy(config[DOMAIN]):
hass.async_create_task(_async_import(hass, storage, rtm_config))
return True
def _create_instance(
async def _async_import(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
token: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
storage: RememberTheMilkConfiguration,
rtm_config: dict[str, Any],
) -> None:
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
"""Import a YAML configured account and create a repair issue."""
name = rtm_config[CONF_NAME]
token = storage.get_token(name)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=rtm_config | {CONF_TOKEN: token},
)
component.add_entities([entity])
hass.services.register(
if (
result["type"] is FlowResultType.ABORT
and result["reason"] != "already_configured"
):
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2027.3.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Remember The Milk",
},
)
return
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2027.3.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Remember The Milk",
},
)
async def async_setup_entry(
hass: HomeAssistant, entry: RememberTheMilkConfigEntry
) -> bool:
"""Set up Remember The Milk from a config entry."""
# pylint: disable-next=home-assistant-use-runtime-data
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
# pylint: disable-next=home-assistant-use-runtime-data
storage: RememberTheMilkConfiguration = hass.data[DOMAIN][DATA_STORAGE]
rtm_config = entry.data
account_name: str = rtm_config[CONF_USERNAME]
LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key: str = rtm_config[CONF_API_KEY]
shared_secret: str = rtm_config[CONF_SHARED_SECRET]
token: str = rtm_config[CONF_TOKEN]
client = AioRTMClient(
Auth(
client_session=async_get_clientsession(hass),
api_key=api_key,
shared_secret=shared_secret,
auth_token=token,
permission="delete",
)
)
token_valid = True
try:
await client.rtm.api.check_token()
except AuthError:
token_valid = False
except AioRTMError as err:
raise ConfigEntryNotReady from err
# The entity will be deprecated when a todo platform is added.
entity = RememberTheMilkEntity(
name=account_name,
client=client,
config_entry_id=entry.entry_id,
storage=storage,
token_valid=token_valid,
)
await component.async_add_entities([entity])
entry.runtime_data = RememberTheMilkData(entity_id=entity.entity_id)
# The services are registered here for now because they need the account name.
# The services will be deprecated when a todo platform is added.
# pylint: disable=home-assistant-service-registered-in-setup-entry
hass.services.async_register(
DOMAIN,
f"{account_name}_create_task",
entity.create_task,
schema=SERVICE_SCHEMA_CREATE_TASK,
)
hass.services.register(
hass.services.async_register(
DOMAIN,
f"{account_name}_complete_task",
entity.complete_task,
schema=SERVICE_SCHEMA_COMPLETE_TASK,
)
if not token_valid:
raise ConfigEntryAuthFailed("Invalid token")
def _register_new_account(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
LOGGER.debug("Sent authentication request to server")
return True
def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator."""
api.retrieve_token(frob)
token = api.token
if api.token is None:
LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
stored_rtm_config.set_token(account_name, token)
LOGGER.debug("Retrieved new token from server")
_create_instance(
hass,
account_name,
api_key,
shared_secret,
token,
stored_rtm_config,
component,
)
configurator.request_done(hass, request_id)
request_id = configurator.request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
description=(
"You need to log in to Remember The Milk to"
"connect your account. \n\n"
"Step 1: Click on the link 'Remember The Milk login'\n\n"
"Step 2: Click on 'login completed'"
),
link_name="Remember The Milk login",
link_url=url,
submit_caption="login completed",
)
async def async_unload_entry(
hass: HomeAssistant, entry: RememberTheMilkConfigEntry
) -> bool:
"""Unload a config entry."""
component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][
DATA_COMPONENT
]
await component.async_remove_entity(entry.runtime_data.entity_id)
return True
@@ -0,0 +1,180 @@
"""Config flow for Remember The Milk integration."""
import asyncio
from typing import Any, override
from aiortm import AioRTMError, Auth, AuthError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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 (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import CONF_SHARED_SECRET, DOMAIN, LOGGER
TOKEN_TIMEOUT_SEC = 30
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Required(CONF_SHARED_SECRET): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
)
class RTMConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remember The Milk."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._auth: Auth | None = None
self._url: str | None = None
self._frob: str | None = None
self._auth_credentials: dict[str, str] | None = None
def _get_auth(
self, api_key: str, shared_secret: str, token: str | None = None
) -> Auth:
"""Return an Auth client for the given credentials."""
return Auth(
client_session=async_get_clientsession(self.hass),
api_key=api_key,
shared_secret=shared_secret,
auth_token=token,
permission="delete",
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._auth_credentials = user_input
auth = self._auth = self._get_auth(
user_input[CONF_API_KEY], user_input[CONF_SHARED_SECRET]
)
try:
self._url, self._frob = await auth.authenticate_desktop()
except AuthError:
errors["base"] = "invalid_auth"
except AioRTMError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self.async_step_auth()
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
user_input,
),
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Authorize the application."""
assert self._url is not None
if user_input is not None:
return await self._get_token()
return self.async_show_form(
step_id="auth", description_placeholders={"url": self._url}
)
async def _get_token(self) -> ConfigFlowResult:
"""Get token and create config entry."""
assert self._auth is not None
assert self._frob is not None
assert self._auth_credentials is not None
try:
async with asyncio.timeout(TOKEN_TIMEOUT_SEC):
token_data = await self._auth.get_token(self._frob)
except TimeoutError:
return self.async_abort(reason="timeout_token")
except AuthError:
return self.async_abort(reason="invalid_auth")
except AioRTMError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
return await self._async_create_entry(
token_data,
self._auth_credentials[CONF_API_KEY],
self._auth_credentials[CONF_SHARED_SECRET],
)
async def _async_create_entry(
self,
token_data: dict[str, Any],
api_key: str,
shared_secret: str,
) -> ConfigFlowResult:
"""Create the config entry from token data.
The token data has the same structure whether it comes from get_token
or check_token.
"""
await self.async_set_unique_id(token_data["user"]["id"])
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=token_data["user"]["fullname"],
data={
CONF_API_KEY: api_key,
CONF_SHARED_SECRET: shared_secret,
CONF_TOKEN: token_data["token"],
CONF_USERNAME: token_data["user"]["username"],
},
)
async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult:
"""Import a config entry from YAML.
The token, looked up from legacy storage in async_setup, is passed in
the import data. Without a valid token the import is aborted so the user
sets the integration up via the UI. A repair issue is raised in
async_setup for both the success and failure cases.
"""
name = import_info.pop(CONF_NAME)
self._async_abort_entries_match({CONF_USERNAME: name})
token = import_info.get(CONF_TOKEN)
if token is None:
return self.async_abort(reason="invalid_auth")
auth = self._get_auth(
import_info[CONF_API_KEY], import_info[CONF_SHARED_SECRET], token
)
try:
token_data = await auth.check_token()
except AuthError:
return self.async_abort(reason="invalid_auth")
except AioRTMError:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001 pylint: disable=broad-except
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
if token_data["user"]["username"] != name:
return self.async_abort(reason="invalid_auth")
return await self._async_create_entry(
token_data,
import_info[CONF_API_KEY],
import_info[CONF_SHARED_SECRET],
)
@@ -2,4 +2,6 @@
import logging
CONF_SHARED_SECRET = "shared_secret"
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__)
@@ -2,10 +2,10 @@
from typing import override
from rtmapi import Rtm, RtmRequestFailedException
from aiortm import AioRTMClient, AioRTMError, AuthError
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.core import ServiceCall, callback
from homeassistant.helpers.entity import Entity
from .const import LOGGER
@@ -17,42 +17,21 @@ class RememberTheMilkEntity(Entity):
def __init__(
self,
*,
name: str,
api_key: str,
shared_secret: str,
token: str,
rtm_config: RememberTheMilkConfiguration,
client: AioRTMClient,
config_entry_id: str,
storage: RememberTheMilkConfiguration,
token_valid: bool,
) -> None:
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
self._shared_secret = shared_secret
self._token = token
self._rtm_config = rtm_config
self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
self._token_valid = False
self._check_token()
LOGGER.debug("Instance created for account %s", self._name)
self._rtm_config = storage
self._client = client
self._config_entry_id = config_entry_id
self._token_valid = token_valid
def _check_token(self) -> bool:
"""Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process.
"""
valid = self._rtm_api.token_valid()
if not valid:
LOGGER.error(
"Token for account %s is invalid. You need to register again!",
self.name,
)
self._rtm_config.delete_token(self._name)
self._token_valid = False
else:
self._token_valid = True
return self._token_valid
def create_task(self, call: ServiceCall) -> None:
async def create_task(self, call: ServiceCall) -> None:
"""Create a new task on Remember The Milk.
You can use the smart syntax to define the attributes of a new task,
@@ -60,31 +39,37 @@ class RememberTheMilkEntity(Entity):
due date to today.
"""
try:
task_name = call.data[CONF_NAME]
hass_id = call.data.get(CONF_ID)
rtm_id = None
task_name: str = call.data[CONF_NAME]
hass_id: str | None = call.data.get(CONF_ID)
rtm_id: tuple[int, int, int] | None = None
if hass_id is not None:
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
rtm_id = await self.hass.async_add_executor_job(
self._rtm_config.get_rtm_id, self._name, hass_id
)
timeline_response = await self._client.rtm.timelines.create()
timeline = timeline_response.timeline
if rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
add_response = await self._client.rtm.tasks.add(
timeline=timeline, name=task_name, parse=True
)
LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
if hass_id is not None:
self._rtm_config.set_rtm_id(
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
)
if hass_id is None:
return
task_list = add_response.task_list
taskseries = task_list.taskseries[0]
await self.hass.async_add_executor_job(
self._rtm_config.set_rtm_id,
self._name,
hass_id,
task_list.id,
taskseries.id,
taskseries.task[0].id,
)
else:
self._rtm_api.rtm.tasks.setName(
await self._client.rtm.tasks.set_name(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
@@ -97,17 +82,26 @@ class RememberTheMilkEntity(Entity):
self.name,
task_name,
)
except RtmRequestFailedException as rtm_exception:
except AuthError as err:
LOGGER.error(
"Invalid authentication when creating task for account %s: %s",
self._name,
err,
)
self._handle_token(False)
except AioRTMError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
err,
)
def complete_task(self, call: ServiceCall) -> None:
async def complete_task(self, call: ServiceCall) -> None:
"""Complete a task that was previously created by this component."""
hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
rtm_id = await self.hass.async_add_executor_job(
self._rtm_config.get_rtm_id, self._name, hass_id
)
if rtm_id is None:
LOGGER.error(
(
@@ -119,21 +113,32 @@ class RememberTheMilkEntity(Entity):
)
return
try:
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete(
result = await self._client.rtm.timelines.create()
timeline = result.timeline
await self._client.rtm.tasks.complete(
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
self._rtm_config.delete_rtm_id(self._name, hass_id)
await self.hass.async_add_executor_job(
self._rtm_config.delete_rtm_id, self._name, hass_id
)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
except RtmRequestFailedException as rtm_exception:
except AuthError as err:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
"Invalid authentication when completing task with id %s for account %s: %s",
hass_id,
self._name,
rtm_exception,
err,
)
self._handle_token(False)
except AioRTMError as err:
LOGGER.error(
"Error completing task with id %s for account %s: %s",
hass_id,
self._name,
err,
)
@property
@@ -149,3 +154,11 @@ class RememberTheMilkEntity(Entity):
if not self._token_valid:
return "API token invalid"
return STATE_OK
@callback
def _handle_token(self, token_valid: bool) -> None:
self._token_valid = token_valid
self.async_write_ha_state()
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._config_entry_id)
)
@@ -2,10 +2,11 @@
"domain": "remember_the_milk",
"name": "Remember The Milk",
"codeowners": [],
"dependencies": ["configurator"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/remember_the_milk",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["rtmapi"],
"loggers": ["aiortm"],
"quality_scale": "legacy",
"requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"]
"requirements": ["aiortm==0.19.0"]
}
@@ -1,8 +1,8 @@
"""Store RTM configuration in Home Assistant storage."""
"""Provide storage for Remember The Milk integration."""
import json
from pathlib import Path
from typing import cast
from typing import Any, cast
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
@@ -22,7 +22,10 @@ class RememberTheMilkConfiguration:
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
self._config = {}
self._config: dict[str, Any] = {}
def setup(self) -> None:
"""Set up the configuration."""
LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
self._config = json.loads(
@@ -48,24 +51,8 @@ class RememberTheMilkConfiguration:
)
def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile."""
if profile_name in self._config:
return cast(str, self._config[profile_name][CONF_TOKEN])
return None
def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile."""
self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token
self._save_config()
def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile.
Usually called when the token has expired.
"""
self._config.pop(profile_name, None)
self._save_config()
"""Get the stored token for a profile, if any."""
return cast("str | None", self._config.get(profile_name, {}).get(CONF_TOKEN))
def _initialize_profile(self, profile_name: str) -> None:
"""Initialize the data structures for a profile."""
@@ -76,7 +63,7 @@ class RememberTheMilkConfiguration:
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
) -> tuple[int, int, int] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
@@ -86,22 +73,28 @@ class RememberTheMilkConfiguration:
ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
if ids is None:
return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
# Legacy storage stored the ids as strings, so convert to int.
return (
int(ids[CONF_LIST_ID]),
int(ids[CONF_TIMESERIES_ID]),
int(ids[CONF_TASK_ID]),
)
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
list_id: int,
time_series_id: int,
rtm_task_id: int,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
"""Add/Update the RTM task ID for a Home Assistant task ID."""
self._initialize_profile(profile_name)
# Store the ids as strings to keep the legacy storage format.
id_tuple = {
CONF_LIST_ID: list_id,
CONF_TIMESERIES_ID: time_series_id,
CONF_TASK_ID: rtm_task_id,
CONF_LIST_ID: str(list_id),
CONF_TIMESERIES_ID: str(time_series_id),
CONF_TASK_ID: str(rtm_task_id),
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
self._save_config()
@@ -1,4 +1,48 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"timeout_token": "Timeout getting access token",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"auth": {
"description": "Follow the link to authorize Home Assistant to access your Remember The Milk account. When done, click on the button below to continue.\n\n[Authorize]({url})"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"shared_secret": "Shared secret"
},
"data_description": {
"api_key": "The API key of your Remember The Milk API application.",
"shared_secret": "The shared secret of your Remember The Milk API application."
},
"description": "Enter the API key and shared secret from a Remember The Milk API application. You can request these credentials using your Remember The Milk account."
}
}
},
"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.",
"title": "The {integration_title} YAML configuration is being removed"
},
"deprecated_yaml_import_issue_invalid_auth": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a stored authentication token could not be found or was invalid. Please remove the {domain} configuration from your YAML and set the integration up via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
},
"deprecated_yaml_import_issue_unknown": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unknown error occurred. Please remove the {domain} configuration from your YAML and set the integration up via the UI.",
"title": "The {integration_title} YAML configuration is being removed"
}
},
"services": {
"complete_task": {
"description": "Completes a task that was previously created.",
+1
View File
@@ -650,6 +650,7 @@ FLOWS = {
"redgtech",
"refoss",
"rehlko",
"remember_the_milk",
"remote_calendar",
"renault",
"renson",
+2 -2
View File
@@ -5982,8 +5982,8 @@
},
"remember_the_milk": {
"name": "Remember The Milk",
"integration_type": "hub",
"config_flow": false,
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_push"
},
"remote_calendar": {
+3 -6
View File
@@ -110,9 +110,6 @@ RachioPy==1.1.0
# homeassistant.components.python_script
RestrictedPython==8.5
# homeassistant.components.remember_the_milk
RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
SQLAlchemy==2.0.52
@@ -409,6 +406,9 @@ aiorecollect==2023.09.0
# homeassistant.components.ridwell
aioridwell==2025.09.0
# homeassistant.components.remember_the_milk
aiortm==0.19.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.46.3
@@ -1304,9 +1304,6 @@ homevolt==0.5.0
# homeassistant.components.horizon
horimote==0.4.1
# homeassistant.components.remember_the_milk
httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.11.0
+74 -18
View File
@@ -1,37 +1,71 @@
"""Provide common pytest fixtures."""
from collections.abc import AsyncGenerator, Generator
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.remember_the_milk.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import TOKEN
from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE
from tests.common import MockConfigEntry
@pytest.fixture
def ignore_missing_translations(request: pytest.FixtureRequest) -> list[str]:
"""Ignore translations for the per-account services registered at runtime.
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")):
return [
f"component.{DOMAIN}.services.{PROFILE}_create_task.",
f"component.{DOMAIN}.services.{PROFILE}_complete_task.",
]
return []
@pytest.fixture(name="client")
def client_fixture() -> Generator[MagicMock]:
"""Create a mock client."""
client = MagicMock()
with (
patch(
"homeassistant.components.remember_the_milk.entity.Rtm"
) as entity_client_class,
patch("homeassistant.components.remember_the_milk.Rtm") as client_class,
"homeassistant.components.remember_the_milk.AioRTMClient",
) as client_class,
patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.check_token",
AsyncMock(return_value=TOKEN_RESPONSE),
),
patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
AsyncMock(return_value=("https://test-url.com", "test-frob")),
),
patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
AsyncMock(return_value=TOKEN_RESPONSE),
),
):
entity_client_class.return_value = client
client_class.return_value = client
client.token = TOKEN
client.token_valid.return_value = True
client = client_class.return_value
client.rtm.api.check_token = AsyncMock(return_value=TOKEN_RESPONSE)
timelines = MagicMock()
timelines.timeline.value = "1234"
client.rtm.timelines.create.return_value = timelines
add_response = MagicMock()
add_response.list.id = "1"
add_response.list.taskseries.id = "2"
add_response.list.taskseries.task.id = "3"
client.rtm.tasks.add.return_value = add_response
timelines.timeline = 1234
client.rtm.timelines.create = AsyncMock(return_value=timelines)
response = MagicMock()
response.task_list.id = 1
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)
yield client
@@ -43,6 +77,28 @@ async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]:
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration"
) as storage_class:
storage = storage_class.return_value
storage.get_token.return_value = TOKEN
storage.get_rtm_id.return_value = None
storage.get_token.return_value = "test-token"
yield storage
@pytest.fixture
def config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return a mock config entry."""
entry = MockConfigEntry(
data=CREATE_ENTRY_DATA,
domain=DOMAIN,
unique_id="1234567",
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.remember_the_milk.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
+22 -6
View File
@@ -3,17 +3,33 @@
import json
PROFILE = "myprofile"
CONFIG = {
"name": f"{PROFILE}",
CREATE_ENTRY_DATA = {
"api_key": "test-api-key",
"shared_secret": "test-shared-secret",
"shared_secret": "test-secret",
"token": "test-token",
"username": PROFILE,
}
TOKEN = "mytoken"
JSON_STRING = json.dumps(
TOKEN_RESPONSE = {
"token": "test-token",
"perms": "delete",
"user": {"id": "1234567", "username": PROFILE, "fullname": "John Smith"},
}
# The legacy configuration file format:
LEGACY_JSON_STRING = json.dumps(
{
"myprofile": {
PROFILE: {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
}
)
# The new configuration file format:
JSON_STRING = json.dumps(
{
PROFILE: {
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
}
)
@@ -0,0 +1,270 @@
"""Test the Remember The Milk config flow."""
import asyncio
from collections.abc import Awaitable
from typing import Any
from unittest.mock import AsyncMock, patch
from aiortm import AioRTMError, AuthError
import pytest
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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import CREATE_ENTRY_DATA, PROFILE, TOKEN_RESPONSE
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_successful_flow(
hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test successful flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TOKEN_RESPONSE["user"]["fullname"]
assert result["data"] == CREATE_ENTRY_DATA
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(AuthError, "invalid_auth"),
(AioRTMError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
client: AsyncMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test form errors when getting the authentication URL."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TOKEN_RESPONSE["user"]["fullname"]
assert result["data"] == CREATE_ENTRY_DATA
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
assert len(mock_setup_entry.mock_calls) == 1
async def mock_get_token(*args: Any) -> None:
"""Handle get token."""
await asyncio.Future()
@pytest.mark.parametrize(
("side_effect", "reason", "timeout"),
[
(AuthError, "invalid_auth", TOKEN_TIMEOUT_SEC),
(AioRTMError, "cannot_connect", TOKEN_TIMEOUT_SEC),
(Exception, "unknown", TOKEN_TIMEOUT_SEC),
(mock_get_token, "timeout_token", 0),
],
)
async def test_token_abort_reasons(
hass: HomeAssistant,
client: AsyncMock,
side_effect: Exception | Awaitable[None],
reason: str,
timeout: int,
) -> None:
"""Test abort result when getting token."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
with (
patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.get_token",
side_effect=side_effect,
),
patch(
"homeassistant.components.remember_the_milk.config_flow.TOKEN_TIMEOUT_SEC",
timeout,
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_abort_if_already_configured(
hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry
) -> 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}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"api_key": "test-api-key",
"shared_secret": "test-secret",
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import_flow(
hass: HomeAssistant, client: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test import flow with a valid stored token."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"name": PROFILE,
"token": "test-token",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == TOKEN_RESPONSE["user"]["fullname"]
assert result["data"] == {
"api_key": "test-api-key",
"shared_secret": "test-secret",
"token": "test-token",
"username": PROFILE,
}
assert result["result"].unique_id == TOKEN_RESPONSE["user"]["id"]
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("token", "side_effect", "reason"),
[
(None, None, "invalid_auth"),
("test-token", AuthError, "invalid_auth"),
("test-token", AioRTMError, "cannot_connect"),
("test-token", Exception, "unknown"),
],
)
async def test_import_flow_abort(
hass: HomeAssistant,
token: str | None,
side_effect: type[Exception] | None,
reason: str,
) -> None:
"""Test import flow aborts without a valid token."""
with patch(
"homeassistant.components.remember_the_milk.config_flow.Auth.check_token",
side_effect=side_effect,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"name": "test-name",
"token": token,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_import_flow_username_mismatch(
hass: HomeAssistant, client: AsyncMock
) -> None:
"""Test import flow aborts when the token username doesn't match the name."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"name": "other-name",
"token": "test-token",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_auth"
async def test_import_flow_already_configured(
hass: HomeAssistant, client: AsyncMock, config_entry: MockConfigEntry
) -> None:
"""Test import flow aborts when the account name is already configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"api_key": "test-api-key",
"shared_secret": "test-secret",
"name": PROFILE,
"token": "test-token",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
+129 -47
View File
@@ -3,29 +3,40 @@
from typing import Any
from unittest.mock import MagicMock, call
from aiortm import AioRTMError, AuthError
import pytest
from rtmapi import RtmRequestFailedException
from homeassistant.components.remember_the_milk import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .const import CONFIG, PROFILE
from .const import PROFILE
from tests.common import MockConfigEntry
CONFIG = {
"name": f"{PROFILE}",
"api_key": "test-api-key",
"shared_secret": "test-shared-secret",
}
@pytest.mark.usefixtures("storage")
@pytest.mark.parametrize(
("valid_token", "entity_state"), [(True, "ok"), (False, "API token invalid")]
("check_token_side_effect", "entity_state"),
[(None, "ok"), (AuthError("Invalid token!"), "API token invalid")],
)
async def test_entity_state(
hass: HomeAssistant,
client: MagicMock,
storage: MagicMock,
valid_token: bool,
config_entry: MockConfigEntry,
check_token_side_effect: Exception | None,
entity_state: str,
) -> None:
"""Test the entity state."""
client.token_valid.return_value = valid_token
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
client.rtm.api.check_token.side_effect = check_token_side_effect
await hass.config_entries.async_setup(config_entry.entry_id)
entity_id = f"{DOMAIN}.{PROFILE}"
state = hass.states.get(entity_id)
@@ -50,7 +61,7 @@ async def test_entity_state(
),
[
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
0,
@@ -59,9 +70,9 @@ async def test_entity_state(
"rtm.tasks.add",
1,
call(
timeline="1234",
timeline=1234,
name="Test 1",
parse="1",
parse=True,
),
"set_rtm_id",
0,
@@ -77,36 +88,36 @@ async def test_entity_state(
"rtm.tasks.add",
1,
call(
timeline="1234",
timeline=1234,
name="Test 1",
parse="1",
parse=True,
),
"set_rtm_id",
1,
call(PROFILE, "test_1", "1", "2", "3"),
call(PROFILE, "test_1", 1, 2, 3),
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
1,
call(PROFILE, "test_1"),
1,
"rtm.tasks.setName",
"rtm.tasks.set_name",
1,
call(
name="Test 1",
list_id="1",
taskseries_id="2",
task_id="3",
timeline="1234",
list_id=1,
taskseries_id=2,
task_id=3,
timeline=1234,
),
"set_rtm_id",
0,
None,
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
1,
@@ -115,10 +126,10 @@ async def test_entity_state(
"rtm.tasks.complete",
1,
call(
list_id="1",
taskseries_id="2",
task_id="3",
timeline="1234",
list_id=1,
taskseries_id=2,
task_id=3,
timeline=1234,
),
"delete_rtm_id",
1,
@@ -173,52 +184,52 @@ async def test_services(
),
[
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
"rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.tasks.add",
RtmRequestFailedException("rtm.tasks.add", "400", "Bad request"),
"Request rtm.tasks.add failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.tasks.setName",
RtmRequestFailedException("rtm.tasks.setName", "400", "Bad request"),
"Request rtm.tasks.setName failed. Status: 400, reason: Bad request.",
"rtm.tasks.set_name",
AioRTMError("Boom!"),
"Error creating new Remember The Milk task for account myprofile: Boom!",
),
(
None,
@@ -232,20 +243,20 @@ async def test_services(
),
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
"rtm.timelines.create",
RtmRequestFailedException("rtm.timelines.create", "400", "Bad request"),
"Request rtm.timelines.create failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error completing task with id test_1 for account myprofile: Boom!",
),
(
("1", "2", "3"),
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
"rtm.tasks.complete",
RtmRequestFailedException("rtm.tasks.complete", "400", "Bad request"),
"Request rtm.tasks.complete failed. Status: 400, reason: Bad request.",
AioRTMError("Boom!"),
"Error completing task with id test_1 for account myprofile: Boom!",
),
],
)
@@ -274,3 +285,74 @@ async def test_services_errors(
await hass.services.async_call(DOMAIN, service, service_data, blocking=True)
assert error_message in caplog.text
@pytest.mark.parametrize(
(
"get_rtm_id_return_value",
"service",
"service_data",
"method",
"error_message",
),
[
(
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1"},
"rtm.timelines.create",
"Invalid authentication when creating task for account myprofile: Boom!",
),
(
(1, 2, 3),
f"{PROFILE}_create_task",
{"name": "Test 1", "id": "test_1"},
"rtm.tasks.set_name",
"Invalid authentication when creating task for account myprofile: Boom!",
),
(
(1, 2, 3),
f"{PROFILE}_complete_task",
{"id": "test_1"},
"rtm.tasks.complete",
(
"Invalid authentication when completing task with id test_1 "
"for account myprofile: Boom!"
),
),
],
)
async def test_services_auth_errors(
hass: HomeAssistant,
client: MagicMock,
storage: MagicMock,
caplog: pytest.LogCaptureFixture,
get_rtm_id_return_value: Any,
service: str,
service_data: dict[str, Any],
method: str,
error_message: str,
) -> None:
"""Test that an auth error invalidates the token and reloads the entry."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
storage.get_rtm_id.return_value = get_rtm_id_return_value
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is ConfigEntryState.LOADED
state = hass.states.get(f"{DOMAIN}.{PROFILE}")
assert state
assert state.state == "ok"
client_method = client
for name in method.split("."):
client_method = getattr(client_method, name)
client_method.side_effect = AuthError("Boom!")
# The token is now invalid, so re-checking it during the reload fails too.
client.rtm.api.check_token.side_effect = AuthError("Invalid token!")
await hass.services.async_call(DOMAIN, service, service_data, blocking=True)
await hass.async_block_till_done()
assert error_message in caplog.text
assert entry.state is ConfigEntryState.SETUP_ERROR
+97 -50
View File
@@ -1,68 +1,115 @@
"""Test the Remember The Milk integration."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from aiortm import AioRTMError, AuthError
import pytest
from homeassistant.components.remember_the_milk import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.components.remember_the_milk.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
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 CONFIG, PROFILE, TOKEN
from .const import PROFILE
from tests.common import MockConfigEntry
CONFIG = {
"name": "myprofile",
"api_key": "test-api-key",
"shared_secret": "test-shared-secret",
}
@pytest.fixture(autouse=True)
def configure_id() -> Generator[str]:
"""Fixture to return a configure_id."""
mock_id = "1-1"
with patch(
"homeassistant.components.configurator.Configurator._generate_unique_id"
) as generate_id:
generate_id.return_value = mock_id
yield mock_id
@pytest.mark.parametrize(
("token", "rtm_entity_exists", "configurator_end_state"),
[(TOKEN, True, "configured"), (None, False, "configure")],
)
@pytest.mark.parametrize(
"ignore_missing_translations", ["component.configurator.services.configure."]
)
async def test_configurator(
@pytest.mark.usefixtures("storage")
async def test_load_unload_config_entry(
hass: HomeAssistant,
client: MagicMock,
storage: MagicMock,
configure_id: str,
token: str | None,
rtm_entity_exists: bool,
configurator_end_state: str,
config_entry: MockConfigEntry,
) -> None:
"""Test configurator."""
"""Test loading and unloading a config entry."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("storage")
@pytest.mark.parametrize(
("side_effect", "entry_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("Connection failed!"),
ConfigEntryState.SETUP_RETRY,
[],
id="rtm_error",
),
],
)
async def test_config_entry_check_token_fails(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
side_effect: Exception,
entry_state: ConfigEntryState,
) -> None:
"""Test that token check failures put the entry in the expected state."""
client.rtm.api.check_token.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 entry_state
@pytest.mark.usefixtures("client", "storage")
async def test_import_creates_deprecation_issue(
hass: HomeAssistant,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test a successful YAML import creates a deprecation repair issue."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert issue_registry.async_get_issue(
HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}"
)
@pytest.mark.parametrize("ignore_missing_translations", [[]])
@pytest.mark.usefixtures("client")
async def test_import_without_token_creates_issue(
hass: HomeAssistant,
storage: MagicMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test YAML import without a stored token aborts and creates an issue.
Without a token the import can't be completed, so no config entry is
created and the user is guided to set the integration up via the UI.
"""
storage.get_token.return_value = None
client.authenticate_desktop.return_value = ("test-url", "test-frob")
client.token = token
rtm_entity_id = f"{DOMAIN}.{PROFILE}"
configure_entity_id = f"configurator.{DOMAIN}_{PROFILE}"
assert await async_setup_component(hass, DOMAIN, {DOMAIN: CONFIG})
await hass.async_block_till_done()
assert hass.states.get(rtm_entity_id) is None
state = hass.states.get(configure_entity_id)
assert state
assert state.state == "configure"
await hass.services.async_call(
"configurator",
"configure",
{"configure_id": configure_id},
blocking=True,
assert not hass.config_entries.async_entries(DOMAIN)
assert issue_registry.async_get_issue(
DOMAIN, "deprecated_yaml_import_issue_invalid_auth"
)
await hass.async_block_till_done()
assert bool(hass.states.get(rtm_entity_id)) == rtm_entity_exists
state = hass.states.get(configure_entity_id)
assert state
assert state.state == configurator_end_state
@@ -8,51 +8,52 @@ import pytest
from homeassistant.components import remember_the_milk as rtm
from homeassistant.core import HomeAssistant
from .const import JSON_STRING, PROFILE, TOKEN
from .const import JSON_STRING, LEGACY_JSON_STRING, PROFILE
def test_set_get_delete_token(hass: HomeAssistant) -> None:
"""Test set, get and delete token."""
open_mock = mock_open()
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass)
assert open_mock.return_value.write.call_count == 0
assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 0
config.set_token(PROFILE, TOKEN)
assert open_mock.return_value.write.call_count == 1
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {},
"token": "mytoken",
}
}
)
assert config.get_token(PROFILE) == TOKEN
assert open_mock.return_value.write.call_count == 1
config.delete_token(PROFILE)
assert open_mock.return_value.write.call_count == 2
assert open_mock.return_value.write.call_args[0][0] == json.dumps({})
assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 2
@pytest.mark.parametrize(
"json_string",
[JSON_STRING, LEGACY_JSON_STRING],
ids=["new_format", "legacy_format"],
)
def test_config_load(hass: HomeAssistant, json_string: str) -> None:
"""Test loading from the file.
def test_config_load(hass: HomeAssistant) -> None:
"""Test loading from the file."""
The legacy configuration file format stored the ids as strings, so
check that the ids are always returned as integers.
"""
config = rtm.RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING),
mock_open(read_data=json_string),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None
assert rtm_id == ("1", "2", "3")
assert rtm_id == (1, 2, 3)
@pytest.mark.parametrize(
("json_string", "expected_token"),
[(LEGACY_JSON_STRING, "mytoken"), (JSON_STRING, None)],
ids=["legacy_format", "new_format"],
)
def test_get_token(
hass: HomeAssistant, json_string: str, expected_token: str | None
) -> None:
"""Test getting the stored token for a profile."""
config = rtm.RememberTheMilkConfiguration(hass)
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=json_string),
):
config.setup()
assert config.get_token(PROFILE) == expected_token
assert config.get_token("unknown-profile") is None
@pytest.mark.parametrize(
@@ -67,7 +68,7 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) ->
side_effect=side_effect,
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@@ -84,7 +85,7 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
mock_open(read_data="random characters"),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
@@ -95,15 +96,15 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
def test_config_set_delete_id(hass: HomeAssistant) -> None:
"""Test setting and deleting an id from the config."""
hass_id = "123"
list_id = "1"
timeseries_id = "2"
rtm_id = "3"
list_id = 1
timeseries_id = 2
rtm_id = 3
open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass)
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
config = rtm.RememberTheMilkConfiguration(hass)
config.setup()
assert open_mock.return_value.write.call_count == 0
assert config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 0
@@ -114,7 +115,11 @@ def test_config_set_delete_id(hass: HomeAssistant) -> None:
{
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
"123": {
"list_id": "1",
"timeseries_id": "2",
"task_id": "3",
}
}
}
}