From d1ab93fbaf731e3966cd80392178f9e5684f560b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare <marhje52@gmail.com> Date: Sun, 7 Aug 2022 23:45:32 +0200 Subject: [PATCH] Add openexchangerates config flow (#76390) --- .coveragerc | 4 +- CODEOWNERS | 1 + .../components/openexchangerates/__init__.py | 62 +++- .../openexchangerates/config_flow.py | 132 +++++++++ .../components/openexchangerates/const.py | 2 + .../openexchangerates/coordinator.py | 20 +- .../openexchangerates/manifest.json | 4 +- .../components/openexchangerates/sensor.py | 136 +++++---- .../components/openexchangerates/strings.json | 33 +++ .../openexchangerates/translations/en.json | 33 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + .../components/openexchangerates/__init__.py | 1 + .../components/openexchangerates/conftest.py | 39 +++ .../openexchangerates/test_config_flow.py | 268 ++++++++++++++++++ 15 files changed, 659 insertions(+), 80 deletions(-) create mode 100644 homeassistant/components/openexchangerates/config_flow.py create mode 100644 homeassistant/components/openexchangerates/strings.json create mode 100644 homeassistant/components/openexchangerates/translations/en.json create mode 100644 tests/components/openexchangerates/__init__.py create mode 100644 tests/components/openexchangerates/conftest.py create mode 100644 tests/components/openexchangerates/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4c11fe46120..5068574df78 100644 --- a/.coveragerc +++ b/.coveragerc @@ -851,7 +851,9 @@ omit = homeassistant/components/open_meteo/weather.py homeassistant/components/opencv/* homeassistant/components/openevse/sensor.py - homeassistant/components/openexchangerates/* + homeassistant/components/openexchangerates/__init__.py + homeassistant/components/openexchangerates/coordinator.py + homeassistant/components/openexchangerates/sensor.py homeassistant/components/opengarage/__init__.py homeassistant/components/opengarage/binary_sensor.py homeassistant/components/opengarage/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 1a322b09981..e10a8a0b26c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -767,6 +767,7 @@ build.json @home-assistant/supervisor /homeassistant/components/openerz/ @misialq /tests/components/openerz/ @misialq /homeassistant/components/openexchangerates/ @MartinHjelmare +/tests/components/openexchangerates/ @MartinHjelmare /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index 93d53614bdb..1b6ab4e65f1 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -1 +1,61 @@ -"""The openexchangerates component.""" +"""The Open Exchange Rates integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_BASE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER +from .coordinator import OpenexchangeratesCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Open Exchange Rates from a config entry.""" + api_key: str = entry.data[CONF_API_KEY] + base: str = entry.data[CONF_BASE] + + # Create one coordinator per base currency per API key. + existing_coordinators: dict[str, OpenexchangeratesCoordinator] = hass.data.get( + DOMAIN, {} + ) + existing_coordinator_for_api_key = { + existing_coordinator + for config_entry_id, existing_coordinator in existing_coordinators.items() + if (config_entry := hass.config_entries.async_get_entry(config_entry_id)) + and config_entry.data[CONF_API_KEY] == api_key + } + + # Adjust update interval by coordinators per API key. + update_interval = BASE_UPDATE_INTERVAL * (len(existing_coordinator_for_api_key) + 1) + coordinator = OpenexchangeratesCoordinator( + hass, + async_get_clientsession(hass), + api_key, + base, + update_interval, + ) + + LOGGER.debug("Coordinator update interval set to: %s", update_interval) + + # Set new interval on all coordinators for this API key. + for existing_coordinator in existing_coordinator_for_api_key: + existing_coordinator.update_interval = update_interval + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(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/openexchangerates/config_flow.py b/homeassistant/components/openexchangerates/config_flow.py new file mode 100644 index 00000000000..3c22f3e0fe0 --- /dev/null +++ b/homeassistant/components/openexchangerates/config_flow.py @@ -0,0 +1,132 @@ +"""Config flow for Open Exchange Rates integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from typing import Any + +from aioopenexchangerates import ( + Client, + OpenExchangeRatesAuthError, + OpenExchangeRatesClientError, +) +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_BASE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CLIENT_TIMEOUT, DEFAULT_BASE, DOMAIN, LOGGER + + +def get_data_schema( + currencies: dict[str, str], existing_data: Mapping[str, str] +) -> vol.Schema: + """Return a form schema.""" + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Optional( + CONF_BASE, default=existing_data.get(CONF_BASE) or DEFAULT_BASE + ): vol.In(currencies), + } + ) + + +async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, str]: + """Validate the user input allows us to connect.""" + client = Client(data[CONF_API_KEY], async_get_clientsession(hass)) + + async with async_timeout.timeout(CLIENT_TIMEOUT): + await client.get_latest(base=data[CONF_BASE]) + + return {"title": data[CONF_BASE]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Open Exchange Rates.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self.currencies: dict[str, str] = {} + self._reauth_entry: config_entries.ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + currencies = await self.async_get_currencies() + + if user_input is None: + existing_data: Mapping[str, str] | dict[str, str] = ( + self._reauth_entry.data if self._reauth_entry else {} + ) + return self.async_show_form( + step_id="user", data_schema=get_data_schema(currencies, existing_data) + ) + + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except OpenExchangeRatesAuthError: + errors["base"] = "invalid_auth" + except OpenExchangeRatesClientError: + errors["base"] = "cannot_connect" + except asyncio.TimeoutError: + errors["base"] = "timeout_connect" + except Exception: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self._async_abort_entries_match( + { + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_BASE: user_input[CONF_BASE], + } + ) + + if self._reauth_entry is not None: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=self._reauth_entry.data | user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=get_data_schema(currencies, user_input), + description_placeholders={"signup": "https://openexchangerates.org/signup"}, + errors=errors, + ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Handle reauth.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_user() + + async def async_get_currencies(self) -> dict[str, str]: + """Get the available currencies.""" + if not self.currencies: + client = Client("dummy-api-key", async_get_clientsession(self.hass)) + try: + async with async_timeout.timeout(CLIENT_TIMEOUT): + self.currencies = await client.get_currencies() + except OpenExchangeRatesClientError as err: + raise AbortFlow("cannot_connect") from err + except asyncio.TimeoutError as err: + raise AbortFlow("timeout_connect") from err + return self.currencies + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Handle import from yaml/configuration.""" + return await self.async_step_user(import_config) diff --git a/homeassistant/components/openexchangerates/const.py b/homeassistant/components/openexchangerates/const.py index 2c037887489..146919cfe44 100644 --- a/homeassistant/components/openexchangerates/const.py +++ b/homeassistant/components/openexchangerates/const.py @@ -5,3 +5,5 @@ import logging DOMAIN = "openexchangerates" LOGGER = logging.getLogger(__package__) BASE_UPDATE_INTERVAL = timedelta(hours=2) +CLIENT_TIMEOUT = 10 +DEFAULT_BASE = "USD" diff --git a/homeassistant/components/openexchangerates/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 0106edcd751..3795f33aec5 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -1,19 +1,22 @@ """Provide an OpenExchangeRates data coordinator.""" from __future__ import annotations -import asyncio from datetime import timedelta from aiohttp import ClientSession -from aioopenexchangerates import Client, Latest, OpenExchangeRatesClientError +from aioopenexchangerates import ( + Client, + Latest, + OpenExchangeRatesAuthError, + OpenExchangeRatesClientError, +) import async_timeout from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER - -TIMEOUT = 10 +from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): @@ -33,14 +36,15 @@ class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): ) self.base = base self.client = Client(api_key, session) - self.setup_lock = asyncio.Lock() async def _async_update_data(self) -> Latest: """Update data from Open Exchange Rates.""" try: - async with async_timeout.timeout(TIMEOUT): + async with async_timeout.timeout(CLIENT_TIMEOUT): latest = await self.client.get_latest(base=self.base) - except (OpenExchangeRatesClientError) as err: + except OpenExchangeRatesAuthError as err: + raise ConfigEntryAuthFailed(err) from err + except OpenExchangeRatesClientError as err: raise UpdateFailed(err) from err LOGGER.debug("Result: %s", latest) diff --git a/homeassistant/components/openexchangerates/manifest.json b/homeassistant/components/openexchangerates/manifest.json index f2377478c5f..efa67ff39e9 100644 --- a/homeassistant/components/openexchangerates/manifest.json +++ b/homeassistant/components/openexchangerates/manifest.json @@ -3,6 +3,8 @@ "name": "Open Exchange Rates", "documentation": "https://www.home-assistant.io/integrations/openexchangerates", "requirements": ["aioopenexchangerates==0.4.0"], + "dependencies": ["repairs"], "codeowners": ["@MartinHjelmare"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "config_flow": true } diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 337cd3050ac..7f7681b6887 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -1,26 +1,26 @@ """Support for openexchangerates.org exchange rates service.""" from __future__ import annotations -from dataclasses import dataclass, field - import voluptuous as vol +from homeassistant.components.repairs.issue_handler import async_create_issue +from homeassistant.components.repairs.models import IssueSeverity from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_BASE, CONF_NAME, CONF_QUOTE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER +from .const import DEFAULT_BASE, DOMAIN, LOGGER from .coordinator import OpenexchangeratesCoordinator ATTRIBUTION = "Data provided by openexchangerates.org" -DEFAULT_BASE = "USD" DEFAULT_NAME = "Exchange Rate Sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -33,15 +33,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -@dataclass -class DomainData: - """Data structure to hold data for this domain.""" - - coordinators: dict[tuple[str, str], OpenexchangeratesCoordinator] = field( - default_factory=dict, init=False - ) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -49,56 +40,48 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Open Exchange Rates sensor.""" - name: str = config[CONF_NAME] - api_key: str = config[CONF_API_KEY] - base: str = config[CONF_BASE] - quote: str = config[CONF_QUOTE] - - integration_data: DomainData = hass.data.setdefault(DOMAIN, DomainData()) - coordinators = integration_data.coordinators - - if (api_key, base) not in coordinators: - # Create one coordinator per base currency per API key. - update_interval = BASE_UPDATE_INTERVAL * ( - len( - { - coordinator_base - for coordinator_api_key, coordinator_base in coordinators - if coordinator_api_key == api_key - } - ) - + 1 - ) - coordinator = coordinators[api_key, base] = OpenexchangeratesCoordinator( - hass, - async_get_clientsession(hass), - api_key, - base, - update_interval, - ) - - LOGGER.debug( - "Coordinator update interval set to: %s", coordinator.update_interval + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2022.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, ) + ) - # Set new interval on all coordinators for this API key. - for ( - coordinator_api_key, - _, - ), coordinator in coordinators.items(): - if coordinator_api_key == api_key: - coordinator.update_interval = update_interval - - coordinator = coordinators[api_key, base] - async with coordinator.setup_lock: - # We need to make sure that the coordinator data is ready. - if not coordinator.data: - await coordinator.async_refresh() + LOGGER.warning( + "Configuration of Open Exchange Rates integration in YAML is deprecated and " + "will be removed in Home Assistant 2022.11.; Your existing configuration " + "has been imported into the UI automatically and can be safely removed from" + " your configuration.yaml file" + ) - if not coordinator.last_update_success: - raise PlatformNotReady - async_add_entities([OpenexchangeratesSensor(coordinator, name, quote)]) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Open Exchange Rates sensor.""" + # Only YAML imported configs have name and quote in config entry data. + name: str | None = config_entry.data.get(CONF_NAME) + quote: str = config_entry.data.get(CONF_QUOTE, "EUR") + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + OpenexchangeratesSensor( + config_entry, coordinator, name, rate_quote, rate_quote == quote + ) + for rate_quote in coordinator.data.rates + ) class OpenexchangeratesSensor( @@ -109,20 +92,35 @@ class OpenexchangeratesSensor( _attr_attribution = ATTRIBUTION def __init__( - self, coordinator: OpenexchangeratesCoordinator, name: str, quote: str + self, + config_entry: ConfigEntry, + coordinator: OpenexchangeratesCoordinator, + name: str | None, + quote: str, + enabled: bool, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = name - self._quote = quote + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Open Exchange Rates", + name=f"Open Exchange Rates {coordinator.base}", + ) + self._attr_entity_registry_enabled_default = enabled + if name and enabled: + # name is legacy imported from YAML config + # this block can be removed when removing import from YAML + self._attr_name = name + self._attr_has_entity_name = False + else: + self._attr_name = quote + self._attr_has_entity_name = True self._attr_native_unit_of_measurement = quote + self._attr_unique_id = f"{config_entry.entry_id}_{quote}" + self._quote = quote @property def native_value(self) -> float: """Return the state of the sensor.""" return round(self.coordinator.data.rates[self._quote], 4) - - @property - def extra_state_attributes(self) -> dict[str, float]: - """Return other attributes of the sensor.""" - return self.coordinator.data.rates diff --git a/homeassistant/components/openexchangerates/strings.json b/homeassistant/components/openexchangerates/strings.json new file mode 100644 index 00000000000..57180e367aa --- /dev/null +++ b/homeassistant/components/openexchangerates/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "base": "Base currency" + }, + "data_description": { + "base": "Using another base currency than USD requires a [paid plan]({signup})." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Open Exchange Rates YAML configuration is being removed", + "description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/openexchangerates/translations/en.json b/homeassistant/components/openexchangerates/translations/en.json new file mode 100644 index 00000000000..011953904ff --- /dev/null +++ b/homeassistant/components/openexchangerates/translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "cannot_connect": "Failed to connect", + "reauth_successful": "Re-authentication was successful", + "timeout_connect": "Timeout establishing connection" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "timeout_connect": "Timeout establishing connection", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "base": "Base currency" + }, + "data_description": { + "base": "Using another base currency than USD requires a [paid plan]({signup})." + } + } + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring Open Exchange Rates using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Open Exchange Rates YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "The Open Exchange Rates YAML configuration is being removed" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6d92f7cf7e7..327781c2562 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -258,6 +258,7 @@ FLOWS = { "onewire", "onvif", "open_meteo", + "openexchangerates", "opengarage", "opentherm_gw", "openuv", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fbc84fc0a8..7211bb3f08a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,6 +191,9 @@ aionotion==3.0.2 # homeassistant.components.oncue aiooncue==0.3.4 +# homeassistant.components.openexchangerates +aioopenexchangerates==0.4.0 + # homeassistant.components.acmeda aiopulse==0.4.3 diff --git a/tests/components/openexchangerates/__init__.py b/tests/components/openexchangerates/__init__.py new file mode 100644 index 00000000000..4547f25f4bc --- /dev/null +++ b/tests/components/openexchangerates/__init__.py @@ -0,0 +1 @@ +"""Tests for the Open Exchange Rates integration.""" diff --git a/tests/components/openexchangerates/conftest.py b/tests/components/openexchangerates/conftest.py new file mode 100644 index 00000000000..a1512442fd1 --- /dev/null +++ b/tests/components/openexchangerates/conftest.py @@ -0,0 +1,39 @@ +"""Provide common fixtures for tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.openexchangerates.const import DOMAIN + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, data={"api_key": "test-api-key", "base": "USD"} + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.openexchangerates.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_latest_rates_config_flow( + request: pytest.FixtureRequest, +) -> Generator[AsyncMock, None, None]: + """Return a mocked WLED client.""" + with patch( + "homeassistant.components.openexchangerates.config_flow.Client.get_latest", + ) as mock_latest: + mock_latest.return_value = {"EUR": 1.0} + yield mock_latest diff --git a/tests/components/openexchangerates/test_config_flow.py b/tests/components/openexchangerates/test_config_flow.py new file mode 100644 index 00000000000..ee4ba57de2c --- /dev/null +++ b/tests/components/openexchangerates/test_config_flow.py @@ -0,0 +1,268 @@ +"""Test the Open Exchange Rates config flow.""" +import asyncio +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioopenexchangerates import ( + OpenExchangeRatesAuthError, + OpenExchangeRatesClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.openexchangerates.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="currencies", autouse=True) +def currencies_fixture(hass: HomeAssistant) -> Generator[AsyncMock, None, None]: + """Mock currencies.""" + with patch( + "homeassistant.components.openexchangerates.config_flow.Client.get_currencies", + return_value={"USD": "United States Dollar", "EUR": "Euro"}, + ) as mock_currencies: + yield mock_currencies + + +async def test_user_create_entry( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "test-api-key"}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "USD" + assert result["data"] == { + "api_key": "test-api-key", + "base": "USD", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, +) -> None: + """Test we handle invalid auth.""" + mock_latest_rates_config_flow.side_effect = OpenExchangeRatesAuthError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "bad-api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, +) -> None: + """Test we handle cannot connect error.""" + mock_latest_rates_config_flow.side_effect = OpenExchangeRatesClientError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "test-api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, +) -> None: + """Test we handle unknown error.""" + mock_latest_rates_config_flow.side_effect = Exception() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "test-api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + +async def test_already_configured_service( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we abort if the service is already configured.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "test-api-key"}, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_no_currencies(hass: HomeAssistant, currencies: AsyncMock) -> None: + """Test we abort if the service fails to retrieve currencies.""" + currencies.side_effect = OpenExchangeRatesClientError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_currencies_timeout(hass: HomeAssistant, currencies: AsyncMock) -> None: + """Test we abort if the service times out retrieving currencies.""" + + async def currencies_side_effect(): + await asyncio.sleep(1) + return {"USD": "United States Dollar", "EUR": "Euro"} + + currencies.side_effect = currencies_side_effect + + with patch( + "homeassistant.components.openexchangerates.config_flow.CLIENT_TIMEOUT", 0 + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + +async def test_latest_rates_timeout( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, +) -> None: + """Test we abort if the service times out retrieving latest rates.""" + + async def latest_rates_side_effect(*args: Any, **kwargs: Any) -> dict[str, float]: + await asyncio.sleep(1) + return {"EUR": 1.0} + + mock_latest_rates_config_flow.side_effect = latest_rates_side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.openexchangerates.config_flow.CLIENT_TIMEOUT", 0 + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"api_key": "test-api-key"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timeout_connect"} + + +async def test_reauth( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can reauthenticate the config entry.""" + mock_config_entry.add_to_hass(hass) + flow_context = { + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + "title_placeholders": {"name": mock_config_entry.title}, + "unique_id": mock_config_entry.unique_id, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context=flow_context, data=mock_config_entry.data + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + mock_latest_rates_config_flow.side_effect = OpenExchangeRatesAuthError() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "invalid-test-api-key", + }, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_auth"} + + mock_latest_rates_config_flow.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "new-test-api-key", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_create_entry( + hass: HomeAssistant, + mock_latest_rates_config_flow: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can import data from configuration.yaml.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "api_key": "test-api-key", + "base": "USD", + "quote": "EUR", + "name": "test", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "USD" + assert result["data"] == { + "api_key": "test-api-key", + "base": "USD", + "quote": "EUR", + "name": "test", + } + assert len(mock_setup_entry.mock_calls) == 1 -- GitLab