Remove name selection (#169446)

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Mike Woudenberg
2026-07-31 16:37:44 +02:00
committed by GitHub
parent e100e8cdef
commit b4e517e5d4
4 changed files with 365 additions and 147 deletions
+93 -34
View File
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID
from homeassistant.const import CONF_API_TOKEN, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -27,30 +27,43 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
DOMAIN = DOMAIN
_host: str | None = None
_locks: list[dict[str, Any]]
_api_token: str | None = None
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._locks = []
async def validate_input(
self, hass: HomeAssistant, data: dict[str, Any]
) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
# 1. Checking loqed-connection
try:
session = async_get_clientsession(hass)
session = async_get_clientsession(hass)
if self._locks and not self._host:
# Reuse the lock list already fetched during manual setup to
# avoid a duplicate cloud request.
lock_data = {"data": self._locks}
else:
cloud_api_client = cloud_loqed.CloudAPIClient(
session,
data[CONF_API_TOKEN],
)
cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client)
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed API")
raise CannotConnect from err
try:
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed API")
raise CannotConnect from err
try:
match_key, match_value = (
("bridge_ip", self._host) if self._host else ("id", data.get("lock_id"))
)
selected_lock = next(
lock
for lock in lock_data["data"]
if lock["bridge_ip"] == self._host or lock["name"] == data.get("name")
lock for lock in lock_data["data"] if lock[match_key] == match_value
)
apiclient = loqed.APIClient(session, f"http://{selected_lock['bridge_ip']}")
@@ -73,11 +86,11 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
"name": selected_lock["name"],
"id": selected_lock["id"],
}
except StopIteration:
raise InvalidAuth from StopIteration
except aiohttp.ClientError:
except StopIteration as err:
raise InvalidAuth from err
except aiohttp.ClientError as err:
_LOGGER.error("HTTP Connection error to loqed lock")
raise CannotConnect from aiohttp.ClientError
raise CannotConnect from err
@override
async def async_step_zeroconf(
@@ -103,21 +116,10 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Show userform to user."""
user_data_schema = (
vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
if self._host
else vol.Schema(
{
# Name field is no longer allowed in config flow schemas
# pylint: disable-next=home-assistant-config-flow-name-field
vol.Required(CONF_NAME): str,
vol.Required(CONF_API_TOKEN): str,
}
)
user_data_schema = vol.Schema(
{
vol.Required(CONF_API_TOKEN): str,
}
)
if user_input is None:
@@ -131,6 +133,39 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
errors = {}
# If no Zeroconf discovery and no selected lock, we need to fetch locks and show picker
if not self._host and not user_input.get("lock_id"):
session = async_get_clientsession(self.hass)
cloud_api_client = cloud_loqed.CloudAPIClient(
session,
user_input[CONF_API_TOKEN],
)
cloud_client = cloud_loqed.LoqedCloudAPI(cloud_api_client)
try:
lock_data = await cloud_client.async_get_locks()
except aiohttp.ClientError:
errors["base"] = "cannot_connect"
else:
self._locks = lock_data["data"]
self._api_token = user_input[CONF_API_TOKEN]
if not self._locks:
errors["base"] = "no_locks"
elif len(self._locks) == 1:
user_input["lock_id"] = self._locks[0]["id"]
else:
return await self.async_step_pick_lock()
if errors:
return self.async_show_form(
step_id="user",
data_schema=user_data_schema,
errors=errors,
description_placeholders={
"config_url": "https://integrations.loqed.com/personal-access-tokens",
},
)
try:
info = await self.validate_input(self.hass, user_input)
except CannotConnect:
@@ -147,10 +182,12 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="LOQED Touch Smart Lock",
data=(
user_input | {CONF_WEBHOOK_ID: webhook.async_generate_id()} | info
),
title=info["name"],
data={
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
CONF_WEBHOOK_ID: webhook.async_generate_id(),
**info,
},
)
return self.async_show_form(
@@ -162,6 +199,28 @@ class LoqedConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
async def async_step_pick_lock(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle lock selection when multiple locks are available."""
if user_input is not None:
if self._api_token is None:
return await self.async_step_user()
return await self.async_step_user(
{**user_input, CONF_API_TOKEN: self._api_token}
)
lock_options = {lock["id"]: lock["name"] for lock in self._locks}
return self.async_show_form(
step_id="pick_lock",
data_schema=vol.Schema(
{
vol.Required("lock_id"): vol.In(lock_options),
}
),
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
+9 -3
View File
@@ -5,14 +5,20 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_locks": "No locks found in your LOQED account."
},
"flow_title": "LOQED Touch Smartlock setup",
"step": {
"pick_lock": {
"data": {
"lock_id": "Select your lock"
},
"description": "Multiple locks found. Please select the lock you want to configure."
},
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"name": "Name of your lock in the LOQED app."
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"description": "Log in at LOQED's [personal access tokens portal]({config_url}) and: \n* Create an API key by clicking 'Create' \n* Copy the created access token."
}
+37 -1
View File
@@ -1,6 +1,7 @@
"""Contains fixtures for Loqed tests."""
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, Callable
from contextlib import contextmanager
import json
from typing import Any
from unittest.mock import AsyncMock, Mock, patch
@@ -15,6 +16,8 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_load_fixture
type PatchLockCreationFlow = Callable[[dict[str, Any], loqed.Lock, str], Any]
@pytest.fixture(name="config_entry")
async def config_entry_fixture(hass: HomeAssistant) -> MockConfigEntry:
@@ -100,3 +103,36 @@ async def integration_fixture(
await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
yield config_entry
@pytest.fixture(name="patch_lock_creation_flow")
def patch_lock_creation_flow_fixture() -> PatchLockCreationFlow:
"""Patch config-flow calls used when creating a lock entry."""
@contextmanager
def _patch_lock_creation_flow(
all_locks_response: dict[str, Any],
lock: loqed.Lock,
webhook_id: str,
) -> Any:
with (
patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value=all_locks_response,
),
patch(
"loqedAPI.loqed.LoqedAPI.async_get_lock",
return_value=lock,
),
patch(
"homeassistant.components.loqed.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.webhook.async_generate_id",
return_value=webhook_id,
),
):
yield
return _patch_lock_creation_flow
+226 -109
View File
@@ -1,7 +1,9 @@
"""Test the Loqed config flow."""
from collections.abc import Callable
from ipaddress import ip_address
import json
from typing import Any
from unittest.mock import Mock, patch
import aiohttp
@@ -9,7 +11,7 @@ from loqedAPI import loqed
from homeassistant import config_entries
from homeassistant.components.loqed.const import DOMAIN
from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID
from homeassistant.const import CONF_API_TOKEN, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -17,6 +19,9 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import async_load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
TEST_API_TOKEN = "eyadiuyfasiuasf"
TEST_WEBHOOK_ID = "Webhook_ID"
zeroconf_data = ZeroconfServiceInfo(
ip_address=ip_address("192.168.12.34"),
ip_addresses=[ip_address("192.168.12.34")],
@@ -28,7 +33,37 @@ zeroconf_data = ZeroconfServiceInfo(
)
async def test_create_entry_zeroconf(hass: HomeAssistant) -> None:
async def _async_init_zeroconf_flow(hass: HomeAssistant) -> dict[str, Any]:
"""Initialize a zeroconf flow and return the form result."""
lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN))
with patch(
"loqedAPI.loqed.LoqedAPI.async_get_lock_details",
return_value=lock_result,
):
return await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=zeroconf_data,
)
async def _async_init_user_flow(hass: HomeAssistant) -> dict[str, Any]:
"""Initialize a user flow and return the form result."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
return result
async def test_create_entry_zeroconf(
hass: HomeAssistant,
patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any],
) -> None:
"""Test we get can create a lock via zeroconf."""
lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN))
@@ -51,25 +86,8 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None:
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
)
with (
patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value=all_locks_response,
),
patch(
"loqedAPI.loqed.LoqedAPI.async_get_lock",
return_value=mock_lock,
),
patch(
"homeassistant.components.loqed.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.webhook.async_generate_id",
return_value=webhook_id,
),
):
result2 = await hass.config_entries.flow.async_configure(
with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_API_TOKEN: "eyadiuyfasiuasf",
@@ -78,9 +96,9 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
found_lock = all_locks_response["data"][0]
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "LOQED Touch Smart Lock"
assert result2["data"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "MyLock"
assert result["data"] == {
"id": "Foo",
"lock_key_key": found_lock["key_secret"],
"bridge_key": found_lock["bridge_key"],
@@ -95,55 +113,30 @@ async def test_create_entry_zeroconf(hass: HomeAssistant) -> None:
async def test_create_entry_user(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any],
) -> None:
"""Test we can create a lock via manual entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
result = await _async_init_user_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
lock_result = json.loads(await async_load_fixture(hass, "status_ok.json", DOMAIN))
mock_lock = Mock(spec=loqed.Lock, id="Foo")
webhook_id = "Webhook_ID"
webhook_id = TEST_WEBHOOK_ID
all_locks_response = json.loads(
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
)
found_lock = all_locks_response["data"][0]
with (
patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value=all_locks_response,
),
patch(
"loqedAPI.loqed.LoqedAPI.async_get_lock",
return_value=mock_lock,
),
patch(
"homeassistant.components.loqed.async_setup_entry",
return_value=True,
),
patch(
"homeassistant.components.webhook.async_generate_id",
return_value=webhook_id,
),
patch(
"loqedAPI.loqed.LoqedAPI.async_get_lock_details", return_value=lock_result
),
):
result2 = await hass.config_entries.flow.async_configure(
with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"},
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "LOQED Touch Smart Lock"
assert result2["data"] == {
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "MyLock"
assert result["data"] == {
"id": "Foo",
"lock_key_key": found_lock["key_secret"],
"bridge_key": found_lock["bridge_key"],
@@ -152,7 +145,56 @@ async def test_create_entry_user(
"bridge_ip": found_lock["bridge_ip"],
"name": found_lock["name"],
CONF_WEBHOOK_ID: webhook_id,
CONF_API_TOKEN: "eyadiuyfasiuasf",
CONF_API_TOKEN: TEST_API_TOKEN,
}
mock_lock.getWebhooks.assert_awaited()
async def test_create_entry_user_with_pick_lock(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any],
) -> None:
"""Test we can create a lock via manual entry when multiple locks exist."""
result = await _async_init_user_flow(hass)
mock_lock = Mock(spec=loqed.Lock, id="Foo")
webhook_id = TEST_WEBHOOK_ID
all_locks_response = json.loads(
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
)
second_lock = all_locks_response["data"][0].copy()
second_lock["id"] = "Bar"
second_lock["name"] = "MyOtherLock"
all_locks_response["data"].append(second_lock)
with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "pick_lock"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"lock_id": second_lock["id"]},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == second_lock["name"]
assert result["data"] == {
"id": second_lock["id"],
"lock_key_key": second_lock["key_secret"],
"bridge_key": second_lock["bridge_key"],
"lock_key_local_id": second_lock["local_id"],
"bridge_mdns_hostname": second_lock["bridge_hostname"],
"bridge_ip": second_lock["bridge_ip"],
"name": second_lock["name"],
CONF_WEBHOOK_ID: webhook_id,
CONF_API_TOKEN: TEST_API_TOKEN,
}
mock_lock.getWebhooks.assert_awaited()
@@ -161,10 +203,121 @@ async def test_cannot_connect(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
result = await _async_init_user_flow(hass)
with patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
side_effect=aiohttp.ClientError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_recover_after_cannot_connect(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
patch_lock_creation_flow: Callable[[dict[str, Any], loqed.Lock, str], Any],
) -> None:
"""Test we can recover from a connection error and create an entry."""
result = await _async_init_user_flow(hass)
with patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
side_effect=aiohttp.ClientError,
):
error_result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert error_result["type"] is FlowResultType.FORM
assert error_result["errors"] == {"base": "cannot_connect"}
mock_lock = Mock(spec=loqed.Lock, id="Foo")
webhook_id = TEST_WEBHOOK_ID
all_locks_response = json.loads(
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
)
found_lock = all_locks_response["data"][0]
with patch_lock_creation_flow(all_locks_response, mock_lock, webhook_id):
success_result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert success_result["type"] is FlowResultType.CREATE_ENTRY
assert success_result["title"] == "MyLock"
assert success_result["data"] == {
"id": "Foo",
"lock_key_key": found_lock["key_secret"],
"bridge_key": found_lock["bridge_key"],
"lock_key_local_id": found_lock["local_id"],
"bridge_mdns_hostname": found_lock["bridge_hostname"],
"bridge_ip": found_lock["bridge_ip"],
"name": found_lock["name"],
CONF_WEBHOOK_ID: webhook_id,
CONF_API_TOKEN: TEST_API_TOKEN,
}
mock_lock.getWebhooks.assert_awaited()
async def test_no_locks(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle a situation where the account has no locks."""
result = await _async_init_user_flow(hass)
with patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value={"data": []},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_locks"}
async def test_invalid_auth_when_lock_not_found(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle a situation where the lock is absent from the cloud API response."""
result = await _async_init_zeroconf_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
with patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value={"data": []},
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
async def test_cannot_connect_zeroconf_cloud_api_error(
hass: HomeAssistant,
) -> None:
"""Test we handle a cloud API error during zeroconf validate_input."""
result = await _async_init_zeroconf_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
@@ -173,57 +326,21 @@ async def test_cannot_connect(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
side_effect=aiohttp.ClientError,
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"},
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
async def test_invalid_auth_when_lock_not_found(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle a situation where the user enters an invalid lock name."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
all_locks_response = json.loads(
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
)
with patch(
"loqedAPI.cloud_loqed.LoqedCloudAPI.async_get_locks",
return_value=all_locks_response,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock2"},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "invalid_auth"}
assert result["errors"] == {"base": "cannot_connect"}
async def test_cannot_connect_when_lock_not_reachable(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test we handle a situation where the user enters an invalid lock name."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] is None
"""Test we handle a situation where the lock is not reachable."""
result = await _async_init_user_flow(hass)
all_locks_response = json.loads(
await async_load_fixture(hass, "get_all_locks.json", DOMAIN)
@@ -238,11 +355,11 @@ async def test_cannot_connect_when_lock_not_reachable(
"loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError
),
):
result2 = await hass.config_entries.flow.async_configure(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_API_TOKEN: "eyadiuyfasiuasf", CONF_NAME: "MyLock"},
{CONF_API_TOKEN: TEST_API_TOKEN},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "cannot_connect"}
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}