Use builtin store for remember the milk config

This commit is contained in:
Martin Hjelmare 2025-02-22 16:48:39 +01:00
parent d2a660714f
commit cbc1899990
7 changed files with 294 additions and 155 deletions

View File

@ -5,20 +5,18 @@ import voluptuous as vol
from homeassistant.components import configurator from homeassistant.components import configurator
from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import LOGGER from .const import DOMAIN, LOGGER
from .entity import RememberTheMilkEntity from .entity import RememberTheMilkEntity
from .storage import RememberTheMilkConfiguration from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not # httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work. # set explicitly, the library does not work.
DOMAIN = "remember_the_milk"
CONF_SHARED_SECRET = "shared_secret" CONF_SHARED_SECRET = "shared_secret"
RTM_SCHEMA = vol.Schema( RTM_SCHEMA = vol.Schema(
@ -43,11 +41,12 @@ SERVICE_SCHEMA_CREATE_TASK = vol.Schema(
SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string}) 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.""" """Set up the Remember the milk component."""
component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass) component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass) stored_rtm_config = RememberTheMilkConfiguration(hass)
await stored_rtm_config.setup()
for rtm_config in config[DOMAIN]: for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME] 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)
@ -56,7 +55,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
token = stored_rtm_config.get_token(account_name) token = stored_rtm_config.get_token(account_name)
if token: if token:
LOGGER.debug("found token for account %s", account_name) LOGGER.debug("found token for account %s", account_name)
_create_instance( await _create_instance(
hass, hass,
account_name, account_name,
api_key, api_key,
@ -66,7 +65,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
component, component,
) )
else: else:
_register_new_account( await _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component hass, account_name, api_key, shared_secret, stored_rtm_config, component
) )
@ -74,20 +73,28 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True return True
def _create_instance( async def _create_instance(
hass, account_name, api_key, shared_secret, token, stored_rtm_config, component hass: HomeAssistant,
): account_name: str,
api_key: str,
shared_secret: str,
token: str,
stored_rtm_config: RememberTheMilkConfiguration,
component: EntityComponent[RememberTheMilkEntity],
) -> None:
entity = RememberTheMilkEntity( entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config account_name, api_key, shared_secret, token, stored_rtm_config
) )
component.add_entities([entity]) LOGGER.debug("Instance created for account %s", entity.name)
hass.services.register( await entity.check_token(hass)
await component.async_add_entities([entity])
hass.services.async_register(
DOMAIN, DOMAIN,
f"{account_name}_create_task", f"{account_name}_create_task",
entity.create_task, entity.create_task,
schema=SERVICE_SCHEMA_CREATE_TASK, schema=SERVICE_SCHEMA_CREATE_TASK,
) )
hass.services.register( hass.services.async_register(
DOMAIN, DOMAIN,
f"{account_name}_complete_task", f"{account_name}_complete_task",
entity.complete_task, entity.complete_task,
@ -95,21 +102,32 @@ def _create_instance(
) )
def _register_new_account( async def _register_new_account(
hass, account_name, api_key, shared_secret, stored_rtm_config, component hass: HomeAssistant,
): account_name: str,
request_id = None 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) api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop() url, frob = await hass.async_add_executor_job(api.authenticate_desktop)
LOGGER.debug("Sent authentication request to server") LOGGER.debug("Sent authentication request to server")
@callback
def register_account_callback(fields: list[dict[str, str]]) -> None: def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator.""" """Call for register the configurator."""
api.retrieve_token(frob) hass.async_create_task(handle_token(api, frob))
token = api.token
if api.token is None: 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") LOGGER.error("Failed to register, please try again")
configurator.notify_errors( configurator.async_notify_errors(
hass, request_id, "Failed to register, please try again." hass, request_id, "Failed to register, please try again."
) )
return return
@ -117,7 +135,7 @@ def _register_new_account(
stored_rtm_config.set_token(account_name, token) 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, hass,
account_name, account_name,
api_key, api_key,
@ -127,9 +145,9 @@ def _register_new_account(
component, 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, hass,
f"{DOMAIN} - {account_name}", f"{DOMAIN} - {account_name}",
callback=register_account_callback, callback=register_account_callback,

View File

@ -2,4 +2,5 @@
import logging import logging
DOMAIN = "remember_the_milk"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)

View File

@ -1,18 +1,30 @@
"""Support to interact with Remember The Milk.""" """Support to interact with Remember The Milk."""
from __future__ import annotations
from typing import Any
from rtmapi import Rtm, RtmRequestFailedException from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK 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 from homeassistant.helpers.entity import Entity
from .const import LOGGER from .const import LOGGER
from .storage import RememberTheMilkConfiguration
class RememberTheMilkEntity(Entity): class RememberTheMilkEntity(Entity):
"""Representation of an interface to Remember The Milk.""" """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.""" """Create new instance of Remember The Milk component."""
self._name = name self._name = name
self._api_key = api_key self._api_key = api_key
@ -20,29 +32,27 @@ class RememberTheMilkEntity(Entity):
self._token = token self._token = token
self._rtm_config = rtm_config self._rtm_config = rtm_config
self._rtm_api = Rtm(api_key, shared_secret, "delete", token) self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
self._token_valid = None self._token_valid = False
self._check_token()
LOGGER.debug("Instance created for account %s", self._name)
def _check_token(self): async def check_token(self, hass: HomeAssistant) -> None:
"""Check if the API token is still valid. """Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This If it is not valid any more, delete it from the configuration. This
will trigger a new authentication process. will trigger a new authentication process.
""" """
valid = self._rtm_api.token_valid() valid = await hass.async_add_executor_job(self._rtm_api.token_valid)
if not valid: if valid:
self._token_valid = True
return
LOGGER.error( LOGGER.error(
"Token for account %s is invalid. You need to register again!", "Token for account %s is invalid. You need to register again!",
self.name, self.name,
) )
self._rtm_config.delete_token(self._name) self._rtm_config.delete_token(self._name)
self._token_valid = False 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. """Create a new task on Remember The Milk.
You can use the smart syntax to define the attributes of a new task, You can use the smart syntax to define the attributes of a new task,
@ -55,12 +65,11 @@ class RememberTheMilkEntity(Entity):
rtm_id = None rtm_id = None
if hass_id is not None: if hass_id is not None:
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) 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: if rtm_id is None:
result = self._rtm_api.rtm.tasks.add( result = await self.hass.async_add_executor_job(
timeline=timeline, name=task_name, parse="1" self._add_task,
task_name,
) )
LOGGER.debug( LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name "Created new task '%s' in account %s", task_name, self.name
@ -74,12 +83,10 @@ class RememberTheMilkEntity(Entity):
result.list.taskseries.task.id, result.list.taskseries.task.id,
) )
else: else:
self._rtm_api.rtm.tasks.setName( await self.hass.async_add_executor_job(
name=task_name, self._rename_task,
list_id=rtm_id[0], rtm_id,
taskseries_id=rtm_id[1], task_name,
task_id=rtm_id[2],
timeline=timeline,
) )
LOGGER.debug( LOGGER.debug(
"Updated task with id '%s' in account %s to name %s", "Updated task with id '%s' in account %s to name %s",
@ -94,7 +101,29 @@ class RememberTheMilkEntity(Entity):
rtm_exception, 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.""" """Complete a task that was previously created by this component."""
hass_id = call.data[CONF_ID] hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id) rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
@ -109,6 +138,20 @@ class RememberTheMilkEntity(Entity):
) )
return return
try: try:
await self.hass.async_add_executor_job(self._complete_task, rtm_id)
except RtmRequestFailedException as rtm_exception:
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() result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value timeline = result.timeline.value
self._rtm_api.rtm.tasks.complete( self._rtm_api.rtm.tasks.complete(
@ -117,22 +160,14 @@ class RememberTheMilkEntity(Entity):
task_id=rtm_id[2], task_id=rtm_id[2],
timeline=timeline, 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)
except RtmRequestFailedException as rtm_exception:
LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
)
@property @property
def name(self): def name(self) -> str:
"""Return the name of the device.""" """Return the name of the device."""
return self._name return self._name
@property @property
def state(self): def state(self) -> str:
"""Return the state of the device.""" """Return the state of the device."""
if not self._token_valid: if not self._token_valid:
return "API token invalid" return "API token invalid"

