"""Tests for Greencell EVSE config flow.""" from __future__ import annotations import time from unittest.mock import patch import pytest from homeassistant import config_entries from homeassistant.components.greencell.const import ( DOMAIN, GREENCELL_BROADCAST_TOPIC, GREENCELL_DISC_TOPIC, GREENCELL_HABU_DEN, GREENCELL_OTHER_DEVICE, ) from homeassistant.components.mqtt import ReceiveMessage from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from .conftest import TEST_SERIAL_NUMBER, TEST_SERIAL_NUMBER_2 from tests.common import MockConfigEntry from tests.typing import MqttMockHAClient OTHER_DEVICE_SERIAL = "OTHER12345678" @pytest.fixture(autouse=True) def fast_discovery(): """Patch discovery timeout and grace period to 0 for all tests.""" with ( patch( "homeassistant.components.greencell.const.DISCOVERY_TIMEOUT", 0.01, ), patch( "homeassistant.components.greencell.DISCOVERY_TIMEOUT", 0.01, ), patch( "homeassistant.components.greencell.config_flow.asyncio.sleep", return_value=None, ), ): yield async def _init_flow_and_fire_discovery( hass: HomeAssistant, payloads: list[str], ) -> config_entries.ConfigFlowResult: """Initialize user flow and fire discovery messages synchronously.""" async def _mock_subscribe(hass_arg, topic, msg_callback, *args, **kwargs): """Fire payloads immediately when subscription happens.""" for payload in payloads: msg_callback( ReceiveMessage( topic=GREENCELL_DISC_TOPIC, payload=payload, qos=0, retain=False, subscribed_topic=GREENCELL_DISC_TOPIC, timestamp=time.time(), ) ) return lambda: None with patch( "homeassistant.components.greencell.config_flow.mqtt.async_subscribe", side_effect=_mock_subscribe, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await hass.async_block_till_done() return result async def test_user_setup_single_device( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test user setup with single device creates entry.""" result = await _init_flow_and_fire_discovery( hass, [f'{{"id": "{TEST_SERIAL_NUMBER}"}}'], ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}" assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER} assert result["result"].unique_id == TEST_SERIAL_NUMBER async def test_user_setup_multiple_devices( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test user setup with multiple devices triggers selection.""" result = await _init_flow_and_fire_discovery( hass, [ f'{{"id": "{TEST_SERIAL_NUMBER}"}}', f'{{"id": "{TEST_SERIAL_NUMBER_2}"}}', f'{{"id": "{OTHER_DEVICE_SERIAL}"}}', ], ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "select" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"serial_number": TEST_SERIAL_NUMBER_2} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER_2}" assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER_2} assert result["result"].unique_id == TEST_SERIAL_NUMBER_2 async def test_user_setup_no_devices( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test user setup aborts when no devices respond.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_discovery_data" async def test_user_setup_mqtt_not_configured(hass: HomeAssistant) -> None: """Test user setup aborts when MQTT is not configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "mqtt_not_configured" async def test_user_setup_mqtt_not_connected( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test user setup aborts when MQTT is not connected.""" with patch( "homeassistant.components.greencell.config_flow.mqtt.is_connected", return_value=False, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "mqtt_not_connected" async def test_duplicate_device( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mock_config_entry: MockConfigEntry, ) -> None: """Test that configuring same device twice aborts.""" mock_config_entry.add_to_hass(hass) result = await _init_flow_and_fire_discovery( hass, [f'{{"id": "{TEST_SERIAL_NUMBER}"}}'], ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @pytest.mark.parametrize( "payload", [ "", "{BAD JSON}", '{"name": "device"}', '{"id": ""}', '{"id": " "}', '{"id": 12345}', ], ids=[ "empty_payload", "bad_json", "missing_id", "empty_id", "whitespace_id", "non_string_id", ], ) async def test_invalid_discovery_payload( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, payload: str, ) -> None: """Test that invalid discovery payloads are ignored.""" result = await _init_flow_and_fire_discovery(hass, [payload]) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_discovery_data" @pytest.mark.parametrize( ("serial", "expected_name"), [ ("EVGC021A12345678ZM0001", GREENCELL_HABU_DEN), ("EVGC021Z99999999ZM9999", GREENCELL_HABU_DEN), ("OTHER12345678", GREENCELL_OTHER_DEVICE), ("EVGC021a12345678ZM0001", GREENCELL_OTHER_DEVICE), ("EVGC021A1234567ZM0001", GREENCELL_OTHER_DEVICE), ("EVGC022A12345678ZM0001", GREENCELL_OTHER_DEVICE), ], ids=[ "habu_den_valid_1", "habu_den_valid_2", "other_device", "habu_den_lowercase_invalid", "habu_den_short_digits", "habu_den_wrong_prefix", ], ) async def test_device_naming( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, serial: str, expected_name: str, ) -> None: """Test device name is determined correctly from serial prefix.""" result = await _init_flow_and_fire_discovery( hass, [f'{{"id": "{serial}"}}'], ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{expected_name} {serial}" async def test_broadcast_message_published( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test that discovery broadcast is published on flow init.""" await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await hass.async_block_till_done() mqtt_mock.async_publish.assert_called_once() call_args = mqtt_mock.async_publish.call_args assert call_args.args[0] == GREENCELL_BROADCAST_TOPIC assert call_args.args[1] == '{"name": "BROADCAST"}' async def test_select_step_shows_all_discovered_devices( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test select step displays all discovered devices.""" result = await _init_flow_and_fire_discovery( hass, [ f'{{"id": "{TEST_SERIAL_NUMBER}"}}', f'{{"id": "{TEST_SERIAL_NUMBER_2}"}}', ], ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "select" schema = result["data_schema"].schema serial_field = schema["serial_number"] assert TEST_SERIAL_NUMBER in serial_field.container assert TEST_SERIAL_NUMBER_2 in serial_field.container async def test_mqtt_discovery_confirm_creates_entry( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_service_info, ) -> None: """Test MQTT discovery triggers confirm step and creates entry on confirm.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'), ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{GREENCELL_HABU_DEN} {TEST_SERIAL_NUMBER}" assert result["data"] == {"serial_number": TEST_SERIAL_NUMBER} assert result["result"].unique_id == TEST_SERIAL_NUMBER async def test_mqtt_discovery_other_device( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_service_info, ) -> None: """Test MQTT discovery with non-HabuDen device.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(f'{{"id": "{OTHER_DEVICE_SERIAL}"}}'), ) assert result["type"] is FlowResultType.FORM result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{GREENCELL_OTHER_DEVICE} {OTHER_DEVICE_SERIAL}" async def test_mqtt_discovery_duplicate_aborts( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mock_config_entry: MockConfigEntry, mqtt_service_info, ) -> None: """Test MQTT discovery aborts for already configured device.""" mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" @pytest.mark.parametrize( "payload", [ "{BAD JSON}", '{"name": "device"}', '{"id": ""}', '{"id": " "}', '{"id": 12345}', ], ids=[ "bad_json", "missing_id", "empty_id", "whitespace_id", "non_string_id", ], ) async def test_mqtt_discovery_invalid_payload( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_service_info, payload: str, ) -> None: """Test MQTT discovery aborts on invalid payloads.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(payload), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_discovery_data" async def test_mqtt_discovery_attribute_error( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_service_info, ) -> None: """Test MQTT discovery aborts when json.loads raises AttributeError.""" with patch( "homeassistant.components.greencell.config_flow.json.loads", side_effect=AttributeError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info('{"id": "anything"}'), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "invalid_discovery_data" async def test_user_setup_mqtt_subscription_value_error( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, ) -> None: """Test user setup aborts when MQTT subscription raises ValueError.""" with patch( "homeassistant.components.greencell.config_flow.mqtt.async_subscribe", side_effect=ValueError("bad topic"), ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "mqtt_subscription_failed" async def test_mqtt_discovery_already_in_progress( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, mqtt_service_info, ) -> None: """Test MQTT discovery aborts when another flow for same serial is in progress.""" result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'), ) assert result1["type"] is FlowResultType.FORM assert result1["step_id"] == "confirm" result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_MQTT}, data=mqtt_service_info(f'{{"id": "{TEST_SERIAL_NUMBER}"}}'), ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_in_progress"