mirror of
https://github.com/home-assistant/core.git
synced 2026-09-17 14:08:42 +01:00
614 lines
19 KiB
Python
614 lines
19 KiB
Python
"""Tests for the Bitvis Power Hub config flow."""
|
|
|
|
import asyncio
|
|
from ipaddress import ip_address
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from bitvis_protobuf.parse import PayloadSample
|
|
from bitvis_protobuf.powerhub_pb2 import Payload
|
|
import pytest
|
|
|
|
from homeassistant.components.bitvis.const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
|
|
from homeassistant.components.bitvis.coordinator import async_get_listener_registry
|
|
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
|
from homeassistant.const import CONF_HOST, CONF_PORT
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.data_entry_flow import FlowResultType
|
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
|
|
|
from .conftest import (
|
|
SECOND_DEVICE_MAC,
|
|
TEST_DEVICE_MAC,
|
|
FakeListener,
|
|
patch_config_flow_connectivity,
|
|
)
|
|
|
|
from tests.common import MockConfigEntry
|
|
|
|
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
|
|
|
ZEROCONF_HOST = "192.168.1.200"
|
|
USER_HOST = "192.168.1.100"
|
|
|
|
|
|
def _zeroconf_discovery(
|
|
host: str = ZEROCONF_HOST,
|
|
name: str = "Bitvis Power Hub._powerhub._udp.local.",
|
|
port: int | None = DEFAULT_PORT,
|
|
) -> ZeroconfServiceInfo:
|
|
return ZeroconfServiceInfo(
|
|
ip_address=ip_address(host),
|
|
ip_addresses=[ip_address(host)],
|
|
hostname="powerhub.local.",
|
|
name=name,
|
|
port=port,
|
|
properties={},
|
|
type="_powerhub._udp.local.",
|
|
)
|
|
|
|
|
|
ZEROCONF_DISCOVERY = _zeroconf_discovery()
|
|
UNRELATED_HOST = "10.9.9.9"
|
|
|
|
|
|
def _invalid_mac_datagram() -> bytes:
|
|
payload = Payload()
|
|
payload.sample.SetInParent()
|
|
return payload.SerializeToString()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("input_host", "resolved_ip", "expected_host"),
|
|
[
|
|
pytest.param(USER_HOST, USER_HOST, USER_HOST, id="ipv4"),
|
|
pytest.param("2001:db8::10", "2001:db8::10", "2001:db8::10", id="ipv6"),
|
|
pytest.param(
|
|
"my-powerhub.local", "10.0.0.5", "my-powerhub.local", id="hostname"
|
|
),
|
|
pytest.param(
|
|
"[2001:db8::10]", "2001:db8::10", "2001:db8::10", id="bracketed-ipv6"
|
|
),
|
|
],
|
|
)
|
|
async def test_user_form_create_entry(
|
|
hass: HomeAssistant,
|
|
input_host: str,
|
|
resolved_ip: str,
|
|
expected_host: str,
|
|
) -> None:
|
|
"""Test creating an entry via user flow."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["step_id"] == "user"
|
|
|
|
with patch_config_flow_connectivity(resolved_ip):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: input_host,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == DEFAULT_NAME
|
|
assert result["data"] == {
|
|
CONF_HOST: expected_host,
|
|
CONF_PORT: DEFAULT_PORT,
|
|
}
|
|
assert result["result"].unique_id == TEST_DEVICE_MAC
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connectivity_kwargs", "error_key"),
|
|
[
|
|
pytest.param(
|
|
{"port_bind_side_effect": OSError("UDP port is unavailable")},
|
|
"cannot_connect",
|
|
id="cannot-connect",
|
|
),
|
|
pytest.param({"invalid_mac": True}, "invalid_mac", id="invalid-mac"),
|
|
pytest.param(
|
|
{"deliver_mac": False, "discovery_timeout": True},
|
|
"timeout_connect",
|
|
id="timeout",
|
|
),
|
|
],
|
|
)
|
|
async def test_user_form_error_and_recovery(
|
|
hass: HomeAssistant,
|
|
connectivity_kwargs: dict[str, object],
|
|
error_key: str,
|
|
) -> None:
|
|
"""Test user form error then successful recovery."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
with patch_config_flow_connectivity(USER_HOST, **connectivity_kwargs):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["errors"] == {"base": error_key}
|
|
|
|
with patch_config_flow_connectivity(USER_HOST):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == DEFAULT_NAME
|
|
assert result["data"] == {
|
|
CONF_HOST: USER_HOST,
|
|
CONF_PORT: DEFAULT_PORT,
|
|
}
|
|
assert result["result"].unique_id == TEST_DEVICE_MAC
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"input_host",
|
|
[
|
|
pytest.param(USER_HOST, id="same-host"),
|
|
pytest.param("192.168.1.101", id="different-host"),
|
|
],
|
|
)
|
|
async def test_user_form_duplicate_mac(
|
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry, input_host: str
|
|
) -> None:
|
|
"""Test duplicate detection is based on MAC address, not host."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
with patch_config_flow_connectivity(input_host):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: input_host,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "already_configured"
|
|
|
|
|
|
async def test_user_form_reused_ip_new_device(
|
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
|
) -> None:
|
|
"""Test a new device can be added at an IP already stored on another entry."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
with patch_config_flow_connectivity(USER_HOST, mac_address=SECOND_DEVICE_MAC):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["data"] == {
|
|
CONF_HOST: USER_HOST,
|
|
CONF_PORT: DEFAULT_PORT,
|
|
}
|
|
assert result["result"].unique_id == SECOND_DEVICE_MAC
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("connectivity_kwargs", "reason"),
|
|
[
|
|
pytest.param(
|
|
{"port_bind_side_effect": OSError("UDP port is unavailable")},
|
|
"cannot_connect",
|
|
id="cannot-connect",
|
|
),
|
|
pytest.param({"invalid_mac": True}, "invalid_mac", id="invalid-mac"),
|
|
pytest.param(
|
|
{"deliver_mac": False, "discovery_timeout": True},
|
|
"timeout_connect",
|
|
id="timeout",
|
|
),
|
|
],
|
|
)
|
|
async def test_zeroconf_abort(
|
|
hass: HomeAssistant,
|
|
connectivity_kwargs: dict[str, object],
|
|
reason: str,
|
|
) -> None:
|
|
"""Test zeroconf discovery abort reasons."""
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST, **connectivity_kwargs):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == reason
|
|
|
|
|
|
async def test_zeroconf_duplicate(
|
|
hass: HomeAssistant, mock_zeroconf_config_entry: MockConfigEntry
|
|
) -> None:
|
|
"""Test that a duplicate zeroconf discovery is aborted by MAC address."""
|
|
mock_zeroconf_config_entry.add_to_hass(hass)
|
|
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "already_configured"
|
|
|
|
|
|
async def test_zeroconf_reused_ip_new_device(
|
|
hass: HomeAssistant, mock_zeroconf_config_entry: MockConfigEntry
|
|
) -> None:
|
|
"""Test zeroconf can add a new device at an IP already stored on another entry."""
|
|
mock_zeroconf_config_entry.add_to_hass(hass)
|
|
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST, mac_address=SECOND_DEVICE_MAC):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["step_id"] == "zeroconf_confirm"
|
|
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={}
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["data"] == {
|
|
CONF_HOST: ZEROCONF_HOST,
|
|
CONF_PORT: DEFAULT_PORT,
|
|
}
|
|
assert result["result"].unique_id == SECOND_DEVICE_MAC
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "port", "expected_title"),
|
|
[
|
|
pytest.param(
|
|
"Bitvis Power Hub._powerhub._udp.local.",
|
|
DEFAULT_PORT,
|
|
"Bitvis Power Hub",
|
|
id="happy-path",
|
|
),
|
|
pytest.param(
|
|
"Bitvis Power Hub._powerhub._udp.local.",
|
|
None,
|
|
"Bitvis Power Hub",
|
|
id="none-port",
|
|
),
|
|
pytest.param(
|
|
"My Custom Hub._powerhub._udp.local.",
|
|
DEFAULT_PORT,
|
|
"My Custom Hub",
|
|
id="friendly-name",
|
|
),
|
|
pytest.param("", DEFAULT_PORT, DEFAULT_NAME, id="empty-name"),
|
|
pytest.param(
|
|
"._powerhub._udp.local.", DEFAULT_PORT, DEFAULT_NAME, id="dot-prefixed"
|
|
),
|
|
],
|
|
)
|
|
async def test_zeroconf_create_entry(
|
|
hass: HomeAssistant,
|
|
name: str,
|
|
port: int | None,
|
|
expected_title: str,
|
|
) -> None:
|
|
"""Test zeroconf confirm creates an entry with title, data, and unique_id."""
|
|
discovery = _zeroconf_discovery(name=name, port=port)
|
|
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=discovery,
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["step_id"] == "zeroconf_confirm"
|
|
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"], user_input={}
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["title"] == expected_title
|
|
assert result["data"] == {
|
|
CONF_HOST: ZEROCONF_HOST,
|
|
CONF_PORT: DEFAULT_PORT,
|
|
}
|
|
assert result["result"].unique_id == TEST_DEVICE_MAC
|
|
|
|
|
|
async def test_zeroconf_updates_host_on_new_ip(
|
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
|
) -> None:
|
|
"""Test rediscovery on a new IP updates the stored host and aborts."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
assert mock_config_entry.data[CONF_HOST] != ZEROCONF_HOST
|
|
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "already_configured"
|
|
assert mock_config_entry.data[CONF_HOST] == ZEROCONF_HOST
|
|
|
|
|
|
async def test_aborted_flow_removes_listener(
|
|
hass: HomeAssistant,
|
|
mock_shared_listener: FakeListener,
|
|
) -> None:
|
|
"""Test listener is stopped after an aborted config flow."""
|
|
with patch_config_flow_connectivity(
|
|
USER_HOST,
|
|
deliver_mac=False,
|
|
discovery_timeout=True,
|
|
shared_listener=mock_shared_listener,
|
|
):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.FORM
|
|
assert result["errors"] == {"base": "timeout_connect"}
|
|
mock_shared_listener.stop.assert_awaited_once()
|
|
assert not async_get_listener_registry(hass).has_listener(DEFAULT_PORT)
|
|
|
|
|
|
async def test_invalid_mac_from_other_host_is_ignored(
|
|
hass: HomeAssistant, mock_shared_listener: FakeListener
|
|
) -> None:
|
|
"""Test an invalid-MAC datagram from another host does not fail the flow."""
|
|
with patch_config_flow_connectivity(
|
|
USER_HOST, deliver_mac=False, shared_listener=mock_shared_listener
|
|
):
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
configure_task = asyncio.create_task(
|
|
hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
mock_shared_listener.dispatch(_invalid_mac_datagram(), (UNRELATED_HOST, 1234))
|
|
mock_shared_listener.deliver(
|
|
PayloadSample(mac_address=TEST_DEVICE_MAC, sample=MagicMock()),
|
|
(USER_HOST, 1234),
|
|
)
|
|
result = await configure_task
|
|
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert result["result"].unique_id == TEST_DEVICE_MAC
|
|
|
|
|
|
async def test_invalid_mac_does_not_fail_other_flow(
|
|
hass: HomeAssistant, mock_shared_listener: FakeListener
|
|
) -> None:
|
|
"""Test an invalid-MAC datagram only fails the flow waiting for that host."""
|
|
|
|
async def resolve_host(host: str) -> set[str]:
|
|
return {host}
|
|
|
|
with (
|
|
patch(
|
|
"homeassistant.components.bitvis.config_flow.async_verify_udp_port_bindable",
|
|
new_callable=AsyncMock,
|
|
),
|
|
patch(
|
|
"homeassistant.components.bitvis.config_flow.async_resolve_host",
|
|
side_effect=resolve_host,
|
|
),
|
|
patch(
|
|
"homeassistant.components.bitvis.coordinator.SharedListener",
|
|
return_value=mock_shared_listener,
|
|
),
|
|
):
|
|
first_result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
first_task = asyncio.create_task(
|
|
hass.config_entries.flow.async_configure(
|
|
first_result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
second_result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
second_task = asyncio.create_task(
|
|
hass.config_entries.flow.async_configure(
|
|
second_result["flow_id"],
|
|
{
|
|
CONF_HOST: ZEROCONF_HOST,
|
|
},
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
mock_shared_listener.dispatch(_invalid_mac_datagram(), (ZEROCONF_HOST, 1234))
|
|
mock_shared_listener.deliver(
|
|
PayloadSample(mac_address=TEST_DEVICE_MAC, sample=MagicMock()),
|
|
(USER_HOST, 1234),
|
|
)
|
|
first_result = await first_task
|
|
second_result = await second_task
|
|
|
|
assert first_result["type"] is FlowResultType.CREATE_ENTRY
|
|
assert first_result["result"].unique_id == TEST_DEVICE_MAC
|
|
assert second_result["type"] is FlowResultType.FORM
|
|
assert second_result["errors"] == {"base": "invalid_mac"}
|
|
|
|
|
|
async def test_concurrent_flow_same_host_aborts(hass: HomeAssistant) -> None:
|
|
"""Test concurrent flows for the same host abort with already_in_progress."""
|
|
with patch_config_flow_connectivity(USER_HOST, deliver_mac=False):
|
|
first_result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
first_task = asyncio.create_task(
|
|
hass.config_entries.flow.async_configure(
|
|
first_result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
second_result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
second_result = await hass.config_entries.flow.async_configure(
|
|
second_result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
first_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first_task
|
|
|
|
assert second_result["type"] is FlowResultType.ABORT
|
|
assert second_result["reason"] == "already_in_progress"
|
|
|
|
|
|
async def test_discovery_register_runtime_error_aborts(hass: HomeAssistant) -> None:
|
|
"""Test discovery aborts when filter registration raises RuntimeError."""
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
with patch_config_flow_connectivity(
|
|
USER_HOST,
|
|
deliver_mac=False,
|
|
register_side_effect=RuntimeError("Filter already registered"),
|
|
):
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: USER_HOST,
|
|
},
|
|
)
|
|
|
|
assert result["type"] is FlowResultType.ABORT
|
|
assert result["reason"] == "already_in_progress"
|
|
|
|
|
|
async def test_zeroconf_concurrent_flow_same_host_aborts(hass: HomeAssistant) -> None:
|
|
"""Test concurrent zeroconf flows for the same host abort."""
|
|
with patch_config_flow_connectivity(ZEROCONF_HOST, deliver_mac=False):
|
|
first_task = asyncio.create_task(
|
|
hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
)
|
|
await hass.async_block_till_done()
|
|
|
|
second_result = await hass.config_entries.flow.async_init(
|
|
DOMAIN,
|
|
context={"source": SOURCE_ZEROCONF},
|
|
data=ZEROCONF_DISCOVERY,
|
|
)
|
|
|
|
first_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first_task
|
|
|
|
assert second_result["type"] is FlowResultType.ABORT
|
|
assert second_result["reason"] == "already_in_progress"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("listener_already_running", "expected_awaits"),
|
|
[
|
|
pytest.param(True, 0, id="listener-exists"),
|
|
pytest.param(False, 1, id="no-listener"),
|
|
],
|
|
)
|
|
async def test_user_form_port_bind_check(
|
|
hass: HomeAssistant,
|
|
mock_shared_listener: FakeListener,
|
|
listener_already_running: bool,
|
|
expected_awaits: int,
|
|
) -> None:
|
|
"""Test user flow skips the port bind check only when a listener exists."""
|
|
if listener_already_running:
|
|
with patch(
|
|
"homeassistant.components.bitvis.coordinator.SharedListener",
|
|
return_value=mock_shared_listener,
|
|
):
|
|
await async_get_listener_registry(hass).async_get_or_create(DEFAULT_PORT)
|
|
|
|
result = await hass.config_entries.flow.async_init(
|
|
DOMAIN, context={"source": SOURCE_USER}
|
|
)
|
|
|
|
kwargs: dict[str, object] = {
|
|
"mac_address": SECOND_DEVICE_MAC
|
|
if listener_already_running
|
|
else TEST_DEVICE_MAC
|
|
}
|
|
if listener_already_running:
|
|
kwargs["shared_listener"] = mock_shared_listener
|
|
|
|
with patch_config_flow_connectivity("192.168.1.101", **kwargs) as mock_verify:
|
|
result = await hass.config_entries.flow.async_configure(
|
|
result["flow_id"],
|
|
{
|
|
CONF_HOST: "192.168.1.101",
|
|
},
|
|
)
|
|
|
|
assert mock_verify.await_count == expected_awaits
|
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|