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

View file

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

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