View File

@ -4,17 +4,31 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from typing import TypedDict
from homeassistant.const import CONF_TOKEN from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from .const import LOGGER from .const import DOMAIN, LOGGER
CONFIG_FILE_NAME = ".remember_the_milk.conf" LEGACY_CONFIG_FILE_NAME = ".remember_the_milk.conf"
CONF_ID_MAP = "id_map" STORE_DELAY_SAVE = 30
CONF_LIST_ID = "list_id"
CONF_TASK_ID = "task_id"
CONF_TIMESERIES_ID = "timeseries_id" 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: class RememberTheMilkConfiguration:
@ -22,31 +36,63 @@ class RememberTheMilkConfiguration:
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Create new instance of configuration.""" """Create new instance of configuration."""
self._config_file_path = hass.config.path(CONFIG_FILE_NAME) self._legacy_config_path = hass.config.path(LEGACY_CONFIG_FILE_NAME)
self._config = {} self._config: dict[str, StoredUserConfig] = {}
LOGGER.debug("Loading configuration from file: %s", self._config_file_path) 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: try:
self._config = json.loads( config = json.loads(
Path(self._config_file_path).read_text(encoding="utf8") Path(self._legacy_config_path).read_text(encoding="utf8")
) )
except FileNotFoundError: except FileNotFoundError:
LOGGER.debug("Missing configuration file: %s", self._config_file_path) LOGGER.debug(
"Missing legacy configuration file: %s", self._legacy_config_path
)
except OSError: except OSError:
LOGGER.debug( LOGGER.debug(
"Failed to read from configuration file, %s, using empty configuration", "Failed to read from legacy configuration file, %s, using empty configuration",
self._config_file_path, self._legacy_config_path,
) )
except ValueError: except ValueError:
LOGGER.error( LOGGER.error(
"Failed to parse configuration file, %s, using empty configuration", "Failed to parse legacy configuration file, %s, using empty configuration",
self._config_file_path, 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: def _save_config(self) -> None:
"""Write the configuration to a file.""" """Save config."""
Path(self._config_file_path).write_text( self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
json.dumps(self._config), encoding="utf8"
) @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: def get_token(self, profile_name: str) -> str | None:
"""Get the server token for a profile.""" """Get the server token for a profile."""
@ -54,12 +100,14 @@ class RememberTheMilkConfiguration:
return self._config[profile_name][CONF_TOKEN] return self._config[profile_name][CONF_TOKEN]
return None return None
@callback
def set_token(self, profile_name: str, token: str) -> None: def set_token(self, profile_name: str, token: str) -> None:
"""Store a new server token for a profile.""" """Store a new server token for a profile."""
self._initialize_profile(profile_name) self._initialize_profile(profile_name)
self._config[profile_name][CONF_TOKEN] = token self._config[profile_name][CONF_TOKEN] = token
self._save_config() self._save_config()
@callback
def delete_token(self, profile_name: str) -> None: def delete_token(self, profile_name: str) -> None:
"""Delete a token for a profile. """Delete a token for a profile.
@ -72,8 +120,8 @@ class RememberTheMilkConfiguration:
"""Initialize the data structures for a profile.""" """Initialize the data structures for a profile."""
if profile_name not in self._config: if profile_name not in self._config:
self._config[profile_name] = {} self._config[profile_name] = {}
if CONF_ID_MAP not in self._config[profile_name]: if "id_map" not in self._config[profile_name]:
self._config[profile_name][CONF_ID_MAP] = {} self._config[profile_name]["id_map"] = {}
def get_rtm_id( def get_rtm_id(
self, profile_name: str, hass_id: str self, profile_name: str, hass_id: str
@ -84,11 +132,12 @@ class RememberTheMilkConfiguration:
list id, timeseries id and the task id. list id, timeseries id and the task id.
""" """
self._initialize_profile(profile_name) self._initialize_profile(profile_name)
ids = self._config[profile_name][CONF_ID_MAP].get(hass_id) task_ids = self._config[profile_name]["id_map"].get(hass_id)
if ids is None: if task_ids is None:
return None return None
return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID] return task_ids["list_id"], task_ids["timeseries_id"], task_ids["task_id"]
@callback
def set_rtm_id( def set_rtm_id(
self, self,
profile_name: str, profile_name: str,
@ -97,19 +146,20 @@ class RememberTheMilkConfiguration:
time_series_id: str, time_series_id: str,
rtm_task_id: str, rtm_task_id: str,
) -> None: ) -> 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) self._initialize_profile(profile_name)
id_tuple = { ids = TaskIds(
CONF_LIST_ID: list_id, list_id=list_id,
CONF_TIMESERIES_ID: time_series_id, timeseries_id=time_series_id,
CONF_TASK_ID: rtm_task_id, task_id=rtm_task_id,
} )
self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple self._config[profile_name]["id_map"][hass_id] = ids
self._save_config() self._save_config()
@callback
def delete_rtm_id(self, profile_name: str, hass_id: str) -> None: def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
"""Delete a key mapping.""" """Delete a key mapping."""
self._initialize_profile(profile_name) self._initialize_profile(profile_name)
if hass_id in self._config[profile_name][CONF_ID_MAP]: if hass_id in self._config[profile_name]["id_map"]:
del self._config[profile_name][CONF_ID_MAP][hass_id] del self._config[profile_name]["id_map"][hass_id]
self._save_config() self._save_config()

View File

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

View File

@ -4,11 +4,10 @@ import json
PROFILE = "myprofile" PROFILE = "myprofile"
TOKEN = "mytoken" TOKEN = "mytoken"
JSON_STRING = json.dumps( STORED_DATA = {
{
"myprofile": { "myprofile": {
"token": "mytoken", "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"}},
} }
} }
) JSON_STRING = json.dumps(STORED_DATA)

View File

@ -1,54 +1,88 @@
"""Tests for the Remember The Milk integration.""" """Tests for the Remember The Milk integration."""
import json from collections.abc import Generator
from typing import Any
from unittest.mock import mock_open, patch from unittest.mock import mock_open, patch
import pytest import pytest
from homeassistant.components import remember_the_milk as rtm from homeassistant.components.remember_the_milk import (
DOMAIN,
RememberTheMilkConfiguration,
)
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import JSON_STRING, PROFILE, TOKEN 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])
def test_set_get_delete_token(hass: HomeAssistant) -> None: @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.""" """Test set, get and delete token."""
open_mock = mock_open() open_mock = mock_open()
config = RememberTheMilkConfiguration(hass)
with patch( with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock "homeassistant.components.remember_the_milk.storage.Path.open", open_mock
): ):
config = rtm.RememberTheMilkConfiguration(hass) await config.setup()
assert open_mock.return_value.write.call_count == 0
assert config.get_token(PROFILE) is None assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 0
config.set_token(PROFILE, TOKEN) config.set_token(PROFILE, TOKEN)
assert open_mock.return_value.write.call_count == 1 async_fire_time_changed(hass)
assert open_mock.return_value.write.call_args[0][0] == json.dumps( await hass.async_block_till_done()
{ assert hass_storage[DOMAIN]["data"] == {
"myprofile": { "myprofile": {
"id_map": {}, "id_map": {},
"token": "mytoken", "token": "mytoken",
} }
} }
)
assert config.get_token(PROFILE) == TOKEN assert config.get_token(PROFILE) == TOKEN
assert open_mock.return_value.write.call_count == 1
config.delete_token(PROFILE) config.delete_token(PROFILE)
assert open_mock.return_value.write.call_count == 2 async_fire_time_changed(hass)
assert open_mock.return_value.write.call_args[0][0] == json.dumps({}) await hass.async_block_till_done()
assert hass_storage[DOMAIN]["data"] == {}
assert config.get_token(PROFILE) is None assert config.get_token(PROFILE) is None
assert open_mock.return_value.write.call_count == 2
def test_config_load(hass: HomeAssistant) -> None: async def test_config_load(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None:
"""Test loading from the file.""" """Test loading from the file."""
hass_storage[DOMAIN] = {
"data": STORED_DATA,
"key": DOMAIN,
"version": 1,
"minor_version": 1,
}
config = RememberTheMilkConfiguration(hass)
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.storage.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data=JSON_STRING), mock_open(read_data=JSON_STRING),
), ),
): ):
config = rtm.RememberTheMilkConfiguration(hass) await config.setup()
rtm_id = config.get_rtm_id(PROFILE, "123") rtm_id = config.get_rtm_id(PROFILE, "123")
assert rtm_id is not None assert rtm_id is not None
@ -58,16 +92,18 @@ def test_config_load(hass: HomeAssistant) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"side_effect", [FileNotFoundError("Missing file"), OSError("IO error")] "side_effect", [FileNotFoundError("Missing file"), OSError("IO error")]
) )
def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) -> None: async def test_config_load_file_error(
hass: HomeAssistant, side_effect: Exception
) -> None:
"""Test loading with file error.""" """Test loading with file error."""
config = rtm.RememberTheMilkConfiguration(hass) config = RememberTheMilkConfiguration(hass)
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.storage.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
side_effect=side_effect, side_effect=side_effect,
), ),
): ):
config = rtm.RememberTheMilkConfiguration(hass) await config.setup()
# The config should be empty and we should not have any errors # The config should be empty and we should not have any errors
# when trying to access it. # when trying to access it.
@ -75,16 +111,16 @@ def test_config_load_file_error(hass: HomeAssistant, side_effect: Exception) ->
assert rtm_id is None assert rtm_id is None
def test_config_load_invalid_data(hass: HomeAssistant) -> None: async def test_config_load_invalid_data(hass: HomeAssistant) -> None:
"""Test loading invalid data.""" """Test loading invalid data."""
config = rtm.RememberTheMilkConfiguration(hass) config = RememberTheMilkConfiguration(hass)
with ( with (
patch( patch(
"homeassistant.components.remember_the_milk.storage.Path.open", "homeassistant.components.remember_the_milk.storage.Path.open",
mock_open(read_data="random characters"), mock_open(read_data="random characters"),
), ),
): ):
config = rtm.RememberTheMilkConfiguration(hass) await config.setup()
# The config should be empty and we should not have any errors # The config should be empty and we should not have any errors
# when trying to access it. # when trying to access it.
@ -92,40 +128,39 @@ def test_config_load_invalid_data(hass: HomeAssistant) -> None:
assert rtm_id is None assert rtm_id is None
def test_config_set_delete_id(hass: HomeAssistant) -> 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.""" """Test setting and deleting an id from the config."""
hass_id = "123" hass_id = "123"
list_id = "1" list_id = "1"
timeseries_id = "2" timeseries_id = "2"
rtm_id = "3" rtm_id = "3"
open_mock = mock_open() open_mock = mock_open()
config = rtm.RememberTheMilkConfiguration(hass) config = RememberTheMilkConfiguration(hass)
with patch( with patch(
"homeassistant.components.remember_the_milk.storage.Path.open", open_mock "homeassistant.components.remember_the_milk.storage.Path.open", open_mock
): ):
config = rtm.RememberTheMilkConfiguration(hass) await config.setup()
assert open_mock.return_value.write.call_count == 0
assert config.get_rtm_id(PROFILE, hass_id) is None 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) 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 (list_id, timeseries_id, rtm_id) == config.get_rtm_id(PROFILE, hass_id)
assert open_mock.return_value.write.call_count == 1 assert hass_storage[DOMAIN]["data"] == {
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": { "myprofile": {
"id_map": { "id_map": {
"123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"} "123": {"list_id": "1", "timeseries_id": "2", "task_id": "3"}
} }
} }
} }
)
config.delete_rtm_id(PROFILE, hass_id) 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 config.get_rtm_id(PROFILE, hass_id) is None
assert open_mock.return_value.write.call_count == 2 assert hass_storage[DOMAIN]["data"] == {
assert open_mock.return_value.write.call_args[0][0] == json.dumps(
{
"myprofile": { "myprofile": {
"id_map": {}, "id_map": {},
} }
} }
)