Compare commits

...

2 Commits

Author SHA1 Message Date
Martin Hjelmare
cbc1899990 Use builtin store for remember the milk config 2025-02-22 16:48:39 +01:00
Martin Hjelmare
d2a660714f Move rememeber the milk config storage to own module 2025-02-21 17:53:26 +01:00
8 changed files with 477 additions and 328 deletions

View File

@@ -1,33 +1,23 @@
"""Support to interact with Remember The Milk."""
import json
import logging
from pathlib import Path
from rtmapi import Rtm
import voluptuous as vol
from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
from .const import 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.
_LOGGER = logging.getLogger(__name__)
DOMAIN = "remember_the_milk"
DEFAULT_NAME = DOMAIN
CONF_SHARED_SECRET = "shared_secret"
CONF_ID_MAP = "id_map"
CONF_LIST_ID = "list_id"
CONF_TIMESERIES_ID = "timeseries_id"
CONF_TASK_ID = "task_id"
RTM_SCHEMA = vol.Schema(
{
@@ -41,7 +31,6 @@ CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA
)
CONFIG_FILE_NAME = ".remember_the_milk.conf"
SERVICE_CREATE_TASK = "create_task"
SERVICE_COMPLETE_TASK = "complete_task"
@@ -52,20 +41,21 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
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)
component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass)
await stored_rtm_config.setup()
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
_LOGGER.debug("Adding Remember the milk account %s", account_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(
LOGGER.debug("found token for account %s", account_name)
await _create_instance(
hass,
account_name,
api_key,
@@ -75,28 +65,36 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
component,
)
else:
_register_new_account(
await _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
_LOGGER.debug("Finished adding all Remember the milk accounts")
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
):
async def _create_instance(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
token: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
)
component.add_entities([entity])
hass.services.register(
LOGGER.debug("Instance created for account %s", entity.name)
await entity.check_token(hass)
await component.async_add_entities([entity])
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,
@@ -104,29 +102,40 @@ def _create_instance(
)
def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component
):
request_id = None
async def _register_new_account(
hass: HomeAssistant,
account_name: str,
api_key: str,
shared_secret: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
"""Register a new account."""
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
_LOGGER.debug("Sent authentication request to server")
url, frob = await hass.async_add_executor_job(api.authenticate_desktop)
LOGGER.debug("Sent authentication request to server")
@callback
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.async_create_task(handle_token(api, frob))
async def handle_token(api: Rtm, frob: str) -> None:
"""Handle token."""
await hass.async_add_executor_job(api.retrieve_token, frob)
token: str | None = api.token
if token is None:
LOGGER.error("Failed to register, please try again")
configurator.async_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")
LOGGER.debug("Retrieved new token from server")
_create_instance(
await _create_instance(
hass,
account_name,
api_key,
@@ -136,9 +145,9 @@ def _register_new_account(
component,
)
configurator.request_done(hass, request_id)
configurator.async_request_done(hass, request_id)
request_id = configurator.request_config(
request_id = configurator.async_request_config(
hass,
f"{DOMAIN} - {account_name}",
callback=register_account_callback,
@@ -152,104 +161,3 @@ def _register_new_account(
link_url=url,
submit_caption="login completed",
)
class RememberTheMilkConfiguration:
"""Internal configuration data for RememberTheMilk class.
This class stores the authentication token it get from the backend.
"""
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
self._config = {}
_LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
try:
self._config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8")
)
except FileNotFoundError:
_LOGGER.debug("Missing configuration file: %s", self._config_file_path)
except OSError:
_LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration",
self._config_file_path,
)
except ValueError:
_LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration",
self._config_file_path,
)
def _save_config(self) -> None:
"""Write the configuration to a file."""
Path(self._config_file_path).write_text(
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:
self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
list id, timeseries id and the task id.
"""
self._initialize_profile(profile_name)
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]
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task IS."""
self._initialize_profile(profile_name)
id_tuple = {
CONF_LIST_ID: list_id,
CONF_TIMESERIES_ID: time_series_id,
CONF_TASK_ID: rtm_task_id,
}
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
self._save_config()
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]:
del self._config[profile_name][CONF_ID_MAP][hass_id]
self._save_config()

View File

@@ -0,0 +1,6 @@
"""Constants for the Remember The Milk integration."""
import logging
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__)

View File

@@ -1,20 +1,30 @@
"""Support to interact with Remember The Milk."""
import logging
from __future__ import annotations
from typing import Any
from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers.entity import Entity
_LOGGER = logging.getLogger(__name__)
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,
api_key: str,
shared_secret: str,
token: str,
rtm_config: RememberTheMilkConfiguration,
) -> None:
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
@@ -22,29 +32,27 @@ class RememberTheMilkEntity(Entity):
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._token_valid = False
def _check_token(self):
async def check_token(self, hass: HomeAssistant) -> None:
"""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:
valid = await hass.async_add_executor_job(self._rtm_api.token_valid)
if valid:
self._token_valid = True
return self._token_valid
return
def create_task(self, call: ServiceCall) -> None:
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
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,
@@ -57,14 +65,13 @@ class RememberTheMilkEntity(Entity):
rtm_id = 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
if rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
result = await self.hass.async_add_executor_job(
self._add_task,
task_name,
)
_LOGGER.debug(
LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
if hass_id is not None:
@@ -76,32 +83,52 @@ class RememberTheMilkEntity(Entity):
result.list.taskseries.task.id,
)
else:
self._rtm_api.rtm.tasks.setName(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
await self.hass.async_add_executor_job(
self._rename_task,
rtm_id,
task_name,
)
_LOGGER.debug(
LOGGER.debug(
"Updated task with id '%s' in account %s to name %s",
hass_id,
self.name,
task_name,
)
except RtmRequestFailedException as rtm_exception:
_LOGGER.error(
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
)
def complete_task(self, call: ServiceCall) -> None:
def _add_task(self, task_name: str) -> Any:
"""Add a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
return self._rtm_api.rtm.tasks.add(
timeline=timeline,
name=task_name,
parse="1",
)
def _rename_task(self, rtm_id: tuple[str, str, str], task_name: str) -> None:
"""Rename a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.setName(
name=task_name,
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
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)
if rtm_id is None:
_LOGGER.error(
LOGGER.error(
(
"Could not find task with ID %s in account %s. "
"So task could not be closed"
@@ -111,32 +138,36 @@ class RememberTheMilkEntity(Entity):
)
return
try:
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.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)
_LOGGER.debug(
"Completed task with id %s in account %s", hass_id, self._name
)
await self.hass.async_add_executor_job(self._complete_task, rtm_id)
except RtmRequestFailedException as rtm_exception:
_LOGGER.error(
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
)
return
self._rtm_config.delete_rtm_id(self._name, hass_id)
LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
def _complete_task(self, rtm_id: tuple[str, str, str]) -> None:
"""Complete a task."""
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete(
list_id=rtm_id[0],
taskseries_id=rtm_id[1],
task_id=rtm_id[2],
timeline=timeline,
)
@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"

View File

@@ -0,0 +1,165 @@
"""Store RTM configuration in Home Assistant storage."""
from __future__ import annotations
import json
from pathlib import Path
from typing import TypedDict
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from .const import DOMAIN, LOGGER
LEGACY_CONFIG_FILE_NAME = ".remember_the_milk.conf"
STORE_DELAY_SAVE = 30
class StoredUserConfig(TypedDict, total=False):
"""Represent the stored config for a username."""
id_map: dict[str, TaskIds]
token: str
class TaskIds(TypedDict):
"""Represent the stored ids of a task."""
list_id: str
timeseries_id: str
task_id: str
class RememberTheMilkConfiguration:
"""Internal configuration data for Remember The Milk."""
def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration."""
self._legacy_config_path = hass.config.path(LEGACY_CONFIG_FILE_NAME)
self._config: dict[str, StoredUserConfig] = {}
self._hass = hass
self._store = Store[dict[str, StoredUserConfig]](hass, 1, DOMAIN)
async def setup(self) -> None:
"""Set up the configuration."""
if not (config := await self._hass.async_add_executor_job(self._load_legacy)):
config = await self._load()
self._config = config or {}
def _load_legacy(self) -> dict[str, StoredUserConfig] | None:
"""Load configuration from legacy storage."""
# Do not load from legacy if the new store exists.
if Path(self._store.path).exists():
return None
LOGGER.debug(
"Loading legacy configuration from file: %s", self._legacy_config_path
)
config: dict[str, StoredUserConfig] | None = None
try:
config = json.loads(
Path(self._legacy_config_path).read_text(encoding="utf8")
)
except FileNotFoundError:
LOGGER.debug(
"Missing legacy configuration file: %s", self._legacy_config_path
)
except OSError:
LOGGER.debug(
"Failed to read from legacy configuration file, %s, using empty configuration",
self._legacy_config_path,
)
except ValueError:
LOGGER.error(
"Failed to parse legacy configuration file, %s, using empty configuration",
self._legacy_config_path,
)
return config
async def _load(self) -> dict[str, StoredUserConfig] | None:
"""Load the store."""
return await self._store.async_load()
@callback
def _save_config(self) -> None:
"""Save config."""
self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
@callback
def _data_to_save(self) -> dict[str, StoredUserConfig]:
"""Return data to save."""
return self._config
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
@callback
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()
@callback
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:
self._config[profile_name] = {}
if "id_map" not in self._config[profile_name]:
self._config[profile_name]["id_map"] = {}
def get_rtm_id(
self, profile_name: str, hass_id: str
) -> tuple[str, str, str] | None:
"""Get the RTM ids for a Home Assistant task ID.
The id of a RTM tasks consists of the tuple:
list id, timeseries id and the task id.
"""
self._initialize_profile(profile_name)
task_ids = self._config[profile_name]["id_map"].get(hass_id)
if task_ids is None:
return None
return task_ids["list_id"], task_ids["timeseries_id"], task_ids["task_id"]
@callback
def set_rtm_id(
self,
profile_name: str,
hass_id: str,
list_id: str,
time_series_id: str,
rtm_task_id: str,
) -> None:
"""Add/Update the RTM task ID for a Home Assistant task ID."""
self._initialize_profile(profile_name)
ids = TaskIds(
list_id=list_id,
timeseries_id=time_series_id,
task_id=rtm_task_id,
)
self._config[profile_name]["id_map"][hass_id] = ids
self._save_config()
@callback
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping."""
self._initialize_profile(profile_name)
if hass_id in self._config[profile_name]["id_map"]:
del self._config[profile_name]["id_map"][hass_id]
self._save_config()

View File

@@ -32,7 +32,8 @@ def client_fixture() -> Generator[MagicMock]:
async def storage(hass: HomeAssistant, client) -> AsyncGenerator[MagicMock]:
"""Mock the config storage."""
with patch(
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration"
"homeassistant.components.remember_the_milk.RememberTheMilkConfiguration",
autospec=True,
) as storage_class:
storage = storage_class.return_value
storage.get_token.return_value = TOKEN

View File

@@ -4,11 +4,10 @@ import json
PROFILE = "myprofile"
TOKEN = "mytoken"
JSON_STRING = json.dumps(
{
"myprofile": {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
STORED_DATA = {
"myprofile": {
"token": "mytoken",
"id_map": {"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}},
}
)
}
JSON_STRING = json.dumps(STORED_DATA)

View File

@@ -1,127 +0,0 @@
"""Tests for the Remember The Milk integration."""
import json
from unittest.mock import mock_open, patch
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.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
def test_config_load(hass: HomeAssistant) -> None:
"""Test loading from the file."""
with (
patch(
"homeassistant.components.remember_the_milk.Path.open",
mock_open(read_data=JSON_STRING),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None
assert rtm_id == ("1", "2", "3")
@pytest.mark.parametrize(
"side_effect", [FileNotFoundError("Missing file"), OSError("IO error")]
)
def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None:
"""Test loading with file error."""
config = rtm.RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.Path.open",
side_effect=side_effect,
),
):
config = rtm.RememberTheMilkConfiguration(hass)
# The config should be empty and we should not have any errors
# when trying to access it.
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is None
def test_config_load_invalid_data(hass: HomeAssistant) -> None:
"""Test loading invalid data."""
config = rtm.RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.Path.open",
mock_open(read_data="random characters"),
),
):
config = rtm.RememberTheMilkConfiguration(hass)
# The config should be empty and we should not have any errors
# when trying to access it.
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is 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"
open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass)
with patch("homeassistant.components.remember_the_milk.Path.open", open_mock):
config = rtm.RememberTheMilkConfiguration(hass)
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
config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id)
assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id)
assert open_mock.return_value.write.call_count == 1
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
}
}
}
)
config.delete_rtm_id(PROFILE, hass_id)
assert config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 2
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": {
"id_map": {},
}
}
)

View File

@@ -0,0 +1,166 @@
"""Tests for the Remember The Milk integration."""
from collections.abc import Generator
from typing import Any
from unittest.mock import mock_open, patch
import pytest
from homeassistant.components.remember_the_milk import (
DOMAIN,
RememberTheMilkConfiguration,
)
from homeassistant.core import HomeAssistant
from .const import JSON_STRING, PROFILE, STORED_DATA, TOKEN
from tests.common import async_fire_time_changed
pytestmark = pytest.mark.parametrize("new_storage_exists", [True, False])
@pytest.fixture(autouse=True)
def mock_path_exists(new_storage_exists: bool) -> Generator[None]:
"""Mock path exists."""
with patch(
"homeassistant.components.remember_the_milk.storage.Path.exists",
return_value=new_storage_exists,
):
yield
@pytest.fixture(autouse=True)
def mock_delay_save() -> Generator[None]:
"""Mock delay save."""
with patch(
"homeassistant.components.remember_the_milk.storage.STORE_DELAY_SAVE", 0
):
yield
async def test_set_get_delete_token(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Test set, get and delete token."""
open_mock = mock_open()
config = RememberTheMilkConfiguration(hass)
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
await config.setup()
assert config.get_token(PROFILE) is None
config.set_token(PROFILE, TOKEN)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass_storage[DOMAIN]["data"] == {
"myprofile": {
"id_map": {},
"token": "mytoken",
}
}
assert config.get_token(PROFILE) == TOKEN
config.delete_token(PROFILE)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert hass_storage[DOMAIN]["data"] == {}
assert config.get_token(PROFILE) is None
async def test_config_load(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None:
"""Test loading from the file."""
hass_storage[DOMAIN] = {
"data": STORED_DATA,
"key": DOMAIN,
"version": 1,
"minor_version": 1,
}
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING),
),
):
await config.setup()
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None
assert rtm_id == ("1", "2", "3")
@pytest.mark.parametrize(
"side_effect", [FileNotFoundError("Missing file"), OSError("IO error")]
)
async def test_config_load_file_error(
hass: HomeAssistant, side_effect: Exception
) -> None:
"""Test loading with file error."""
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
side_effect=side_effect,
),
):
await config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is None
async def test_config_load_invalid_data(hass: HomeAssistant) -> None:
"""Test loading invalid data."""
config = RememberTheMilkConfiguration(hass)
with (
patch(
"homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data="random characters"),
),
):
await config.setup()
# The config should be empty and we should not have any errors
# when trying to access it.
rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is None
async def test_config_set_delete_id(
hass: HomeAssistant,
hass_storage: dict[str, Any],
) -> None:
"""Test setting and deleting an id from the config."""
hass_id = "123"
list_id = "1"
timeseries_id = "2"
rtm_id = "3"
open_mock = mock_open()
config = RememberTheMilkConfiguration(hass)
with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock
):
await config.setup()
assert config.get_rtm_id(PROFILE, hass_id) is None
config.set_rtm_id(PROFILE, hass_id, list_id, timeseries_id, rtm_id)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id)
assert hass_storage[DOMAIN]["data"] == {
"myprofile": {
"id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
}
}
}
config.delete_rtm_id(PROFILE, hass_id)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert config.get_rtm_id(PROFILE, hass_id) is None
assert hass_storage[DOMAIN]["data"] == {
"myprofile": {
"id_map": {},
}
}