feat(config_flow): Einrichtung per IP und mDNS, Neuanmeldung, Neukonfiguration, Optionen
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
"""Config flow: manual, zeroconf, reauth, reconfigure and options."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ipaddress import ip_address
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
|
||||
from homeassistant.const import CONF_HOST, CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType, InvalidData
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
import pytest
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.ninlight.api import (
|
||||
NinlightAuthError,
|
||||
NinlightConnectionError,
|
||||
NinlightResponseError,
|
||||
)
|
||||
from custom_components.ninlight.const import (
|
||||
CONF_LAMP_BASE_URL,
|
||||
CONF_SCAN_INTERVAL,
|
||||
DEFAULT_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from tests_unit.samples import HOST_RGB, INFO_CW, MAC_RGB, TOKEN
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
|
||||
|
||||
NEW_TOKEN = "b71e0c9d44a2f6e3"
|
||||
|
||||
|
||||
def zeroconf_info(ip: str = HOST_RGB, **properties: Any) -> ZeroconfServiceInfo:
|
||||
props = {"type": "gu10_rgb", "fw": "0.3.8-36-gca9bb", "mac": MAC_RGB, "name": ""} | properties
|
||||
return ZeroconfServiceInfo(
|
||||
ip_address=ip_address(ip),
|
||||
ip_addresses=[ip_address(ip)],
|
||||
port=3333,
|
||||
hostname="ninlight-06b8.local.",
|
||||
type="_ninlight._tcp.local.",
|
||||
name="ninlight-06b8._ninlight._tcp.local.",
|
||||
properties={k: v for k, v in props.items() if v is not None},
|
||||
)
|
||||
|
||||
|
||||
def existing_entry(hass: HomeAssistant, host: str = HOST_RGB) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, title="Lampe", unique_id=MAC_RGB, data={CONF_HOST: host, CONF_TOKEN: TOKEN}
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def test_user_flow_creates_entry(hass: HomeAssistant, mock_client: MagicMock) -> None:
|
||||
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: " 192.168.2.22 ", CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Klyqa GU10 RGB CW/WW 06b8"
|
||||
assert result["data"] == {CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}
|
||||
assert result["result"].unique_id == MAC_RGB
|
||||
mock_client.get_settings.assert_awaited_once() # the read that enforces the token
|
||||
|
||||
|
||||
async def test_user_form_prefills_default_token(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER})
|
||||
schema = result["data_schema"].schema
|
||||
token_key = next(key for key in schema if key == CONF_TOKEN)
|
||||
assert token_key.default() == DEFAULT_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "error", "expected"),
|
||||
[
|
||||
("get_settings", NinlightAuthError("401"), "invalid_auth"),
|
||||
("get_settings", NinlightConnectionError("down"), "cannot_connect"),
|
||||
("get_info", NinlightResponseError("no JSON"), "not_ninlight"),
|
||||
("get_settings", RuntimeError("boom"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_flow_errors_then_recovers(
|
||||
hass: HomeAssistant, mock_client: MagicMock, method: str, error: Exception, expected: str
|
||||
) -> None:
|
||||
mock = getattr(mock_client, method)
|
||||
original = mock.side_effect
|
||||
mock.side_effect = error
|
||||
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: HOST_RGB, CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": expected}
|
||||
mock.side_effect = original
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_user_flow_same_lamp_under_new_address_updates_host(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
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: "192.168.2.99", CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_HOST] == "192.168.2.99"
|
||||
|
||||
|
||||
async def test_zeroconf_flow_creates_entry(hass: HomeAssistant, mock_client: MagicMock) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info()
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "zeroconf_confirm"
|
||||
assert result["description_placeholders"] == {"name": "gu10_rgb", "host": HOST_RGB}
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {CONF_TOKEN: TOKEN})
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {CONF_HOST: HOST_RGB, CONF_TOKEN: TOKEN}
|
||||
|
||||
|
||||
async def test_zeroconf_uses_announced_name(hass: HomeAssistant, mock_client: MagicMock) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(name="Flur")
|
||||
)
|
||||
assert result["description_placeholders"]["name"] == "Flur"
|
||||
|
||||
|
||||
async def test_zeroconf_known_lamp_with_new_ip_updates_host(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(ip="192.168.2.77")
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert entry.data[CONF_HOST] == "192.168.2.77"
|
||||
mock_client.get_settings.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_zeroconf_without_mac_is_not_ninlight(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info(mac=None)
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "not_ninlight"
|
||||
|
||||
|
||||
async def test_zeroconf_confirm_shows_auth_error(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
mock_client.get_settings.side_effect = NinlightAuthError("401")
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_info()
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_TOKEN: "falsch123"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_reauth_updates_token(hass: HomeAssistant, mock_client: MagicMock) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_TOKEN: NEW_TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert entry.data == {CONF_HOST: HOST_RGB, CONF_TOKEN: NEW_TOKEN}
|
||||
|
||||
|
||||
async def test_reauth_rejects_other_lamp(
|
||||
hass: HomeAssistant, mock_client: MagicMock, lamp_info: dict[str, Any]
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
lamp_info["mac"] = INFO_CW["mac"]
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_TOKEN: NEW_TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "wrong_device"
|
||||
assert entry.data[CONF_TOKEN] == TOKEN
|
||||
|
||||
|
||||
async def test_reauth_shows_error_for_wrong_token(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
mock_client.get_settings.side_effect = NinlightAuthError("401")
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_TOKEN: "falsch123"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_reconfigure_updates_host_and_token(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
assert result["step_id"] == "reconfigure"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.2.40", CONF_TOKEN: NEW_TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert entry.data == {CONF_HOST: "192.168.2.40", CONF_TOKEN: NEW_TOKEN}
|
||||
|
||||
|
||||
async def test_reconfigure_rejects_other_lamp(
|
||||
hass: HomeAssistant, mock_client: MagicMock, lamp_info: dict[str, Any]
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
lamp_info["mac"] = INFO_CW["mac"]
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.2.40", CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "wrong_device"
|
||||
assert entry.data[CONF_HOST] == HOST_RGB
|
||||
|
||||
|
||||
async def test_reconfigure_shows_connection_error(
|
||||
hass: HomeAssistant, mock_client: MagicMock
|
||||
) -> None:
|
||||
entry = existing_entry(hass)
|
||||
mock_client.get_settings.side_effect = NinlightConnectionError("down")
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_HOST: "192.168.2.40", CONF_TOKEN: TOKEN}
|
||||
)
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_options_store_interval_and_normalized_base_url(hass: HomeAssistant) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SCAN_INTERVAL: 45, CONF_LAMP_BASE_URL: " http://192.168.1.50:8123/ "},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert entry.options == {CONF_SCAN_INTERVAL: 45, CONF_LAMP_BASE_URL: "http://192.168.1.50:8123"}
|
||||
|
||||
|
||||
async def test_options_empty_base_url_is_not_stored(hass: HomeAssistant) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], {CONF_SCAN_INTERVAL: 30, CONF_LAMP_BASE_URL: ""}
|
||||
)
|
||||
assert entry.options == {CONF_SCAN_INTERVAL: 30}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("url", "error"),
|
||||
[
|
||||
("https://192.168.1.50:8123", "invalid_base_url"),
|
||||
("http://" + "a" * 183, "base_url_too_long"),
|
||||
],
|
||||
)
|
||||
async def test_options_reject_unusable_base_url(hass: HomeAssistant, url: str, error: str) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], {CONF_SCAN_INTERVAL: 30, CONF_LAMP_BASE_URL: url}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_LAMP_BASE_URL: error}
|
||||
|
||||
|
||||
async def test_options_reject_interval_out_of_range(hass: HomeAssistant) -> None:
|
||||
entry = existing_entry(hass)
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
with pytest.raises(InvalidData):
|
||||
await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], {CONF_SCAN_INTERVAL: 5}
|
||||
)
|
||||
Reference in New Issue
Block a user