Add option flow for imap integration (#89914)

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
This commit is contained in:
Jan Bouwhuis 2023-03-27 11:47:22 +02:00 committed by GitHub
parent 0d58646823
commit 5b3c57ff1e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 197 additions and 19 deletions

View File

@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv 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]: async def validate_input(user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input.""" """Validate user input."""
@ -80,9 +88,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match( self._async_abort_entries_match(
{ {
CONF_USERNAME: user_input[CONF_USERNAME], key: user_input[key]
CONF_FOLDER: user_input[CONF_FOLDER], for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH)
CONF_SEARCH: user_input[CONF_SEARCH],
} }
) )
@ -128,3 +135,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
), ),
errors=errors, 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)

View File

@ -31,5 +31,23 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" "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%]"
}
} }
} }

View File

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

View File

@ -1,11 +1,11 @@
"""Test the imap config flow.""" """Test the imap config flow."""
import asyncio import asyncio
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from aioimaplib import AioImapException from aioimaplib import AioImapException
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries, data_entry_flow
from homeassistant.components.imap.const import ( from homeassistant.components.imap.const import (
CONF_CHARSET, CONF_CHARSET,
CONF_FOLDER, CONF_FOLDER,
@ -29,8 +29,15 @@ MOCK_CONFIG = {
"search": "UnSeen UnDeleted", "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.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -40,10 +47,7 @@ async def test_form(hass: HomeAssistant) -> None:
with patch( with patch(
"homeassistant.components.imap.config_flow.connect_to_server" "homeassistant.components.imap.config_flow.connect_to_server"
) as mock_client, patch( ) as mock_client:
"homeassistant.components.imap.async_setup_entry",
return_value=True,
) as mock_setup_entry:
mock_client.return_value.search.return_value = ( mock_client.return_value.search.return_value = (
"OK", "OK",
[b""], [b""],
@ -184,10 +188,7 @@ async def test_form_invalid_search(hass: HomeAssistant) -> None:
with patch( with patch(
"homeassistant.components.imap.config_flow.connect_to_server" "homeassistant.components.imap.config_flow.connect_to_server"
) as mock_client: ) as mock_client:
mock_client.return_value.search.return_value = ( mock_client.return_value.search.return_value = ("BAD", [b"Invalid search"])
"BAD",
[b"Invalid search"],
)
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG 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"} 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.""" """Test we can reauth."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
@ -219,10 +220,7 @@ async def test_reauth_success(hass: HomeAssistant) -> None:
with patch( with patch(
"homeassistant.components.imap.config_flow.connect_to_server" "homeassistant.components.imap.config_flow.connect_to_server"
) as mock_client, patch( ) as mock_client:
"homeassistant.components.imap.async_setup_entry",
return_value=True,
) as mock_setup_entry:
mock_client.return_value.search.return_value = ( mock_client.return_value.search.return_value = (
"OK", "OK",
[b""], [b""],
@ -310,3 +308,83 @@ async def test_reauth_failed_conn_error(hass: HomeAssistant) -> None:
assert result2["type"] == FlowResultType.FORM assert result2["type"] == FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"} 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"}