From abf9aab18f9a6953b49c4f8aee1ca7e560911e36 Mon Sep 17 00:00:00 2001 From: xLarry Date: Sat, 21 May 2022 21:18:01 +0200 Subject: [PATCH] Add laundrify integration (#65090) * First version of laundrify integration * Code cleanup * Code cleanup after review #2 * Move coordinator to its own file * Save devices as dict and implement available prop as fn * Validate token on init, abort if already configured * Some more cleanup after review * Add strict type hints * Minor changes after code review * Remove OptionsFlow (use default poll interval instead) * Fix CODEOWNERS to pass hassfest job * Fix formatting to pass prettier job * Fix mypy typing error * Update internal device property after fetching data * Call parental update handler and remove obsolete code * Add coordinator tests and fix some config flow tests * Refactor tests * Refactor fixtures * Device unavailable if polling fails --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/laundrify/__init__.py | 52 +++++++ .../components/laundrify/binary_sensor.py | 84 ++++++++++++ .../components/laundrify/config_flow.py | 94 +++++++++++++ homeassistant/components/laundrify/const.py | 10 ++ .../components/laundrify/coordinator.py | 46 +++++++ .../components/laundrify/manifest.json | 9 ++ homeassistant/components/laundrify/model.py | 13 ++ .../components/laundrify/strings.json | 25 ++++ .../components/laundrify/translations/en.json | 25 ++++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/laundrify/__init__.py | 22 +++ tests/components/laundrify/conftest.py | 51 +++++++ tests/components/laundrify/const.py | 11 ++ .../laundrify/fixtures/machines.json | 8 ++ .../components/laundrify/test_config_flow.py | 129 ++++++++++++++++++ .../components/laundrify/test_coordinator.py | 50 +++++++ tests/components/laundrify/test_init.py | 59 ++++++++ 22 files changed, 709 insertions(+) create mode 100644 homeassistant/components/laundrify/__init__.py create mode 100644 homeassistant/components/laundrify/binary_sensor.py create mode 100644 homeassistant/components/laundrify/config_flow.py create mode 100644 homeassistant/components/laundrify/const.py create mode 100644 homeassistant/components/laundrify/coordinator.py create mode 100644 homeassistant/components/laundrify/manifest.json create mode 100644 homeassistant/components/laundrify/model.py create mode 100644 homeassistant/components/laundrify/strings.json create mode 100644 homeassistant/components/laundrify/translations/en.json create mode 100644 tests/components/laundrify/__init__.py create mode 100644 tests/components/laundrify/conftest.py create mode 100644 tests/components/laundrify/const.py create mode 100644 tests/components/laundrify/fixtures/machines.json create mode 100644 tests/components/laundrify/test_config_flow.py create mode 100644 tests/components/laundrify/test_coordinator.py create mode 100644 tests/components/laundrify/test_init.py diff --git a/.strict-typing b/.strict-typing index 4ec4fbff5eea5..563e6e9f91acc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -138,6 +138,7 @@ homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* homeassistant.components.lametric.* +homeassistant.components.laundrify.* homeassistant.components.lcn.* homeassistant.components.light.* homeassistant.components.local_ip.* diff --git a/CODEOWNERS b/CODEOWNERS index 4e1dcd5d93ef4..05e0d0b4377df 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -556,6 +556,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lametric/ @robbiet480 @frenck /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol +/homeassistant/components/laundrify/ @xLarry +/tests/components/laundrify/ @xLarry /homeassistant/components/lcn/ @alengwenus /tests/components/lcn/ @alengwenus /homeassistant/components/lg_netcast/ @Drafteed diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py new file mode 100644 index 0000000000000..27fc412ababcb --- /dev/null +++ b/homeassistant/components/laundrify/__init__.py @@ -0,0 +1,52 @@ +"""The laundrify integration.""" +from __future__ import annotations + +from laundrify_aio import LaundrifyAPI +from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_POLL_INTERVAL, DOMAIN +from .coordinator import LaundrifyUpdateCoordinator + +PLATFORMS = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up laundrify from a config entry.""" + + session = async_get_clientsession(hass) + api_client = LaundrifyAPI(entry.data[CONF_ACCESS_TOKEN], session) + + try: + await api_client.validate_token() + except UnauthorizedException as err: + raise ConfigEntryAuthFailed("Invalid authentication") from err + except ApiConnectionException as err: + raise ConfigEntryNotReady("Cannot reach laundrify API") from err + + coordinator = LaundrifyUpdateCoordinator(hass, api_client, DEFAULT_POLL_INTERVAL) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + "api": api_client, + "coordinator": coordinator, + } + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py new file mode 100644 index 0000000000000..2f64d8cee7806 --- /dev/null +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -0,0 +1,84 @@ +"""Platform for binary sensor integration.""" +from __future__ import annotations + +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, MODEL +from .coordinator import LaundrifyUpdateCoordinator +from .model import LaundrifyDevice + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors from a config entry created in the integrations UI.""" + + coordinator = hass.data[DOMAIN][config.entry_id]["coordinator"] + + async_add_entities( + LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values() + ) + + +class LaundrifyPowerPlug( + CoordinatorEntity[LaundrifyUpdateCoordinator], BinarySensorEntity +): + """Representation of a laundrify Power Plug.""" + + _attr_device_class = BinarySensorDeviceClass.RUNNING + _attr_icon = "mdi:washing-machine" + + def __init__( + self, coordinator: LaundrifyUpdateCoordinator, device: LaundrifyDevice + ) -> None: + """Pass coordinator to CoordinatorEntity.""" + super().__init__(coordinator) + self._device = device + self._attr_unique_id = device["_id"] + + @property + def device_info(self) -> DeviceInfo: + """Configure the Device of this Entity.""" + return DeviceInfo( + identifiers={(DOMAIN, self._device["_id"])}, + name=self.name, + manufacturer=MANUFACTURER, + model=MODEL, + sw_version=self._device["firmwareVersion"], + ) + + @property + def available(self) -> bool: + """Check if the device is available.""" + return ( + self.unique_id in self.coordinator.data + and self.coordinator.last_update_success + ) + + @property + def name(self) -> str: + """Name of the entity.""" + return self._device["name"] + + @property + def is_on(self) -> bool: + """Return entity state.""" + return self._device["status"] == "ON" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._device = self.coordinator.data[self.unique_id] + super()._handle_coordinator_update() diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py new file mode 100644 index 0000000000000..d8230863d7c17 --- /dev/null +++ b/homeassistant/components/laundrify/config_flow.py @@ -0,0 +1,94 @@ +"""Config flow for laundrify integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from laundrify_aio import LaundrifyAPI +from laundrify_aio.exceptions import ( + ApiConnectionException, + InvalidFormat, + UnknownAuthCode, +) +from voluptuous import Required, Schema + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = Schema({Required(CONF_CODE): str}) + + +class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for laundrify.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="init", data_schema=CONFIG_SCHEMA) + + errors = {} + + try: + access_token = await LaundrifyAPI.exchange_auth_code(user_input[CONF_CODE]) + + session = async_get_clientsession(self.hass) + api_client = LaundrifyAPI(access_token, session) + + account_id = await api_client.get_account_id() + except InvalidFormat: + errors[CONF_CODE] = "invalid_format" + except UnknownAuthCode: + errors[CONF_CODE] = "invalid_auth" + except ApiConnectionException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + entry_data = {CONF_ACCESS_TOKEN: access_token} + + await self.async_set_unique_id(account_id) + self._abort_if_unique_id_configured() + + # Create a new entry if it doesn't exist + return self.async_create_entry( + title=DOMAIN, + data=entry_data, + ) + + return self.async_show_form( + step_id="init", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """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 + ) -> FlowResult: + """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=Schema({}), + ) + return await self.async_step_init() diff --git a/homeassistant/components/laundrify/const.py b/homeassistant/components/laundrify/const.py new file mode 100644 index 0000000000000..c312b895234c6 --- /dev/null +++ b/homeassistant/components/laundrify/const.py @@ -0,0 +1,10 @@ +"""Constants for the laundrify integration.""" + +DOMAIN = "laundrify" + +MANUFACTURER = "laundrify" +MODEL = "WLAN-Adapter (SU02)" + +DEFAULT_POLL_INTERVAL = 60 + +REQUEST_TIMEOUT = 10 diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py new file mode 100644 index 0000000000000..3144749050609 --- /dev/null +++ b/homeassistant/components/laundrify/coordinator.py @@ -0,0 +1,46 @@ +"""Custom DataUpdateCoordinator for the laundrify integration.""" +from datetime import timedelta +import logging + +import async_timeout +from laundrify_aio import LaundrifyAPI +from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, REQUEST_TIMEOUT +from .model import LaundrifyDevice + +_LOGGER = logging.getLogger(__name__) + + +class LaundrifyUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching laundrify API data.""" + + def __init__( + self, hass: HomeAssistant, laundrify_api: LaundrifyAPI, poll_interval: int + ) -> None: + """Initialize laundrify coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=poll_interval), + ) + self.laundrify_api = laundrify_api + + async def _async_update_data(self) -> dict[str, LaundrifyDevice]: + """Fetch data from laundrify API.""" + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with async_timeout.timeout(REQUEST_TIMEOUT): + return {m["_id"]: m for m in await self.laundrify_api.get_machines()} + except UnauthorizedException as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + raise ConfigEntryAuthFailed from err + except ApiConnectionException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json new file mode 100644 index 0000000000000..6a61446d31c4c --- /dev/null +++ b/homeassistant/components/laundrify/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "laundrify", + "name": "laundrify", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/laundrify", + "requirements": ["laundrify_aio==1.1.1"], + "codeowners": ["@xLarry"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/laundrify/model.py b/homeassistant/components/laundrify/model.py new file mode 100644 index 0000000000000..aa6bf77509fec --- /dev/null +++ b/homeassistant/components/laundrify/model.py @@ -0,0 +1,13 @@ +"""Models for laundrify platform.""" +from __future__ import annotations + +from typing import TypedDict + + +class LaundrifyDevice(TypedDict): + """laundrify Power Plug.""" + + _id: str + name: str + status: str + firmwareVersion: str diff --git a/homeassistant/components/laundrify/strings.json b/homeassistant/components/laundrify/strings.json new file mode 100644 index 0000000000000..b2fea9f307f54 --- /dev/null +++ b/homeassistant/components/laundrify/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "init": { + "description": "Please enter your personal Auth Code that is shown in the laundrify-App.", + "data": { + "code": "Auth Code (xxx-xxx)" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The laundrify integration needs to re-authenticate." + } + }, + "error": { + "invalid_format": "Invalid format. Please specify as xxx-xxx.", + "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": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/laundrify/translations/en.json b/homeassistant/components/laundrify/translations/en.json new file mode 100644 index 0000000000000..2bfb07d404178 --- /dev/null +++ b/homeassistant/components/laundrify/translations/en.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_format": "Invalid format. Please specify as xxx-xxx.", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "code": "Auth Code (xxx-xxx)" + }, + "description": "Please enter your personal AuthCode that is shown in the laundrify-App." + }, + "reauth_confirm": { + "description": "The laundrify integration needs to re-authenticate.", + "title": "Reauthenticate Integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 67299f403c21e..b0bbd3e1b369c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -187,6 +187,7 @@ "kraken", "kulersky", "launch_library", + "laundrify", "life360", "lifx", "litejet", diff --git a/mypy.ini b/mypy.ini index 8f0b1868bce3c..ed073141fe121 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1281,6 +1281,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.laundrify.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lcn.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4c88b57b9f460..ab4a232e864b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -926,6 +926,9 @@ krakenex==2.1.0 # homeassistant.components.eufy lakeside==0.12 +# homeassistant.components.laundrify +laundrify_aio==1.1.1 + # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca6b5fd162839..5159abb4f430c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -654,6 +654,9 @@ kostal_plenticore==0.2.0 # homeassistant.components.kraken krakenex==2.1.0 +# homeassistant.components.laundrify +laundrify_aio==1.1.1 + # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/tests/components/laundrify/__init__.py b/tests/components/laundrify/__init__.py new file mode 100644 index 0000000000000..c09c6290adfd8 --- /dev/null +++ b/tests/components/laundrify/__init__.py @@ -0,0 +1,22 @@ +"""Tests for the laundrify integration.""" + +from homeassistant.components.laundrify import DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant + +from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID + +from tests.common import MockConfigEntry + + +def create_entry( + hass: HomeAssistant, access_token: str = VALID_ACCESS_TOKEN +) -> MockConfigEntry: + """Create laundrify entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=VALID_ACCOUNT_ID, + data={CONF_ACCESS_TOKEN: access_token}, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py new file mode 100644 index 0000000000000..13e408b61a9d2 --- /dev/null +++ b/tests/components/laundrify/conftest.py @@ -0,0 +1,51 @@ +"""Configure py.test.""" +import json +from unittest.mock import patch + +import pytest + +from .const import VALID_ACCESS_TOKEN, VALID_ACCOUNT_ID + +from tests.common import load_fixture + + +@pytest.fixture(name="laundrify_setup_entry") +def laundrify_setup_entry_fixture(): + """Mock laundrify setup entry function.""" + with patch( + "homeassistant.components.laundrify.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="laundrify_exchange_code") +def laundrify_exchange_code_fixture(): + """Mock laundrify exchange_auth_code function.""" + with patch( + "laundrify_aio.LaundrifyAPI.exchange_auth_code", + return_value=VALID_ACCESS_TOKEN, + ) as exchange_code_mock: + yield exchange_code_mock + + +@pytest.fixture(name="laundrify_validate_token") +def laundrify_validate_token_fixture(): + """Mock laundrify validate_token function.""" + with patch( + "laundrify_aio.LaundrifyAPI.validate_token", + return_value=True, + ) as validate_token_mock: + yield validate_token_mock + + +@pytest.fixture(name="laundrify_api_mock", autouse=True) +def laundrify_api_fixture(laundrify_exchange_code, laundrify_validate_token): + """Mock valid laundrify API responses.""" + with patch( + "laundrify_aio.LaundrifyAPI.get_account_id", + return_value=VALID_ACCOUNT_ID, + ), patch( + "laundrify_aio.LaundrifyAPI.get_machines", + return_value=json.loads(load_fixture("laundrify/machines.json")), + ) as get_machines_mock: + yield get_machines_mock diff --git a/tests/components/laundrify/const.py b/tests/components/laundrify/const.py new file mode 100644 index 0000000000000..644631917c6b0 --- /dev/null +++ b/tests/components/laundrify/const.py @@ -0,0 +1,11 @@ +"""Constants for the laundrify tests.""" + +from homeassistant.const import CONF_CODE + +VALID_AUTH_CODE = "999-001" +VALID_ACCESS_TOKEN = "validAccessToken1234" +VALID_ACCOUNT_ID = "1234" + +VALID_USER_INPUT = { + CONF_CODE: VALID_AUTH_CODE, +} diff --git a/tests/components/laundrify/fixtures/machines.json b/tests/components/laundrify/fixtures/machines.json new file mode 100644 index 0000000000000..ab1a737cb454d --- /dev/null +++ b/tests/components/laundrify/fixtures/machines.json @@ -0,0 +1,8 @@ +[ + { + "_id": "14", + "name": "Demo Waschmaschine", + "status": "OFF", + "firmwareVersion": "2.1.0" + } +] diff --git a/tests/components/laundrify/test_config_flow.py b/tests/components/laundrify/test_config_flow.py new file mode 100644 index 0000000000000..5ee3efe1e450c --- /dev/null +++ b/tests/components/laundrify/test_config_flow.py @@ -0,0 +1,129 @@ +"""Test the laundrify config flow.""" + +from laundrify_aio import exceptions + +from homeassistant.components.laundrify.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CODE, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import create_entry +from .const import VALID_ACCESS_TOKEN, VALID_AUTH_CODE, VALID_USER_INPUT + + +async def test_form(hass: HomeAssistant, laundrify_setup_entry) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=VALID_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DOMAIN + assert result["data"] == { + CONF_ACCESS_TOKEN: VALID_ACCESS_TOKEN, + } + assert len(laundrify_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_format( + hass: HomeAssistant, laundrify_exchange_code +) -> None: + """Test we handle invalid format.""" + laundrify_exchange_code.side_effect = exceptions.InvalidFormat + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={CONF_CODE: "invalidFormat"}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {CONF_CODE: "invalid_format"} + + +async def test_form_invalid_auth(hass: HomeAssistant, laundrify_exchange_code) -> None: + """Test we handle invalid auth.""" + laundrify_exchange_code.side_effect = exceptions.UnknownAuthCode + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {CONF_CODE: "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant, laundrify_exchange_code): + """Test we handle cannot connect error.""" + laundrify_exchange_code.side_effect = exceptions.ApiConnectionException + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unkown_exception(hass: HomeAssistant, laundrify_exchange_code): + """Test we handle all other errors.""" + laundrify_exchange_code.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data=VALID_USER_INPUT, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_step_reauth(hass: HomeAssistant) -> None: + """Test the reauth form is shown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_REAUTH} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_FORM + + +async def test_integration_already_exists(hass: HomeAssistant): + """Test we only allow a single config flow.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: VALID_AUTH_CODE, + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/laundrify/test_coordinator.py b/tests/components/laundrify/test_coordinator.py new file mode 100644 index 0000000000000..58bc297cc42ff --- /dev/null +++ b/tests/components/laundrify/test_coordinator.py @@ -0,0 +1,50 @@ +"""Test the laundrify coordinator.""" + +from laundrify_aio import exceptions + +from homeassistant.components.laundrify.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import create_entry + + +async def test_coordinator_update_success(hass: HomeAssistant): + """Test the coordinator update is performed successfully.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.last_update_success + + +async def test_coordinator_update_unauthorized(hass: HomeAssistant, laundrify_api_mock): + """Test the coordinator update fails if an UnauthorizedException is thrown.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + laundrify_api_mock.side_effect = exceptions.UnauthorizedException + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert not coordinator.last_update_success + + +async def test_coordinator_update_connection_failed( + hass: HomeAssistant, laundrify_api_mock +): + """Test the coordinator update fails if an ApiConnectionException is thrown.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinator"] + laundrify_api_mock.side_effect = exceptions.ApiConnectionException + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert not coordinator.last_update_success diff --git a/tests/components/laundrify/test_init.py b/tests/components/laundrify/test_init.py new file mode 100644 index 0000000000000..129848e880804 --- /dev/null +++ b/tests/components/laundrify/test_init.py @@ -0,0 +1,59 @@ +"""Test the laundrify init file.""" + +from laundrify_aio import exceptions + +from homeassistant.components.laundrify.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import create_entry + + +async def test_setup_entry_api_unauthorized( + hass: HomeAssistant, laundrify_validate_token +): + """Test that ConfigEntryAuthFailed is thrown when authentication fails.""" + laundrify_validate_token.side_effect = exceptions.UnauthorizedException + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_api_cannot_connect( + hass: HomeAssistant, laundrify_validate_token +): + """Test that ApiConnectionException is thrown when connection fails.""" + laundrify_validate_token.side_effect = exceptions.ApiConnectionException + config_entry = create_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_setup_entry_successful(hass: HomeAssistant): + """Test entry can be setup successfully.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_setup_entry_unload(hass: HomeAssistant): + """Test unloading the laundrify entry.""" + config_entry = create_entry(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state == ConfigEntryState.NOT_LOADED