Add openexchangerates config flow (#76390)

This commit is contained in:
Martin Hjelmare
2022-08-07 23:45:32 +02:00
committed by GitHub
parent ceecab9559
commit d1ab93fbaf
15 changed files with 661 additions and 82 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -5,3 +5,5 @@ import logging
DOMAIN = "openexchangerates"
LOGGER = logging.getLogger(__package__)
BASE_UPDATE_INTERVAL = timedelta(hours=2)
CLIENT_TIMEOUT = 10
DEFAULT_BASE = "USD"

View File

@ -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)

View File

@ -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
}

View File

@ -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
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,
)
coordinator = coordinators[api_key, base] = OpenexchangeratesCoordinator(
hass,
async_get_clientsession(hass),
api_key,
base,
update_interval,
)
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"
)
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
)
LOGGER.debug(
"Coordinator update interval set to: %s", coordinator.update_interval
)
# 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()
if not coordinator.last_update_success:
raise PlatformNotReady
async_add_entities([OpenexchangeratesSensor(coordinator, name, 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

View File

@ -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."
}
}
}

View File

@ -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"
}
}
}

View File

@ -258,6 +258,7 @@ FLOWS = {
"onewire",
"onvif",
"open_meteo",
"openexchangerates",
"opengarage",
"opentherm_gw",
"openuv",

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Open Exchange Rates integration."""

View File

@ -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

View File

@ -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