"""Configuration flow tests for the Tailwind integration.""" from ipaddress import ip_address from unittest.mock import MagicMock from gotailwind import ( TailwindAuthenticationError, TailwindConnectionError, TailwindUnsupportedFirmwareVersionError, ) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_tailwind") async def test_user_flow(hass: HomeAssistant) -> None: """Test the full happy path user flow from start to finish.""" 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"], user_input={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == "3c:e9:0e:6d:21:84" assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", } assert not config_entry.options @pytest.mark.parametrize( ("side_effect", "expected_error"), [ (TailwindConnectionError, {CONF_HOST: "cannot_connect"}), (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), (Exception, {"base": "unknown"}), ], ) async def test_user_flow_errors( hass: HomeAssistant, mock_tailwind: MagicMock, side_effect: Exception, expected_error: dict[str, str], ) -> None: """Test we show user form on a connection error.""" mock_tailwind.status.side_effect = side_effect result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == expected_error mock_tailwind.status.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.2", CONF_TOKEN: "123456", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == "3c:e9:0e:6d:21:84" assert config_entry.data == { CONF_HOST: "127.0.0.2", CONF_TOKEN: "123456", } assert not config_entry.options async def test_user_flow_unsupported_firmware_version( hass: HomeAssistant, mock_tailwind: MagicMock ) -> None: """Test configuration flow aborts when the firmware version is not supported.""" mock_tailwind.status.side_effect = TailwindUnsupportedFirmwareVersionError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_firmware" @pytest.mark.usefixtures("mock_tailwind") async def test_user_flow_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test configuration flow aborts when the device is already configured. Also, ensures the existing config entry is updated with the new host. """ mock_config_entry.add_to_hass(hass) assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" assert mock_config_entry.data[CONF_TOKEN] == "987654" @pytest.mark.usefixtures("mock_tailwind") async def test_zeroconf_flow( hass: HomeAssistant, snapshot: SnapshotAssertion, ) -> None: """Test the zeroconf happy flow from start to finish.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, hostname="tailwind-3ce90e6d2184.local.", name="mock_name", properties={ "device_id": "_3c_e9_e_6d_21_84_", "product": "iQ3", "SW ver": "10.10", "vendor": "tailwind", }, type="mock_type", ), ) assert result["step_id"] == "zeroconf_confirm" assert result["type"] is FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 assert progress[0].get("flow_id") == result["flow_id"] result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_TOKEN: "987654"} ) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == "3c:e9:0e:6d:21:84" assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", } assert not config_entry.options @pytest.mark.parametrize( ("properties", "expected_reason"), [ ({"SW ver": "10.10"}, "no_device_id"), ({"device_id": "_3c_e9_e_6d_21_84_", "SW ver": "0.0"}, "unsupported_firmware"), ], ) async def test_zeroconf_flow_abort_incompatible_properties( hass: HomeAssistant, properties: dict[str, str], expected_reason: str ) -> None: """Test the zeroconf aborts when it advertises incompatible data.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, hostname="tailwind-3ce90e6d2184.local.", name="mock_name", properties=properties, type="mock_type", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == expected_reason @pytest.mark.parametrize( ("side_effect", "expected_error"), [ (TailwindConnectionError, {"base": "cannot_connect"}), (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), (Exception, {"base": "unknown"}), ], ) async def test_zeroconf_flow_errors( hass: HomeAssistant, mock_tailwind: MagicMock, side_effect: Exception, expected_error: dict[str, str], ) -> None: """Test we show form on a error.""" mock_tailwind.status.side_effect = side_effect result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, hostname="tailwind-3ce90e6d2184.local.", name="mock_name", properties={ "device_id": "_3c_e9_e_6d_21_84_", "product": "iQ3", "SW ver": "10.10", "vendor": "tailwind", }, type="mock_type", ), ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "zeroconf_confirm" assert result["errors"] == expected_error mock_tailwind.status.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.unique_id == "3c:e9:0e:6d:21:84" assert config_entry.data == { CONF_HOST: "127.0.0.1", CONF_TOKEN: "123456", } assert not config_entry.options @pytest.mark.usefixtures("mock_tailwind") async def test_zeroconf_flow_not_discovered_again( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test the zeroconf doesn't re-discover an existing device. Also, ensures the existing config entry is updated with the new host. """ mock_config_entry.add_to_hass(hass) assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( ip_address=ip_address("127.0.0.1"), ip_addresses=[ip_address("127.0.0.1")], port=80, hostname="tailwind-3ce90e6d2184.local.", name="mock_name", properties={ "device_id": "_3c_e9_e_6d_21_84_", "product": "iQ3", "SW ver": "10.10", "vendor": "tailwind", }, type="mock_type", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" @pytest.mark.usefixtures("mock_tailwind") async def test_reauth_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test the reauthentication configuration flow.""" mock_config_entry.add_to_hass(hass) assert mock_config_entry.data[CONF_TOKEN] == "123456" result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "987654"}, ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert mock_config_entry.data[CONF_TOKEN] == "987654" @pytest.mark.parametrize( ("side_effect", "expected_error"), [ (TailwindConnectionError, {"base": "cannot_connect"}), (TailwindAuthenticationError, {CONF_TOKEN: "invalid_auth"}), (Exception, {"base": "unknown"}), ], ) async def test_reauth_flow_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_tailwind: MagicMock, side_effect: Exception, expected_error: dict[str, str], ) -> None: """Test we show form on a error.""" mock_config_entry.add_to_hass(hass) mock_tailwind.status.side_effect = side_effect result = await mock_config_entry.start_reauth_flow(hass) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" assert result["errors"] == expected_error mock_tailwind.status.side_effect = None result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" async def test_dhcp_discovery_updates_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test DHCP discovery updates config entries.""" mock_config_entry.add_to_hass(hass) assert mock_config_entry.data[CONF_HOST] == "127.0.0.127" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=DhcpServiceInfo( hostname="tailwind-3ce90e6d2184.local.", ip="127.0.0.1", macaddress="3ce90e6d2184", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_HOST] == "127.0.0.1" async def test_dhcp_discovery_ignores_unknown(hass: HomeAssistant) -> None: """Test DHCP discovery is only used for updates. Anything else will just abort the flow. """ result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=DhcpServiceInfo( hostname="tailwind-3ce90e6d2184.local.", ip="127.0.0.1", macaddress="3ce90e6d2184", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unknown"