Files
core/tests/components/airgradient/test_config_flow.py
T

647 lines
20 KiB
Python

"""Tests for the AirGradient config flow."""
from dataclasses import replace
from ipaddress import ip_address
from unittest.mock import ANY, AsyncMock, MagicMock
from airgradient import (
AirGradientBusyError,
AirGradientConnectionError,
AirGradientError,
AirGradientParseError,
ApiVersion,
ConfigurationControl,
)
import pytest
from homeassistant.components.airgradient.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
OLD_ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("10.0.0.131"),
ip_addresses=[ip_address("10.0.0.131")],
hostname="airgradient_84fce612f5b8.local.",
name="airgradient_84fce612f5b8._airgradient._tcp.local.",
port=80,
type="_airgradient._tcp.local.",
properties={
"vendor": "AirGradient",
"fw_ver": "3.0.8",
"serialno": "84fce612f5b8",
"model": "I-9PSL",
},
)
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("10.0.0.131"),
ip_addresses=[ip_address("10.0.0.131")],
hostname="airgradient_84fce612f5b8.local.",
name="airgradient_84fce612f5b8._airgradient._tcp.local.",
port=80,
type="_airgradient._tcp.local.",
properties={
"vendor": "AirGradient",
"fw_ver": "3.1.1",
"serialno": "84fce612f5b8",
"model": "I-9PSL",
},
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_full_flow(
hass: HomeAssistant, mock_new_airgradient_client: AsyncMock
) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "I-9PSL"
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "84fce612f5b8"
mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with(
ConfigurationControl.LOCAL
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_flow_with_registered_device(
hass: HomeAssistant, mock_cloud_airgradient_client: AsyncMock
) -> None:
"""Test we don't revert the cloud setting."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == "84fce612f5b8"
mock_cloud_airgradient_client.set_configuration_control.assert_not_called()
@pytest.mark.usefixtures("mock_setup_entry")
async def test_flow_errors(
hass: HomeAssistant, mock_airgradient_client: AsyncMock
) -> None:
"""Test flow errors."""
mock_airgradient_client.get_current_measures.side_effect = (
AirGradientConnectionError()
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
mock_airgradient_client.get_current_measures.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("method", "exception"),
[
pytest.param(
"get_config", AirGradientConnectionError(), id="read-configuration"
),
pytest.param(
"set_configuration_control",
AirGradientBusyError(status=503, code="busy"),
id="write-configuration",
),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_flow_config_source_errors(
hass: HomeAssistant,
mock_v1_airgradient_client: AsyncMock,
method: str,
exception: AirGradientError,
) -> None:
"""Test errors while setting the configuration source."""
getattr(mock_v1_airgradient_client, method).side_effect = exception
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: "10.0.0.131"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
getattr(mock_v1_airgradient_client, method).side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_flow_old_firmware_version(
hass: HomeAssistant, mock_airgradient_client: AsyncMock
) -> None:
"""Test flow with old firmware version."""
mock_airgradient_client.get_current_measures.side_effect = AirGradientParseError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_version"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_duplicate(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test duplicate flow."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow(
hass: HomeAssistant, mock_new_airgradient_client: AsyncMock
) -> None:
"""Test zeroconf flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "I-9PSL"
assert result["data"] == {
CONF_HOST: "10.0.0.131",
}
assert result["result"].unique_id == "84fce612f5b8"
mock_new_airgradient_client.set_configuration_control.assert_awaited_once_with(
ConfigurationControl.LOCAL
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow_cloud_device(
hass: HomeAssistant, mock_cloud_airgradient_client: AsyncMock
) -> None:
"""Test zeroconf flow doesn't revert the cloud setting."""
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"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
mock_cloud_airgradient_client.set_configuration_control.assert_not_called()
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow_config_source_error(
hass: HomeAssistant, mock_new_airgradient_client: AsyncMock
) -> None:
"""Test errors while setting the discovered device configuration source."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
mock_new_airgradient_client.set_configuration_control.side_effect = (
AirGradientBusyError(status=503, code="busy")
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["errors"] == {"base": "cannot_connect"}
mock_new_airgradient_client.set_configuration_control.side_effect = None
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
("exception", "reason"),
[
pytest.param(AirGradientParseError, "invalid_version", id="parse-error"),
pytest.param(
AirGradientConnectionError, "cannot_connect", id="connection-error"
),
],
)
async def test_zeroconf_flow_client_errors(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
exception: type[AirGradientError],
reason: str,
) -> None:
"""Test errors while reading a discovered device."""
mock_airgradient_client.get_current_measures.side_effect = exception
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
@pytest.mark.parametrize(
"firmware_version",
[
pytest.param("3.0.8", id="old"),
pytest.param("invalid", id="invalid"),
],
)
async def test_zeroconf_flow_abort_unsupported_firmware(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
firmware_version: str,
) -> None:
"""Test zeroconf flow aborts with unsupported firmware."""
discovery_info = replace(
OLD_ZEROCONF_DISCOVERY,
properties={
**OLD_ZEROCONF_DISCOVERY.properties,
"fw_ver": firmware_version,
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_version"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow_v1_hint(
hass: HomeAssistant,
mock_airgradient_client_class: MagicMock,
mock_v1_airgradient_client: AsyncMock,
) -> None:
"""Test zeroconf V1 hint seeds the client and skips the legacy gate."""
discovery_info = replace(
ZEROCONF_DISCOVERY,
properties={
**ZEROCONF_DISCOVERY.properties,
"api": "1",
"fw_ver": "1.0.0",
"model": "P-1PSG",
},
)
mock_v1_airgradient_client.get_config.return_value.configuration_control = (
ConfigurationControl.LOCAL
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
mock_airgradient_client_class.assert_called_once_with(
"10.0.0.131", session=ANY, api_version=ApiVersion.V1
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
"properties",
[
pytest.param(dict(ZEROCONF_DISCOVERY.properties), id="missing"),
pytest.param({**ZEROCONF_DISCOVERY.properties, "api": "2"}, id="unknown"),
],
)
@pytest.mark.usefixtures("mock_airgradient_client", "mock_setup_entry")
async def test_zeroconf_flow_probes_for_missing_or_unknown_api_hint(
hass: HomeAssistant,
mock_airgradient_client_class: MagicMock,
properties: dict[str, str],
) -> None:
"""Test missing and unknown API hints leave the client unseeded."""
discovery_info = replace(ZEROCONF_DISCOVERY, properties=properties)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.FORM
mock_airgradient_client_class.assert_called_once_with(
"10.0.0.131", session=ANY, api_version=None
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_airgradient_client", "mock_setup_entry")
async def test_zeroconf_flow_stale_v1_hint_applies_legacy_gate(
hass: HomeAssistant,
mock_airgradient_client_class: MagicMock,
) -> None:
"""Test a stale V1 hint still enforces the legacy minimum firmware."""
discovery_info = replace(
OLD_ZEROCONF_DISCOVERY,
properties={**OLD_ZEROCONF_DISCOVERY.properties, "api": "1"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=discovery_info,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_version"
mock_airgradient_client_class.assert_called_once_with(
"10.0.0.131", session=ANY, api_version=ApiVersion.V1
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_v1_skips_legacy_firmware_gate(
hass: HomeAssistant, mock_v1_airgradient_client: AsyncMock
) -> None:
"""Test manual setup skips the legacy firmware minimum for API V1."""
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: "10.0.0.131"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize(
"firmware_version",
[
pytest.param("3.0.8", id="old"),
pytest.param("invalid", id="invalid"),
],
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_legacy_firmware_gate(
hass: HomeAssistant,
mock_airgradient_client: AsyncMock,
firmware_version: str,
) -> None:
"""Test manual legacy setup enforces the minimum firmware version."""
mock_airgradient_client.get_current_measures.return_value.firmware_version = (
firmware_version
)
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: "10.0.0.131"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "invalid_version"
async def test_zeroconf_flow_abort_duplicate(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test zeroconf flow aborts with duplicate."""
mock_config_entry.add_to_hass(hass)
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"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_works_discovery(
hass: HomeAssistant, mock_new_airgradient_client: AsyncMock
) -> None:
"""Test user flow can continue after discovery happened."""
await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
# Verify the discovery flow was aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_new_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.131"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
CONF_HOST: "10.0.0.131",
}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_errors(
hass: HomeAssistant,
mock_new_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow."""
mock_config_entry.add_to_hass(hass)
mock_new_airgradient_client.get_current_measures.side_effect = (
AirGradientConnectionError()
)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
mock_new_airgradient_client.get_current_measures.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
CONF_HOST: "10.0.0.132",
}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_flow_unique_id_mismatch(
hass: HomeAssistant,
mock_new_airgradient_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow aborts with unique id mismatch."""
mock_config_entry.add_to_hass(hass)
mock_new_airgradient_client.get_current_measures.return_value.serial_number = (
"84fce612f5b9"
)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: "10.0.0.132"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"
assert mock_config_entry.data == {
CONF_HOST: "10.0.0.131",
}