1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 16:36:08 +01:00
Files
core/tests/components/liebherr/test_init.py

413 lines
14 KiB
Python

"""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