"""Test the liebherr integration init.""" import copy from datetime import timedelta from typing import Any from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory from pyliebherrhomeapi import ( Device, DeviceState, DeviceType, IceMakerControl, IceMakerMode, TemperatureControl, TemperatureUnit, ToggleControl, ZonePosition, ) from pyliebherrhomeapi.exceptions import ( LiebherrAuthenticationError, LiebherrConnectionError, ) import pytest from homeassistant.components.liebherr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE from tests.common import MockConfigEntry, async_fire_time_changed # Test errors during initial get_devices() call in async_setup_entry @pytest.mark.parametrize( ("side_effect", "expected_state"), [ (LiebherrAuthenticationError("Invalid API key"), ConfigEntryState.SETUP_ERROR), (LiebherrConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY), ], ids=["auth_failed", "connection_error"], ) async def test_setup_entry_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, side_effect: Any, expected_state: ConfigEntryState, ) -> None: """Test setup handles various error conditions.""" mock_config_entry.add_to_hass(hass) mock_liebherr_client.get_devices.side_effect = side_effect await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is expected_state # Test errors during get_device() call in coordinator setup (after successful get_devices) @pytest.mark.parametrize( ("side_effect", "expected_state"), [ (LiebherrAuthenticationError("Invalid API key"), ConfigEntryState.SETUP_ERROR), (LiebherrConnectionError("Connection failed"), ConfigEntryState.SETUP_RETRY), ], ids=["auth_failed", "connection_error"], ) async def test_coordinator_setup_errors( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, side_effect: Exception, expected_state: ConfigEntryState, ) -> None: """Test coordinator setup handles device access errors.""" mock_config_entry.add_to_hass(hass) mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE] mock_liebherr_client.get_device.side_effect = side_effect await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is expected_state async def test_unload_entry( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, ) -> None: """Test successful unload of entry.""" mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED NEW_DEVICE = Device( device_id="new_device_id", nickname="New Fridge", device_type=DeviceType.FRIDGE, device_name="K2601", ) NEW_DEVICE_STATE = DeviceState( device=NEW_DEVICE, controls=[ TemperatureControl( zone_id=1, zone_position=ZonePosition.TOP, name="Fridge", type="fridge", value=4, target=5, min=2, max=8, unit=TemperatureUnit.CELSIUS, ), ToggleControl( name="supercool", type="ToggleControl", zone_id=1, zone_position=ZonePosition.TOP, value=False, ), IceMakerControl( name="icemaker", type="IceMakerControl", zone_id=1, zone_position=ZonePosition.TOP, ice_maker_mode=IceMakerMode.OFF, has_max_ice=False, ), ], ) @pytest.mark.usefixtures("init_integration") async def test_dynamic_device_discovery_no_new_devices( hass: HomeAssistant, mock_liebherr_client: MagicMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device scan with no new devices does not create entities.""" # Same devices returned mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE] initial_states = len(hass.states.async_all()) freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # No new entities should be created assert len(hass.states.async_all()) == initial_states @pytest.mark.usefixtures("init_integration") @pytest.mark.parametrize( "exception", [ LiebherrConnectionError("Connection failed"), LiebherrAuthenticationError("Auth failed"), ], ids=["connection_error", "auth_error"], ) async def test_dynamic_device_discovery_api_error( hass: HomeAssistant, mock_liebherr_client: MagicMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, exception: Exception, ) -> None: """Test device scan gracefully handles API errors.""" mock_liebherr_client.get_devices.side_effect = exception initial_states = len(hass.states.async_all()) freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # No crash, no new entities assert len(hass.states.async_all()) == initial_states assert mock_config_entry.state is ConfigEntryState.LOADED @pytest.mark.usefixtures("init_integration") async def test_dynamic_device_discovery_unexpected_error( hass: HomeAssistant, mock_liebherr_client: MagicMock, mock_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: """Test device scan gracefully handles unexpected errors.""" mock_liebherr_client.get_devices.side_effect = RuntimeError("Unexpected") initial_states = len(hass.states.async_all()) freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # No crash, no new entities assert len(hass.states.async_all()) == initial_states assert mock_config_entry.state is ConfigEntryState.LOADED @pytest.mark.usefixtures("init_integration") async def test_dynamic_device_discovery_coordinator_setup_failure( hass: HomeAssistant, mock_liebherr_client: MagicMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test device scan skips devices that fail coordinator setup.""" # New device appears but its state fetch fails mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE] original_state = copy.deepcopy(MOCK_DEVICE_STATE) mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( copy.deepcopy(original_state) if device_id == "test_device_id" else (_ for _ in ()).throw(LiebherrConnectionError("Device offline")) ) freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # New device should NOT be added assert not device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) assert mock_config_entry.state is ConfigEntryState.LOADED async def test_dynamic_device_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test new devices are automatically discovered on all platforms.""" mock_config_entry.add_to_hass(hass) all_platforms = [ Platform.SENSOR, Platform.NUMBER, Platform.SWITCH, Platform.SELECT, ] with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", all_platforms): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Initially only the original device exists assert hass.states.get("sensor.test_fridge_top_zone") is not None assert hass.states.get("sensor.new_fridge") is None # Simulate a new device appearing on the account mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE] mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( copy.deepcopy( NEW_DEVICE_STATE if device_id == "new_device_id" else MOCK_DEVICE_STATE ) ) # Advance time to trigger device scan (5 minute interval) freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # New device should have entities on all platforms state = hass.states.get("sensor.new_fridge") assert state is not None assert state.state == "4" assert hass.states.get("number.new_fridge_setpoint") is not None assert hass.states.get("switch.new_fridge_supercool") is not None assert hass.states.get("select.new_fridge_icemaker") is not None # Original device should still exist assert hass.states.get("sensor.test_fridge_top_zone") is not None # Both devices should be in the device registry assert device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) async def test_stale_device_removal( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test stale devices are removed when no longer returned by the API.""" mock_config_entry.add_to_hass(hass) all_platforms = [ Platform.SENSOR, Platform.NUMBER, Platform.SWITCH, Platform.SELECT, ] # Start with two devices mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE] mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( copy.deepcopy( NEW_DEVICE_STATE if device_id == "new_device_id" else MOCK_DEVICE_STATE ) ) with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", all_platforms): await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # Both devices should exist assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) assert device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) assert hass.states.get("sensor.test_fridge_top_zone") is not None assert hass.states.get("sensor.new_fridge") is not None # Verify both devices are in the device registry assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) new_device_entry = device_registry.async_get_device( identifiers={(DOMAIN, "new_device_id")} ) assert new_device_entry # Simulate the new device being removed from the account. # Make get_device_state raise for new_device_id so we can detect # if the stale coordinator is still polling after shutdown. mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE] def _get_device_state_after_removal(device_id: str, **kw: Any) -> DeviceState: if device_id == "new_device_id": raise AssertionError( "get_device_state called for removed device new_device_id" ) return copy.deepcopy(MOCK_DEVICE_STATE) mock_liebherr_client.get_device_state.side_effect = _get_device_state_after_removal freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # Stale device should be removed from device registry assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) assert not device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) # Advance past the coordinator update interval to confirm the stale # coordinator is no longer polling (would raise AssertionError above) freezer.tick(timedelta(seconds=61)) async_fire_time_changed(hass) await hass.async_block_till_done() # Original device should still work assert hass.states.get("sensor.test_fridge_top_zone") is not None assert mock_config_entry.state is ConfigEntryState.LOADED async def test_stale_device_removal_without_coordinator( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test stale devices removed before startup are cleaned up on scan.""" mock_config_entry.add_to_hass(hass) # Create a device registry entry for a device that was previously known # but is no longer returned by the API (removed while HA was offline). device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={(DOMAIN, "old_device_id")}, name="Old Appliance", ) assert device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) # Start integration — only MOCK_DEVICE is returned, so no coordinator # is created for "old_device_id". await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # The orphaned device still exists in the registry after setup assert device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) # Trigger the periodic device scan freezer.tick(timedelta(minutes=5, seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() # The orphaned device should now be removed from the registry assert not device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) # The active device should still be present assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) assert mock_config_entry.state is ConfigEntryState.LOADED