From 8c72cb616394cafdea881024c18ee101851328a9 Mon Sep 17 00:00:00 2001 From: Ilja Leiko Date: Wed, 17 Feb 2021 09:04:11 +0100 Subject: [PATCH] Add sensors to fetch Habitica tasks (#38910) * Adding sensors to fetch habitica tasks * PR changes and rebase * Fixing pylint * Fixing failed test dependancy * Generating requirements * Apply suggestions from code review Co-authored-by: Martin Hjelmare * PR changes * Update homeassistant/components/habitica/config_flow.py Thank you, @MartinHjelmare Co-authored-by: Martin Hjelmare * PR Changes * Fix failing test * Update tests/components/habitica/test_config_flow.py Co-authored-by: Martin Hjelmare * Fixing linting and imports Co-authored-by: Martin Hjelmare --- .coveragerc | 4 +- CODEOWNERS | 1 + homeassistant/components/habitica/__init__.py | 173 +++++++++-------- .../components/habitica/config_flow.py | 85 +++++++++ homeassistant/components/habitica/const.py | 14 ++ .../components/habitica/manifest.json | 11 +- homeassistant/components/habitica/sensor.py | 177 ++++++++++++++++-- .../components/habitica/strings.json | 20 ++ .../components/habitica/translations/en.json | 20 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/habitica/__init__.py | 1 + tests/components/habitica/test_config_flow.py | 135 +++++++++++++ 13 files changed, 549 insertions(+), 96 deletions(-) create mode 100644 homeassistant/components/habitica/config_flow.py create mode 100644 homeassistant/components/habitica/const.py create mode 100644 homeassistant/components/habitica/strings.json create mode 100644 homeassistant/components/habitica/translations/en.json create mode 100644 tests/components/habitica/__init__.py create mode 100644 tests/components/habitica/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index df9fbac4c58..51a539b4aba 100644 --- a/.coveragerc +++ b/.coveragerc @@ -361,7 +361,9 @@ omit = homeassistant/components/guardian/sensor.py homeassistant/components/guardian/switch.py homeassistant/components/guardian/util.py - homeassistant/components/habitica/* + homeassistant/components/habitica/__init__.py + homeassistant/components/habitica/const.py + homeassistant/components/habitica/sensor.py homeassistant/components/hangouts/* homeassistant/components/hangouts/__init__.py homeassistant/components/hangouts/const.py diff --git a/CODEOWNERS b/CODEOWNERS index 8a2ea23ded9..81f43da58e7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -182,6 +182,7 @@ homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/guardian/* @bachya +homeassistant/components/habitica/* @ASMfreaK @leikoilja homeassistant/components/harmony/* @ehendrix23 @bramkragten @bdraco @mkeesey homeassistant/components/hassio/* @home-assistant/supervisor homeassistant/components/hdmi_cec/* @newAM diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index b2c3fb16831..36e50db6c20 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -1,55 +1,47 @@ -"""Support for Habitica devices.""" -from collections import namedtuple +"""The habitica integration.""" +import asyncio import logging from habitipy.aio import HabitipyAsync import voluptuous as vol -from homeassistant.const import ( - CONF_API_KEY, - CONF_NAME, - CONF_PATH, - CONF_SENSORS, - CONF_URL, -) -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_SENSORS, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .const import ( + ATTR_ARGS, + ATTR_NAME, + ATTR_PATH, + CONF_API_USER, + DEFAULT_URL, + DOMAIN, + EVENT_API_CALL_SUCCESS, + SERVICE_API_CALL, +) +from .sensor import SENSORS_TYPES + _LOGGER = logging.getLogger(__name__) -CONF_API_USER = "api_user" - -DEFAULT_URL = "https://habitica.com" -DOMAIN = "habitica" - -ST = SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) - -SENSORS_TYPES = { - "name": ST("Name", None, "", ["profile", "name"]), - "hp": ST("HP", "mdi:heart", "HP", ["stats", "hp"]), - "maxHealth": ST("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), - "mp": ST("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), - "maxMP": ST("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), - "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), - "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), - "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), - "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), - "class": ST("Class", "mdi:sword", "", ["stats", "class"]), -} - -INSTANCE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_API_USER): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): vol.All( - cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))] - ), - } +INSTANCE_SCHEMA = vol.All( + cv.deprecated(CONF_SENSORS), + vol.Schema( + { + vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url, + vol.Optional(CONF_NAME): cv.string, + vol.Required(CONF_API_USER): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)): vol.All( + cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))] + ), + } + ), ) -has_unique_values = vol.Schema(vol.Unique()) +has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name # because we want a handy alias @@ -73,14 +65,9 @@ def has_all_unique_users_names(value): INSTANCE_LIST_SCHEMA = vol.All( cv.ensure_list, has_all_unique_users, has_all_unique_users_names, [INSTANCE_SCHEMA] ) - CONFIG_SCHEMA = vol.Schema({DOMAIN: INSTANCE_LIST_SCHEMA}, extra=vol.ALLOW_EXTRA) -SERVICE_API_CALL = "api_call" -ATTR_NAME = CONF_NAME -ATTR_PATH = CONF_PATH -ATTR_ARGS = "args" -EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" +PLATFORMS = ["sensor"] SERVICE_API_CALL_SCHEMA = vol.Schema( { @@ -91,12 +78,25 @@ SERVICE_API_CALL_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Habitica service.""" + configs = config.get(DOMAIN, []) - conf = config[DOMAIN] - data = hass.data[DOMAIN] = {} - websession = async_get_clientsession(hass) + for conf in configs: + if conf.get(CONF_URL) is None: + conf[CONF_URL] = DEFAULT_URL + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up habitica from a config entry.""" class HAHabitipyAsync(HabitipyAsync): """Closure API class to hold session.""" @@ -104,28 +104,6 @@ async def async_setup(hass, config): def __call__(self, **kwargs): return super().__call__(websession, **kwargs) - for instance in conf: - url = instance[CONF_URL] - username = instance[CONF_API_USER] - password = instance[CONF_API_KEY] - name = instance.get(CONF_NAME) - config_dict = {"url": url, "login": username, "password": password} - api = HAHabitipyAsync(config_dict) - user = await api.user.get() - if name is None: - name = user["profile"]["name"] - data[name] = api - if CONF_SENSORS in instance: - hass.async_create_task( - discovery.async_load_platform( - hass, - "sensor", - DOMAIN, - {"name": name, "sensors": instance[CONF_SENSORS]}, - config, - ) - ) - async def handle_api_call(call): name = call.data[ATTR_NAME] path = call.data[ATTR_PATH] @@ -147,7 +125,50 @@ async def async_setup(hass, config): EVENT_API_CALL_SUCCESS, {"name": name, "path": path, "data": data} ) - hass.services.async_register( - DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA - ) + data = hass.data.setdefault(DOMAIN, {}) + config = config_entry.data + websession = async_get_clientsession(hass) + url = config[CONF_URL] + username = config[CONF_API_USER] + password = config[CONF_API_KEY] + name = config.get(CONF_NAME) + config_dict = {"url": url, "login": username, "password": password} + api = HAHabitipyAsync(config_dict) + user = await api.user.get() + if name is None: + name = user["profile"]["name"] + hass.config_entries.async_update_entry( + config_entry, + data={**config_entry.data, CONF_NAME: name}, + ) + data[config_entry.entry_id] = api + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + if not hass.services.has_service(DOMAIN, SERVICE_API_CALL): + hass.services.async_register( + DOMAIN, SERVICE_API_CALL, handle_api_call, schema=SERVICE_API_CALL_SCHEMA + ) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + if len(hass.config_entries.async_entries) == 1: + hass.components.webhook.async_unregister(SERVICE_API_CALL) + return unload_ok diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py new file mode 100644 index 00000000000..6e3311ea9b5 --- /dev/null +++ b/homeassistant/components/habitica/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for habitica integration.""" +import logging +from typing import Dict + +from aiohttp import ClientResponseError +from habitipy.aio import HabitipyAsync +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_API_USER, DEFAULT_URL, DOMAIN # pylint: disable=unused-import + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_USER): str, + vol.Required(CONF_API_KEY): str, + vol.Optional(CONF_NAME): str, + vol.Optional(CONF_URL, default=DEFAULT_URL): str, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input( + hass: core.HomeAssistant, data: Dict[str, str] +) -> Dict[str, str]: + """Validate the user input allows us to connect.""" + + websession = async_get_clientsession(hass) + api = HabitipyAsync( + conf={ + "login": data[CONF_API_USER], + "password": data[CONF_API_KEY], + "url": data[CONF_URL] or DEFAULT_URL, + } + ) + try: + await api.user.get(session=websession) + return { + "title": f"{data.get('name', 'Default username')}", + CONF_API_USER: data[CONF_API_USER], + } + except ClientResponseError as ex: + raise InvalidAuth() from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for habitica.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except InvalidAuth: + errors = {"base": "invalid_credentials"} + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors = {"base": "unknown"} + else: + await self.async_set_unique_id(info[CONF_API_USER]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + return self.async_show_form( + step_id="user", + data_schema=DATA_SCHEMA, + errors=errors, + description_placeholders={}, + ) + + async def async_step_import(self, import_data): + """Import habitica config from configuration.yaml.""" + return await self.async_step_user(import_data) + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py new file mode 100644 index 00000000000..438bcec9d94 --- /dev/null +++ b/homeassistant/components/habitica/const.py @@ -0,0 +1,14 @@ +"""Constants for the habitica integration.""" + +from homeassistant.const import CONF_NAME, CONF_PATH + +CONF_API_USER = "api_user" + +DEFAULT_URL = "https://habitica.com" +DOMAIN = "habitica" + +SERVICE_API_CALL = "api_call" +ATTR_NAME = CONF_NAME +ATTR_PATH = CONF_PATH +ATTR_ARGS = "args" +EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index 50664d862ad..0779a2d3248 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -1,7 +1,8 @@ { - "domain": "habitica", - "name": "Habitica", - "documentation": "https://www.home-assistant.io/integrations/habitica", - "requirements": ["habitipy==0.2.0"], - "codeowners": [] + "domain": "habitica", + "name": "Habitica", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/habitica", + "requirements": ["habitipy==0.2.0"], + "codeowners": ["@ASMfreaK", "@leikoilja"] } diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index f885aa832c7..29e494d89ee 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -1,25 +1,82 @@ """Support for Habitica sensors.""" +from collections import namedtuple from datetime import timedelta +import logging -from homeassistant.components import habitica +from aiohttp import ClientResponseError + +from homeassistant.const import CONF_NAME, HTTP_TOO_MANY_REQUESTS from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +ST = SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) -async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the habitica platform.""" - if discovery_info is None: - return +SENSORS_TYPES = { + "name": ST("Name", None, "", ["profile", "name"]), + "hp": ST("HP", "mdi:heart", "HP", ["stats", "hp"]), + "maxHealth": ST("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), + "mp": ST("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), + "maxMP": ST("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), + "exp": ST("EXP", "mdi:star", "EXP", ["stats", "exp"]), + "toNextLevel": ST("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), + "lvl": ST("Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"]), + "gp": ST("Gold", "mdi:currency-usd-circle", "Gold", ["stats", "gp"]), + "class": ST("Class", "mdi:sword", "", ["stats", "class"]), +} - name = discovery_info[habitica.CONF_NAME] - sensors = discovery_info[habitica.CONF_SENSORS] - sensor_data = HabitipyData(hass.data[habitica.DOMAIN][name]) +TASKS_TYPES = { + "habits": ST("Habits", "mdi:clipboard-list-outline", "n_of_tasks", ["habits"]), + "dailys": ST("Dailys", "mdi:clipboard-list-outline", "n_of_tasks", ["dailys"]), + "todos": ST("TODOs", "mdi:clipboard-list-outline", "n_of_tasks", ["todos"]), + "rewards": ST("Rewards", "mdi:clipboard-list-outline", "n_of_tasks", ["rewards"]), +} + +TASKS_MAP_ID = "id" +TASKS_MAP = { + "repeat": "repeat", + "challenge": "challenge", + "group": "group", + "frequency": "frequency", + "every_x": "everyX", + "streak": "streak", + "counter_up": "counterUp", + "counter_down": "counterDown", + "next_due": "nextDue", + "yester_daily": "yesterDaily", + "completed": "completed", + "collapse_checklist": "collapseChecklist", + "type": "type", + "notes": "notes", + "tags": "tags", + "value": "value", + "priority": "priority", + "start_date": "startDate", + "days_of_month": "daysOfMonth", + "weeks_of_month": "weeksOfMonth", + "created_at": "createdAt", + "text": "text", + "is_due": "isDue", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the habitica sensors.""" + + entities = [] + name = config_entry.data[CONF_NAME] + sensor_data = HabitipyData(hass.data[DOMAIN][config_entry.entry_id]) await sensor_data.update() - async_add_devices( - [HabitipySensor(name, sensor, sensor_data) for sensor in sensors], True - ) + for sensor_type in SENSORS_TYPES: + entities.append(HabitipySensor(name, sensor_type, sensor_data)) + for task_type in TASKS_TYPES: + entities.append(HabitipyTaskSensor(name, task_type, sensor_data)) + async_add_entities(entities, True) class HabitipyData: @@ -29,11 +86,43 @@ class HabitipyData: """Habitica API user data cache.""" self.api = api self.data = None + self.tasks = {} @Throttle(MIN_TIME_BETWEEN_UPDATES) async def update(self): """Get a new fix from Habitica servers.""" - self.data = await self.api.user.get() + try: + self.data = await self.api.user.get() + except ClientResponseError as error: + if error.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "Sensor data update for %s has too many API requests." + " Skipping the update.", + DOMAIN, + ) + else: + _LOGGER.error( + "Count not update sensor data for %s (%s)", + DOMAIN, + error, + ) + + for task_type in TASKS_TYPES: + try: + self.tasks[task_type] = await self.api.tasks.user.get(type=task_type) + except ClientResponseError as error: + if error.status == HTTP_TOO_MANY_REQUESTS: + _LOGGER.warning( + "Sensor data update for %s has too many API requests." + " Skipping the update.", + DOMAIN, + ) + else: + _LOGGER.error( + "Count not update sensor data for %s (%s)", + DOMAIN, + error, + ) class HabitipySensor(Entity): @@ -43,7 +132,7 @@ class HabitipySensor(Entity): """Initialize a generic Habitica sensor.""" self._name = name self._sensor_name = sensor_name - self._sensor_type = habitica.SENSORS_TYPES[sensor_name] + self._sensor_type = SENSORS_TYPES[sensor_name] self._state = None self._updater = updater @@ -63,7 +152,7 @@ class HabitipySensor(Entity): @property def name(self): """Return the name of the sensor.""" - return f"{habitica.DOMAIN}_{self._name}_{self._sensor_name}" + return f"{DOMAIN}_{self._name}_{self._sensor_name}" @property def state(self): @@ -74,3 +163,63 @@ class HabitipySensor(Entity): def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._sensor_type.unit + + +class HabitipyTaskSensor(Entity): + """A Habitica task sensor.""" + + def __init__(self, name, task_name, updater): + """Initialize a generic Habitica task.""" + self._name = name + self._task_name = task_name + self._task_type = TASKS_TYPES[task_name] + self._state = None + self._updater = updater + + async def async_update(self): + """Update Condition and Forecast.""" + await self._updater.update() + all_tasks = self._updater.tasks + for element in self._task_type.path: + tasks_length = len(all_tasks[element]) + self._state = tasks_length + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._task_type.icon + + @property + def name(self): + """Return the name of the task.""" + return f"{DOMAIN}_{self._name}_{self._task_name}" + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of all user tasks.""" + if self._updater.tasks is not None: + all_received_tasks = self._updater.tasks + for element in self._task_type.path: + received_tasks = all_received_tasks[element] + attrs = {} + + # Map tasks to TASKS_MAP + for received_task in received_tasks: + task_id = received_task[TASKS_MAP_ID] + task = {} + for map_key, map_value in TASKS_MAP.items(): + value = received_task.get(map_value) + if value: + task[map_key] = value + attrs[task_id] = task + return attrs + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._task_type.unit diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json new file mode 100644 index 00000000000..868d024b02e --- /dev/null +++ b/homeassistant/components/habitica/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be gotten from https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} diff --git a/homeassistant/components/habitica/translations/en.json b/homeassistant/components/habitica/translations/en.json new file mode 100644 index 00000000000..fa571d0d72a --- /dev/null +++ b/homeassistant/components/habitica/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_credentials": "Invalid credentials", + "unknown": "Unknown error" + }, + "step": { + "user": { + "data": { + "url": "Habitica URL", + "name": "Override for Habitica’s username. Will be used for service calls", + "api_user": "Habitica’s API user ID", + "api_key": "Habitica's API user KEY" + }, + "description": "Connect your Habitica profile to allow monitoring of your user's profile and tasks. Note that api_id and api_key must be grabed from https://habitica.com/user/settings/api" + } + } + }, + "title": "Habitica" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 400f2f2352b..53d3c8294d2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -86,6 +86,7 @@ FLOWS = [ "gree", "griddy", "guardian", + "habitica", "hangouts", "harmony", "heos", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f15becca1a..0e863e23bb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,6 +383,9 @@ ha-ffmpeg==3.0.2 # homeassistant.components.philips_js ha-philipsjs==0.1.0 +# homeassistant.components.habitica +habitipy==0.2.0 + # homeassistant.components.hangouts hangups==0.4.11 diff --git a/tests/components/habitica/__init__.py b/tests/components/habitica/__init__.py new file mode 100644 index 00000000000..a7f62afff8f --- /dev/null +++ b/tests/components/habitica/__init__.py @@ -0,0 +1 @@ +"""Tests for the habitica integration.""" diff --git a/tests/components/habitica/test_config_flow.py b/tests/components/habitica/test_config_flow.py new file mode 100644 index 00000000000..8ae92bcc0e2 --- /dev/null +++ b/tests/components/habitica/test_config_flow.py @@ -0,0 +1,135 @@ +"""Test the habitica config flow.""" +from asyncio import Future +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant import config_entries, setup +from homeassistant.components.habitica.config_flow import InvalidAuth +from homeassistant.components.habitica.const import DEFAULT_URL, DOMAIN +from homeassistant.const import HTTP_OK + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + request_mock = MagicMock() + type(request_mock).status_code = HTTP_OK + + mock_obj = MagicMock() + mock_obj.user.get.return_value = Future() + mock_obj.user.get.return_value.set_result(None) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ), patch( + "homeassistant.components.habitica.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.habitica.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_user": "test-api-user", "api_key": "test-api-key"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Default username" + assert result2["data"] == { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_credentials(hass): + """Test we handle invalid credentials error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "habitipy.aio.HabitipyAsync", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_credentials"} + + +async def test_form_unexpected_exception(hass): + """Test we handle unexpected exception error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + +async def test_manual_flow_config_exist(hass, aioclient_mock): + """Test config flow discovers only already configured config.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="test-api-user", + data={"api_user": "test-api-user", "api_key": "test-api-key"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_obj = MagicMock() + mock_obj.user.get.side_effect = AsyncMock( + return_value={"api_user": "test-api-user"} + ) + + with patch( + "homeassistant.components.habitica.config_flow.HabitipyAsync", + return_value=mock_obj, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": DEFAULT_URL, + "api_user": "test-api-user", + "api_key": "test-api-key", + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured"