"""Test the Flexit config flow.""" from unittest.mock import AsyncMock, MagicMock, patch from modbus_connection import ModbusError, ModbusTcpParams from modbus_connection.mock import MockModbusUnit import pytest from homeassistant.components.flexit.const import CONF_UNIT, DOMAIN, TYPE_TCP from homeassistant.config_entries import ( SOURCE_RECONFIGURE, SOURCE_USER, ConfigEntryState, ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData from homeassistant.exceptions import HomeAssistantError from tests.common import MockConfigEntry TCP_USER_INPUT = {CONF_HOST: "1.1.1.1", CONF_PORT: 502, CONF_UNIT: 1} TCP_ENTRY_DATA = {CONF_TYPE: TYPE_TCP, **TCP_USER_INPUT} TCP_RECONFIGURE_INPUT = {CONF_HOST: "2.2.2.2", CONF_PORT: 502, CONF_UNIT: 1} SERIAL_USER_INPUT = { CONF_DEVICE: "/dev/ttyUSB0", "baudrate": 57600, CONF_UNIT: 1, } SERIAL_ENTRY_DATA = {CONF_TYPE: "serial", **SERIAL_USER_INPUT} async def test_full_flow( hass: HomeAssistant, mock_get_temporary_modbus_unit: MagicMock ) -> None: """Test the full TCP flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "user" result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "tcp" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Flexit" assert result["data"] == TCP_ENTRY_DATA mock_get_temporary_modbus_unit.assert_called_once_with( hass, ModbusTcpParams(host="1.1.1.1", port=502), 1 ) async def test_tcp_host_is_normalized(hass: HomeAssistant) -> None: """Test TCP hosts are stored in lowercase.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {**TCP_USER_INPUT, CONF_HOST: "Device.Local"} ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "device.local" async def test_maximum_unit(hass: HomeAssistant) -> None: """Test the maximum Modbus unit ID is accepted.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {**TCP_USER_INPUT, CONF_UNIT: 247} ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_UNIT] == 247 @pytest.mark.parametrize("unit", [0, 248]) async def test_unit_out_of_range(hass: HomeAssistant, unit: int) -> None: """Test unit IDs outside the Modbus address range are rejected.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) with pytest.raises(InvalidData): await hass.config_entries.flow.async_configure( result["flow_id"], {**TCP_USER_INPUT, CONF_UNIT: unit} ) async def test_full_flow_serial( hass: HomeAssistant, ) -> None: """Test the full serial (RTU) flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.MENU result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "serial"} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "serial" assert result["errors"] == {} result = await hass.config_entries.flow.async_configure( result["flow_id"], SERIAL_USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Flexit" assert result["data"] == SERIAL_ENTRY_DATA async def test_form_cannot_connect_and_retry( hass: HomeAssistant, mock_modbus_unit: MockModbusUnit, ) -> None: """Test we handle a connect error, then allow retrying successfully.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) mock_modbus_unit.fail_requests(ModbusError("update failed")) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} mock_modbus_unit.fail_requests(None) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY async def test_form_cannot_read_device( hass: HomeAssistant, mock_modbus_unit: MockModbusUnit, ) -> None: """Test we handle a device that cannot be read while validating the flow.""" mock_modbus_unit.fail_requests(ModbusError("update failed")) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} async def test_form_shared_link_conflict( hass: HomeAssistant, mock_get_temporary_modbus_unit: MagicMock, ) -> None: """Test an incompatible shared connection is a connection error.""" mock_get_temporary_modbus_unit.side_effect = HomeAssistantError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} async def test_form_unknown_exception( hass: HomeAssistant, mock_get_temporary_modbus_unit: MagicMock, ) -> None: """Test we handle unknown 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"], {"next_step_id": "tcp"} ) temporary_unit = mock_get_temporary_modbus_unit.side_effect mock_get_temporary_modbus_unit.side_effect = Exception result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} mock_get_temporary_modbus_unit.side_effect = temporary_unit result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.CREATE_ENTRY async def test_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test reconfiguration flow.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_RECONFIGURE_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data[CONF_HOST] == "2.2.2.2" async def test_reconfigure_flow_serial( hass: HomeAssistant, mock_serial_config_entry: MockConfigEntry, ) -> None: """Test reconfiguration flow for a serial connection.""" mock_serial_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_RECONFIGURE, "entry_id": mock_serial_config_entry.entry_id, }, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" new_input = {**SERIAL_USER_INPUT, CONF_DEVICE: "/dev/ttyUSB1"} result = await hass.config_entries.flow.async_configure( result["flow_id"], new_input, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_serial_config_entry.data[CONF_DEVICE] == "/dev/ttyUSB1" async def test_reconfigure_loaded_entry_releases_connection( hass: HomeAssistant, mock_serial_config_entry: MockConfigEntry, ) -> None: """Test reconfiguration releases the active connection before validation.""" mock_serial_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_serial_config_entry.entry_id) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_RECONFIGURE, "entry_id": mock_serial_config_entry.entry_id, }, ) async def check_connection_after_unload(*_: object) -> None: assert mock_serial_config_entry.state is not ConfigEntryState.LOADED with patch( "homeassistant.components.flexit.config_flow.check_connection", AsyncMock(side_effect=check_connection_after_unload), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {**SERIAL_USER_INPUT, "baudrate": 38400} ) assert result["type"] is FlowResultType.ABORT async def test_reconfigure_loaded_entry_restored_after_error( hass: HomeAssistant, mock_serial_config_entry: MockConfigEntry, ) -> None: """Test failed reconfiguration restores the active connection.""" mock_serial_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_serial_config_entry.entry_id) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_RECONFIGURE, "entry_id": mock_serial_config_entry.entry_id, }, ) with patch( "homeassistant.components.flexit.config_flow.check_connection", AsyncMock(return_value="cannot_connect"), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {**SERIAL_USER_INPUT, "baudrate": 38400} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} assert mock_serial_config_entry.state is ConfigEntryState.LOADED async def test_reconfigure_stops_when_unload_fails( hass: HomeAssistant, mock_serial_config_entry: MockConfigEntry, ) -> None: """Test reconfiguration stops when the active entry cannot unload.""" mock_serial_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_serial_config_entry.entry_id) result = await hass.config_entries.flow.async_init( DOMAIN, context={ "source": SOURCE_RECONFIGURE, "entry_id": mock_serial_config_entry.entry_id, }, ) with ( patch.object( hass.config_entries, "async_unload", AsyncMock(return_value=False) ), patch( "homeassistant.components.flexit.config_flow.check_connection", AsyncMock(), ) as check_connection, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {**SERIAL_USER_INPUT, "baudrate": 38400} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "unknown"} check_connection.assert_not_awaited() async def test_reconfigure_flow_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_modbus_unit: MockModbusUnit, ) -> None: """Test error handling in reconfiguration flow.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) assert result["type"] is FlowResultType.FORM mock_modbus_unit.fail_requests(ModbusError("update failed")) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_RECONFIGURE_INPUT, ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "cannot_connect"} mock_modbus_unit.fail_requests(None) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_RECONFIGURE_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" async def test_reconfigure_flow_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure aborts if another entry already uses the given connection.""" other_entry = MockConfigEntry( domain=DOMAIN, title="Flexit", data={CONF_TYPE: TYPE_TCP, **TCP_RECONFIGURE_INPUT}, entry_id="flexit_002", ) mock_config_entry.add_to_hass(hass) other_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": mock_config_entry.entry_id}, ) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_RECONFIGURE_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" async def test_already_configured( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test we handle already configured.""" mock_config_entry.add_to_hass(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"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], TCP_USER_INPUT, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" async def test_already_configured_host_case_insensitive( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test hostname case differences cannot create duplicate entries.""" mock_config_entry.add_to_hass(hass) hass.config_entries.async_update_entry( mock_config_entry, data={**mock_config_entry.data, CONF_HOST: "device.local"} ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "tcp"} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], {**TCP_USER_INPUT, CONF_HOST: "Device.Local"} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured"