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