diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index fc192bd538a..bf32d9d92b6 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -1,26 +1,29 @@ -"""Support to interact with Remember The Milk.""" +"""The Remember The Milk integration.""" -from rtmapi import Rtm +from __future__ import annotations + +from aiortm import AioRTMClient, Auth, AuthError import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME +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 HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed 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.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,104 +45,106 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema( SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) +DATA_COMPONENT = "component" +DATA_ENTITY_ID = "entity_id" +DATA_STORAGE = "storage" -def setup(hass: HomeAssistant, config: ConfigType) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Remember the milk component.""" - component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) - - stored_rtm_config = RememberTheMilkConfiguration(hass) + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_COMPONENT] = EntityComponent[RememberTheMilkEntity]( + LOGGER, DOMAIN, hass + ) + storage = hass.data[DOMAIN][DATA_STORAGE] = RememberTheMilkConfiguration(hass) + await hass.async_add_executor_job(storage.setup) + if DOMAIN not in config: + return True 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, + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=rtm_config, ) - else: - _register_new_account( - hass, account_name, api_key, shared_secret, stored_rtm_config, component - ) - - LOGGER.debug("Finished adding all Remember the milk accounts") + ) return True -def _create_instance( - hass, account_name, api_key, shared_secret, token, stored_rtm_config, component -): - entity = RememberTheMilkEntity( - account_name, api_key, shared_secret, token, stored_rtm_config +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Remember The Milk from a config entry.""" + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + 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 | None = rtm_config[CONF_TOKEN] # None if imported from YAML + client = AioRTMClient( + Auth( + client_session=async_get_clientsession(hass), + api_key=api_key, + shared_secret=shared_secret, + auth_token=token, + permission="delete", + ) ) - component.add_entities([entity]) - hass.services.register( + + token_valid = True + try: + await client.rtm.api.check_token() + except AuthError as err: + token_valid = False + if entry.source == SOURCE_IMPORT: + raise ConfigEntryAuthFailed("Missing token") from err + + if (known_entity_ids := hass.data[DOMAIN].get(DATA_ENTITY_ID)) and ( + entity_id := known_entity_ids.get(account_name) + ): + await component.async_remove_entity(entity_id) + + # 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]) + known_entity_ids = hass.data[DOMAIN].setdefault(DATA_ENTITY_ID, {}) + known_entity_ids[account_name] = 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. + 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, account_name, api_key, shared_secret, stored_rtm_config, component -): - request_id = 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: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[RememberTheMilkEntity] = hass.data[DOMAIN][ + DATA_COMPONENT + ] + entity_id = hass.data[DOMAIN][DATA_ENTITY_ID].pop(entry.data[CONF_USERNAME]) + await component.async_remove_entity(entity_id) + return True diff --git a/homeassistant/components/remember_the_milk/config_flow.py b/homeassistant/components/remember_the_milk/config_flow.py new file mode 100644 index 00000000000..3567eef1388 --- /dev/null +++ b/homeassistant/components/remember_the_milk/config_flow.py @@ -0,0 +1,157 @@ +"""Config flow for Remember The Milk integration.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +from aiortm import Auth, AuthError, ResponseError +import voluptuous as vol + +from homeassistant.config_entries import ( + SOURCE_IMPORT, + SOURCE_REAUTH, + 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 .const import CONF_SHARED_SECRET, DOMAIN, LOGGER + +TOKEN_TIMEOUT_SEC = 30 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_SHARED_SECRET): str, + } +) + + +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_data: dict[str, str] | None = None + + 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_data = user_input + auth = self._auth = Auth( + client_session=async_get_clientsession(self.hass), + api_key=user_input[CONF_API_KEY], + shared_secret=user_input[CONF_SHARED_SECRET], + permission="delete", + ) + try: + self._url, self._frob = await auth.authenticate_desktop() + except AuthError: + errors["base"] = "invalid_auth" + except ResponseError: + 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=STEP_USER_DATA_SCHEMA, 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_data is not None + try: + async with asyncio.timeout(TOKEN_TIMEOUT_SEC): + token = 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 ResponseError: + 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") + + await self.async_set_unique_id(token["user"]["id"]) + data = { + **self._auth_data, + CONF_TOKEN: token["token"], + CONF_USERNAME: token["user"]["username"], + } + if self.source == SOURCE_REAUTH: + reauth_entry = self._get_reauth_entry() + if reauth_entry.source == SOURCE_IMPORT and reauth_entry.unique_id is None: + # Imported entries do not have a token nor unique id. + # Update unique id to match the new token. + # This case can be removed when the import step is removed. + self.hass.config_entries.async_update_entry( + reauth_entry, data=data, unique_id=token["user"]["id"] + ) + else: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates=data, + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=token["user"]["fullname"], + data=data, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({}), + ) + return await self.async_step_user() + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import a config entry. + + The token will be retrieved after config entry setup in a reauth flow. + """ + name = import_info.pop(CONF_NAME) + return self.async_create_entry( + title=name, + data=import_info | {CONF_USERNAME: name, CONF_TOKEN: None}, + ) diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py index 2fccbf3ee52..8109b6aa98e 100644 --- a/homeassistant/components/remember_the_milk/const.py +++ b/homeassistant/components/remember_the_milk/const.py @@ -2,4 +2,6 @@ import logging +CONF_SHARED_SECRET = "shared_secret" +DOMAIN = "remember_the_milk" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py index bf75debe367..da39730c961 100644 --- a/homeassistant/components/remember_the_milk/entity.py +++ b/homeassistant/components/remember_the_milk/entity.py @@ -1,48 +1,35 @@ """Support to interact with Remember The Milk.""" -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 +from .storage import RememberTheMilkConfiguration class RememberTheMilkEntity(Entity): """Representation of an interface to Remember The Milk.""" - def __init__(self, name, api_key, shared_secret, token, rtm_config): + def __init__( + self, + *, + name: str, + 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 = None - 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): - """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, @@ -50,31 +37,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], @@ -87,17 +80,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( ( @@ -109,31 +111,50 @@ 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 - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._name @property - def state(self): + def state(self) -> str: """Return the state of the device.""" 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) + ) diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json index 13c37d56dba..ab6abf65a3f 100644 --- a/homeassistant/components/remember_the_milk/manifest.json +++ b/homeassistant/components/remember_the_milk/manifest.json @@ -2,10 +2,10 @@ "domain": "remember_the_milk", "name": "Remember The Milk", "codeowners": [], - "dependencies": ["configurator"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/remember_the_milk", "iot_class": "cloud_push", - "loggers": ["rtmapi"], + "loggers": ["aiortm"], "quality_scale": "legacy", - "requirements": ["RtmAPI==0.7.2", "httplib2==0.20.4"] + "requirements": ["aiortm==0.9.45"] } diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py index ae51acd963b..f9e34b39ab3 100644 --- a/homeassistant/components/remember_the_milk/storage.py +++ b/homeassistant/components/remember_the_milk/storage.py @@ -1,11 +1,11 @@ -"""Store RTM configuration in Home Assistant storage.""" +"""Provide storage for Remember The Milk integration.""" from __future__ import annotations import json from pathlib import Path +from typing import Any -from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from .const import LOGGER @@ -23,7 +23,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,26 +51,6 @@ class RememberTheMilkConfiguration: json.dumps(self._config), encoding="utf8" ) - def get_token(self, profile_name: str) -> str | None: - """Get the server token for a profile.""" - if profile_name in self._config: - return 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() - def _initialize_profile(self, profile_name: str) -> None: """Initialize the data structures for a profile.""" if profile_name not in self._config: @@ -77,7 +60,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: @@ -93,11 +76,11 @@ class RememberTheMilkConfiguration: 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) id_tuple = { CONF_LIST_ID: list_id, diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index de6ae8a7f04..fa8e78349c8 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -24,5 +24,36 @@ } } } + }, + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "shared_secret": "Shared secret" + } + }, + "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})" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Remember The Milk integration needs to re-authenticate your account" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_token": "Timeout getting access token", + "unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 40af1df86cd..3245ba483c2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -512,6 +512,7 @@ FLOWS = { "rdw", "recollect_waste", "refoss", + "remember_the_milk", "renault", "renson", "reolink", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 2d28d4f46d7..2618acb32b2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5231,7 +5231,7 @@ "remember_the_milk": { "name": "Remember The Milk", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_push" }, "renault": { diff --git a/requirements_all.txt b/requirements_all.txt index 88eeaafd223..adc11899a6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -111,9 +111,6 @@ RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==8.0 -# homeassistant.components.remember_the_milk -RtmAPI==0.7.2 - # homeassistant.components.recorder # homeassistant.components.sql SQLAlchemy==2.0.38 @@ -358,6 +355,9 @@ aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2024.01.0 +# homeassistant.components.remember_the_milk +aiortm==0.9.45 + # homeassistant.components.ruckus_unleashed aioruckus==0.42 @@ -1157,9 +1157,6 @@ homematicip==1.1.7 # homeassistant.components.horizon horimote==0.4.1 -# homeassistant.components.remember_the_milk -httplib2==0.20.4 - # homeassistant.components.huawei_lte huawei-lte-api==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b37274817c5..7e054f00261 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -105,9 +105,6 @@ RachioPy==1.1.0 # homeassistant.components.python_script RestrictedPython==8.0 -# homeassistant.components.remember_the_milk -RtmAPI==0.7.2 - # homeassistant.components.recorder # homeassistant.components.sql SQLAlchemy==2.0.38 @@ -340,6 +337,9 @@ aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2024.01.0 +# homeassistant.components.remember_the_milk +aiortm==0.9.45 + # homeassistant.components.ruckus_unleashed aioruckus==0.42 @@ -983,9 +983,6 @@ home-assistant-intents==2025.2.5 # homeassistant.components.homematicip_cloud homematicip==1.1.7 -# homeassistant.components.remember_the_milk -httplib2==0.20.4 - # homeassistant.components.huawei_lte huawei-lte-api==1.10.0 diff --git a/tests/components/remember_the_milk/conftest.py b/tests/components/remember_the_milk/conftest.py index f7257f35c64..9e000a137f5 100644 --- a/tests/components/remember_the_milk/conftest.py +++ b/tests/components/remember_the_milk/conftest.py @@ -1,29 +1,42 @@ """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, TOKEN_RESPONSE + +from tests.common import MockConfigEntry @pytest.fixture(name="client") def client_fixture() -> Generator[MagicMock]: """Create a mock client.""" - with patch("homeassistant.components.remember_the_milk.entity.Rtm") as client_class: + with patch( + "homeassistant.components.remember_the_milk.AioRTMClient", + ) as client_class: client = client_class.return_value - client.token_valid.return_value = True + 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 @@ -35,6 +48,26 @@ 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 yield storage + + +@pytest.fixture +def config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mock config entry.""" + entry = MockConfigEntry( + data=CREATE_ENTRY_DATA, + domain=DOMAIN, + ) + 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 diff --git a/tests/components/remember_the_milk/const.py b/tests/components/remember_the_milk/const.py index 3f1d0067219..529cd59db7a 100644 --- a/tests/components/remember_the_milk/const.py +++ b/tests/components/remember_the_milk/const.py @@ -3,12 +3,32 @@ import json PROFILE = "myprofile" -TOKEN = "mytoken" +CREATE_ENTRY_DATA = { + "api_key": "test-api-key", + "shared_secret": "test-secret", + "token": "test-token", + "username": PROFILE, +} +TOKEN_RESPONSE = { + "token": "test-token", + "perms": "delete", + "user": {"id": "1234567", "username": "johnsmith", "fullname": "John Smith"}, +} + +# The legacy configuration file format: + +# { +# "myprofile": { +# "token": "mytoken", +# "id_map": {"123": {"list_id": 1, "timeseries_id": 2, "task_id": 3}}, +# } +# } + +# The new configuration file format: JSON_STRING = json.dumps( { "myprofile": { - "token": "mytoken", - "id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}}, + "id_map": {"123": {"list_id": 1, "timeseries_id": 2, "task_id": 3}}, } } ) diff --git a/tests/components/remember_the_milk/test_config_flow.py b/tests/components/remember_the_milk/test_config_flow.py new file mode 100644 index 00000000000..9bb532a7f79 --- /dev/null +++ b/tests/components/remember_the_milk/test_config_flow.py @@ -0,0 +1,346 @@ +"""Test the Remember The Milk config flow.""" + +import asyncio +from collections.abc import Awaitable +from copy import deepcopy +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.remember_the_milk.config_flow import ( + TOKEN_TIMEOUT_SEC, + AuthError, + ResponseError, +) +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 + +from tests.common import MockConfigEntry + +TOKEN_DATA = { + "token": "test-token", + "user": { + "fullname": PROFILE, + "id": "test-user-id", + "username": PROFILE, + }, +} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_successful_flow( + hass: HomeAssistant, 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"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + 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", + return_value=TOKEN_DATA, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == PROFILE + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == "test-user-id" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthError, "invalid_auth"), + (ResponseError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + 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, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + 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", + return_value=TOKEN_DATA, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == PROFILE + assert result["data"] == CREATE_ENTRY_DATA + assert result["result"].unique_id == "test-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), + (ResponseError, "cannot_connect", TOKEN_TIMEOUT_SEC), + (Exception, "unknown", TOKEN_TIMEOUT_SEC), + (mock_get_token, "timeout_token", 0), + ], +) +async def test_token_abort_reasons( + hass: HomeAssistant, + 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} + ) + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + 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"] == FlowResultType.ABORT + assert result["reason"] == reason + + +async def test_abort_if_already_configured(hass: HomeAssistant) -> None: + """Test abort if the same username is already configured.""" + mock_entry = MockConfigEntry(domain=DOMAIN, unique_id="test-user-id") + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + 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", + return_value=TOKEN_DATA, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "source", [config_entries.SOURCE_IMPORT, config_entries.SOURCE_USER] +) +@pytest.mark.parametrize( + ("reauth_unique_id", "abort_reason", "abort_entry_data"), + [ + ( + "test-user-id", + "reauth_successful", + CREATE_ENTRY_DATA | {"token": "new-test-token"}, + ), + ("other-user-id", "unique_id_mismatch", CREATE_ENTRY_DATA), + ], +) +async def test_reauth( + hass: HomeAssistant, + source: str, + reauth_unique_id: str, + abort_reason: str, + abort_entry_data: dict[str, str], +) -> None: + """Test reauth flow.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, unique_id="test-user-id", data=CREATE_ENTRY_DATA, source=source + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "test-api-key", + "shared_secret": "test-secret", + }, + ) + reauth_data: dict[str, Any] = deepcopy(TOKEN_DATA) | {"token": "new-test-token"} + reauth_data["user"]["id"] = reauth_unique_id + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.get_token", + return_value=reauth_data, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == abort_reason + assert mock_entry.data == abort_entry_data + assert mock_entry.unique_id == "test-user-id" + + +async def test_import_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test import flow.""" + 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", + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "test-name" + assert result["data"] == { + "api_key": "test-api-key", + "shared_secret": "test-secret", + "token": None, + "username": "test-name", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reauth_imported_entry(hass: HomeAssistant) -> None: + """Test reauth flow for an imported entry.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + "api_key": "test-api-key", + "shared_secret": "test-secret", + "token": None, + "username": "test-name", + }, + source=config_entries.SOURCE_IMPORT, + ) + mock_entry.add_to_hass(hass) + + result = await mock_entry.start_reauth_flow(hass) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.remember_the_milk.config_flow.Auth.authenticate_desktop", + return_value=("https://test-url.com", "test-frob"), + ): + 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", + return_value=TOKEN_DATA, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_entry.data == CREATE_ENTRY_DATA + assert mock_entry.unique_id == "test-user-id" diff --git a/tests/components/remember_the_milk/test_entity.py b/tests/components/remember_the_milk/test_entity.py index e9d7a16d7ab..3cb663381f1 100644 --- a/tests/components/remember_the_milk/test_entity.py +++ b/tests/components/remember_the_milk/test_entity.py @@ -3,8 +3,8 @@ 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.core import HomeAssistant @@ -12,6 +12,8 @@ from homeassistant.setup import async_setup_component from .const import PROFILE +from tests.common import MockConfigEntry + CONFIG = { "name": f"{PROFILE}", "api_key": "test-api-key", @@ -19,19 +21,21 @@ CONFIG = { } +@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) @@ -56,7 +60,7 @@ async def test_entity_state( ), [ ( - ("1", "2", "3"), + (1, 2, 3), f"{PROFILE}_create_task", {"name": "Test 1"}, 0, @@ -65,9 +69,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, @@ -83,36 +87,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, @@ -121,10 +125,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, @@ -179,52 +183,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, @@ -238,20 +242,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!", ), ], ) diff --git a/tests/components/remember_the_milk/test_storage.py b/tests/components/remember_the_milk/test_storage.py index 6ae774a3d0d..0dc19fef172 100644 --- a/tests/components/remember_the_milk/test_storage.py +++ b/tests/components/remember_the_milk/test_storage.py @@ -8,51 +8,23 @@ import pytest from homeassistant.components import remember_the_milk as rtm from homeassistant.core import HomeAssistant -from .const import JSON_STRING, PROFILE, TOKEN - - -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 +from .const import JSON_STRING, PROFILE def test_config_load(hass: HomeAssistant) -> None: """Test loading from the file.""" + config = rtm.RememberTheMilkConfiguration(hass) with ( patch( "homeassistant.components.remember_the_milk.storage.Path.open", 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( @@ -67,7 +39,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 +56,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 +67,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 +86,7 @@ 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} } } }