mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add config flow to remember_the_milk (#178808)
This commit is contained in:
@@ -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.",
|
||||
|
||||
Generated
+1
@@ -650,6 +650,7 @@ FLOWS = {
|
||||
"redgtech",
|
||||
"refoss",
|
||||
"rehlko",
|
||||
"remember_the_milk",
|
||||
"remote_calendar",
|
||||
"renault",
|
||||
"renson",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+3
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user