From 5b3c57ff1e954273b7e24d600697bcbd98321349 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 27 Mar 2023 11:47:22 +0200 Subject: [PATCH] Add option flow for imap integration (#89914) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/imap/config_flow.py | 74 ++++++++++++- homeassistant/components/imap/strings.json | 18 +++ tests/components/imap/conftest.py | 14 +++ tests/components/imap/test_config_flow.py | 110 ++++++++++++++++--- 4 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 tests/components/imap/conftest.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index de1ac1e5d65..c855d099b4a 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -36,6 +37,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FOLDER, default="INBOX"): str, + vol.Optional(CONF_SEARCH, default="UnSeen UnDeleted"): str, + } +) + async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: """Validate user input.""" @@ -80,9 +88,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match( { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_FOLDER: user_input[CONF_FOLDER], - CONF_SEARCH: user_input[CONF_SEARCH], + key: user_input[key] + for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) } ) @@ -128,3 +135,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlow: + """Get the options flow for this handler.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): + """Option flow handler.""" + + def _async_abort_entries_match( + self, match_dict: dict[str, Any] | None + ) -> dict[str, str]: + """Validate the user input against other config entries.""" + if match_dict is None: + return {} + + errors: dict[str, str] = {} + for entry in [ + entry + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry is not self.config_entry + ]: + if all(item in entry.data.items() for item in match_dict.items()): + errors["base"] = "already_configured" + break + return errors + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + errors: dict[str, str] = self._async_abort_entries_match( + { + CONF_SERVER: self._config_entry.data[CONF_SERVER], + CONF_USERNAME: self._config_entry.data[CONF_USERNAME], + CONF_FOLDER: user_input[CONF_FOLDER], + CONF_SEARCH: user_input[CONF_SEARCH], + } + if user_input + else None + ) + entry_data: dict[str, Any] = dict(self._config_entry.data) + if not errors and user_input is not None: + entry_data.update(user_input) + errors = await validate_input(entry_data) + if not errors: + self.hass.config_entries.async_update_entry( + self.config_entry, data=entry_data + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.config_entry.entry_id) + ) + return self.async_create_entry(data={}) + + schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + + return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index bb03f82bb76..d104f591c63 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -31,5 +31,23 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "folder": "[%key:component::imap::config::step::user::data::folder%]", + "search": "[%key:component::imap::config::step::user::data::search%]" + } + } + }, + "error": { + "already_configured": "An entry with these folder and search options already exists", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", + "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", + "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + } } } diff --git a/tests/components/imap/conftest.py b/tests/components/imap/conftest.py new file mode 100644 index 00000000000..bc82cf57d81 --- /dev/null +++ b/tests/components/imap/conftest.py @@ -0,0 +1,14 @@ +"""Test the iamp config flow.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.imap.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 663637ff0ba..20c9ddf8938 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -1,11 +1,11 @@ """Test the imap config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest -from homeassistant import config_entries +from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( CONF_CHARSET, CONF_FOLDER, @@ -29,8 +29,15 @@ MOCK_CONFIG = { "search": "UnSeen UnDeleted", } +MOCK_OPTIONS = { + "folder": "INBOX", + "search": "UnSeen UnDeleted", +} -async def test_form(hass: HomeAssistant) -> None: +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, 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} @@ -40,10 +47,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -184,10 +188,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" ) as mock_client: - mock_client.return_value.search.return_value = ( - "BAD", - [b"Invalid search"], - ) + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], MOCK_CONFIG ) @@ -196,7 +197,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None: assert result2["errors"] == {CONF_SEARCH: "invalid_search"} -async def test_reauth_success(hass: HomeAssistant) -> None: +async def test_reauth_success(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we can reauth.""" entry = MockConfigEntry( domain=DOMAIN, @@ -219,10 +220,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None: with patch( "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ) as mock_client: mock_client.return_value.search.return_value = ( "OK", [b""], @@ -310,3 +308,83 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None: assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we show the options form.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config["folder"] = "INBOX.Notifications" + new_config["search"] = "UnSeen UnDeleted!!INVALID" + + # simulate initial search setup error + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {CONF_SEARCH: "invalid_search"} + + new_config["search"] = "UnSeen UnDeleted" + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result3["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result3["data"] == {} + for key, value in new_config.items(): + assert entry.data[key] == value + + +async def test_key_options_in_options_form(hass: HomeAssistant) -> None: + """Test we cannot change options if that would cause duplicates.""" + + entry1 = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry1.add_to_hass(hass) + await hass.config_entries.async_setup(entry1.entry_id) + + config2 = MOCK_CONFIG.copy() + config2["folder"] = "INBOX.Notifications" + entry2 = MockConfigEntry(domain=DOMAIN, data=config2) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + + # Now try to set back the folder option of entry2 + # so that it conflicts with that of entry1 + result = await hass.config_entries.options.async_init(entry2.entry_id) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + new_config, + ) + await hass.async_block_till_done() + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "already_configured"}