"""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 import zeroconf from homeassistant.components.dhcp import DhcpServiceInfo from homeassistant.components.tailwind.const import DOMAIN from homeassistant.config_entries import ( SOURCE_DHCP, SOURCE_REAUTH, 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 tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.usefixtures("mock_tailwind") async def test_user_flow( hass: HomeAssistant, snapshot: SnapshotAssertion, ) -> 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.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.1", CONF_TOKEN: "987654", }, ) assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2 == snapshot @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.get("type") == FlowResultType.FORM assert result.get("step_id") == "user" assert result.get("errors") == expected_error mock_tailwind.status.side_effect = None result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_HOST: "127.0.0.2", CONF_TOKEN: "123456", }, ) assert result2.get("type") == FlowResultType.CREATE_ENTRY 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.get("type") == FlowResultType.ABORT assert result.get("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.get("type") == FlowResultType.ABORT assert result.get("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=zeroconf.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.get("step_id") == "zeroconf_confirm" assert result.get("type") == FlowResultType.FORM progress = hass.config_entries.flow.async_progress() assert len(progress) == 1 assert progress[0].get("flow_id") == result["flow_id"] result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_TOKEN: "987654"} ) assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2 == snapshot @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=zeroconf.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.get("type") == FlowResultType.ABORT assert result.get("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=zeroconf.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", ), ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result2.get("type") == FlowResultType.FORM assert result2.get("step_id") == "zeroconf_confirm" assert result2.get("errors") == expected_error mock_tailwind.status.side_effect = None result3 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result3.get("type") == FlowResultType.CREATE_ENTRY @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=zeroconf.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.get("type") == FlowResultType.ABORT assert result.get("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 hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, "unique_id": mock_config_entry.unique_id, "entry_id": mock_config_entry.entry_id, }, data=mock_config_entry.data, ) assert result.get("type") == FlowResultType.FORM assert result.get("step_id") == "reauth_confirm" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_TOKEN: "987654"}, ) await hass.async_block_till_done() assert result2.get("type") == FlowResultType.ABORT assert result2.get("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 hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_REAUTH, "unique_id": mock_config_entry.unique_id, "entry_id": mock_config_entry.entry_id, }, data=mock_config_entry.data, ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result2.get("type") == FlowResultType.FORM assert result2.get("step_id") == "reauth_confirm" assert result2.get("errors") == expected_error mock_tailwind.status.side_effect = None result3 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_TOKEN: "123456", }, ) assert result3.get("type") == FlowResultType.ABORT assert result3.get("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.get("type") == FlowResultType.ABORT assert result.get("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.get("type") == FlowResultType.ABORT assert result.get("reason") == "unknown"