1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-06 22:36:33 +01:00
Files
core/tests/components/wolflink/test_init.py
T
EnjoyingM 035e7e0a38 Wolflink Shared and multidevice hub support (#172795)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-06-22 22:40:14 +02:00

536 lines
18 KiB
Python

"""Test the Wolf SmartSet Service."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from httpx import RequestError
import pytest
from wolf_comm.models import Device
from wolf_comm.token_auth import InvalidAuth
from wolf_comm.wolf_client import FetchFailed, ParameterReadError
from homeassistant.components.wolflink.const import DOMAIN, MANUFACTURER
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
LEGACY_CONFIG = {
"device_name": "test-device",
"device_id": 1234,
"device_gateway": 5678,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
}
def _assert_v2_hub(entry: MockConfigEntry) -> None:
"""Assert an entry has been migrated to the v2 hub schema."""
assert entry.version == 2
assert entry.minor_version == 2
assert entry.unique_id == "test-username"
assert entry.data == {
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
}
@pytest.mark.parametrize(
("stored_unique_id", "minor_version"),
[
pytest.param(1234, 1, id="v1.1_int_unique_id"),
pytest.param("1234", 2, id="v1.2_str_unique_id"),
],
)
async def test_migration_v1_to_v2(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
stored_unique_id: int | str,
minor_version: int,
) -> None:
"""Test full v1 → v2.2 migration: entry, device, and entity reattachment.
Covers v1.1 (int unique_id) and v1.2 (str unique_id) starting points,
pre-existing devices/entities (including disabled-by-config-entry rows
that must be re-flagged USER/DEVICE), and unique_id rewriting.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=stored_unique_id,
data=LEGACY_CONFIG,
version=1,
minor_version=minor_version,
)
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, stored_unique_id)},
configuration_url="https://www.wolf-smartset.com/",
manufacturer=MANUFACTURER,
name="test-device",
)
device_registry.async_update_device(
device.id, disabled_by=dr.DeviceEntryDisabler.CONFIG_ENTRY
)
entity = entity_registry.async_get_or_create(
domain="sensor",
platform=DOMAIN,
unique_id="1234:6005200000",
config_entry=config_entry,
device_id=device.id,
disabled_by=er.RegistryEntryDisabler.CONFIG_ENTRY,
)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(config_entry.entry_id)
_assert_v2_hub(config_entry)
# Device identifier was rewritten to a string and remains attached to
# the (now-migrated) hub entry; CONFIG_ENTRY disabled_by was rewritten
# to USER (device) / DEVICE (entity) so disabled state survives.
migrated_device = device_registry.async_get(device.id)
assert migrated_device is not None
assert migrated_device.identifiers == {(DOMAIN, "1234")}
assert migrated_device.config_entries == {config_entry.entry_id}
assert migrated_device.disabled_by is dr.DeviceEntryDisabler.USER
migrated_entity = entity_registry.async_get(entity.entity_id)
assert migrated_entity is not None
assert migrated_entity.config_entry_id == config_entry.entry_id
assert migrated_entity.config_subentry_id is None
assert migrated_entity.disabled_by is er.RegistryEntryDisabler.DEVICE
async def test_migration_v1_2_to_v2_without_device(hass: HomeAssistant) -> None:
"""Test migration when the device-registry row is missing.
This happens when the integration never finished its first setup —
the migration must still bump the entry to v2.2 cleanly.
"""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1234",
data=LEGACY_CONFIG,
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(config_entry.entry_id)
_assert_v2_hub(config_entry)
async def test_migration_skips_entity_from_other_entry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test migration ignores entities on the device that belong to another entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1234",
data=LEGACY_CONFIG,
version=1,
minor_version=2,
)
other_entry = MockConfigEntry(domain="other", unique_id="other")
config_entry.add_to_hass(hass)
other_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "1234")},
manufacturer=MANUFACTURER,
name="test-device",
)
# This entity belongs to a different integration sharing the same device.
other_entity = entity_registry.async_get_or_create(
domain="sensor",
platform="other",
unique_id="other:sensor",
config_entry=other_entry,
device_id=device.id,
)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(config_entry.entry_id)
# The other entry's entity must be untouched.
assert (
entity_registry.async_get(other_entity.entity_id).config_entry_id
== other_entry.entry_id
)
async def test_migration_merges_duplicate_v1_entries(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:
"""Test two v1 entries for the same account merge into one v2 hub entry."""
first_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1234",
data={**LEGACY_CONFIG, "device_id": 1234},
version=1,
minor_version=2,
)
second_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="5678",
data={**LEGACY_CONFIG, "device_id": 5678},
version=1,
minor_version=2,
)
first_entry.add_to_hass(hass)
second_entry.add_to_hass(hass)
# Pre-register devices for both entries so the sibling-merge path also
# exercises the device-reattachment branch.
device_registry.async_get_or_create(
config_entry_id=first_entry.entry_id,
identifiers={(DOMAIN, "1234")},
manufacturer=MANUFACTURER,
name="test-device",
)
device_5678 = device_registry.async_get_or_create(
config_entry_id=second_entry.entry_id,
identifiers={(DOMAIN, "5678")},
manufacturer=MANUFACTURER,
name="second-device",
)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(first_entry.entry_id)
await second_entry.async_migrate(hass)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
surviving = entries[0]
assert surviving.entry_id == first_entry.entry_id
assert surviving.version == 2
assert surviving.minor_version == 2
assert surviving.unique_id == "test-username"
# The pre-existing device for 5678 was reattached to the surviving entry.
device = device_registry.async_get(device_5678.id)
assert device is not None
assert device.config_entries == {surviving.entry_id}
async def test_migration_v1_list_device_id(hass: HomeAssistant) -> None:
"""Test v1 migration tolerates device_id stored as a list from partial migrations."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1234",
data={**LEGACY_CONFIG, "device_id": [1234]},
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(config_entry.entry_id)
_assert_v2_hub(config_entry)
async def test_migration_v1_empty_list_device_id(hass: HomeAssistant) -> None:
"""Test v1 migration returns early if device_id is an empty list."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="1234",
data={**LEGACY_CONFIG, "device_id": []},
version=1,
minor_version=2,
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.side_effect = RequestError(
"Unable to connect"
)
await hass.config_entries.async_setup(config_entry.entry_id)
# Migration returned early — entry is unchanged (still v1).
assert config_entry.version == 1
assert config_entry.minor_version == 2
async def test_setup_no_devices_on_account(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test setup loads cleanly even if the account has no devices."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.wolflink.WolfClient",
autospec=True,
) as wolf_mock:
wolf_mock.return_value.fetch_system_list.return_value = []
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
assert not hass.states.async_entity_ids("sensor")
async def test_setup_multiple_devices(
hass: HomeAssistant,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that each device on the account gets its own coordinator and device entry.
Verifies the multi-device path in async_setup_entry: two devices must produce
two device-registry entries and two independent sets of entities with correctly
namespaced unique_ids.
"""
mock_wolflink.fetch_system_list.return_value = [
Device(1234, 5678, "first-device"),
Device(9999, 5678, "second-device"),
]
await setup_integration(hass, mock_config_entry)
# Both devices must appear in the device registry.
first = device_registry.async_get_device({(DOMAIN, "1234")})
second = device_registry.async_get_device({(DOMAIN, "9999")})
assert first is not None
assert second is not None
assert first.name == "first-device"
assert second.name == "second-device"
# Entities for each device carry the correct device-scoped unique_ids.
entity_ids = hass.states.async_entity_ids("sensor")
assert any("first_device" in eid for eid in entity_ids)
assert any("second_device" in eid for eid in entity_ids)
async def test_migrate_future_version_aborts(hass: HomeAssistant) -> None:
"""Test migration refuses to downgrade a future-version entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="test-username",
data={
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
version=3,
minor_version=1,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.MIGRATION_ERROR
async def test_setup_fetch_parameters_fails(
hass: HomeAssistant,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a device whose parameter fetch fails is skipped, not the whole entry."""
mock_wolflink.fetch_parameters.side_effect = RequestError(
"Unable to fetch parameters"
)
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
assert not hass.states.async_entity_ids("sensor")
async def test_system_share_id_forwarded_to_state_list(
hass: HomeAssistant,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test system_share_id is forwarded to fetch_system_state_list for shared systems."""
mock_wolflink.fetch_system_list.return_value = [
Device(1234, 5678, "test-device", system_share_id=9999),
]
await setup_integration(hass, mock_config_entry)
mock_wolflink.fetch_system_state_list.assert_called_with(1234, 5678, 9999)
async def test_update_skipped_when_device_offline(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that an offline device makes its entities unavailable.
On the next successful poll, parameters must be re-fetched because the
cached value IDs may have rotated while the device was unreachable.
"""
await setup_integration(hass, mock_config_entry)
mock_wolflink.fetch_parameters.reset_mock()
mock_wolflink.fetch_system_state_list.return_value = False
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.test_device_energy_parameter").state
== STATE_UNAVAILABLE
)
mock_wolflink.fetch_system_state_list.return_value = True
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_wolflink.fetch_parameters.call_count == 1
assert hass.states.get("sensor.test_device_energy_parameter").state == "183"
@pytest.mark.parametrize(
"side_effect",
[
RequestError("boom"),
FetchFailed("boom"),
InvalidAuth,
],
)
async def test_update_failure_modes(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
side_effect: Exception,
) -> None:
"""Test recoverable update errors mark entities as unavailable."""
await setup_integration(hass, mock_config_entry)
mock_wolflink.fetch_value.side_effect = side_effect
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.test_device_energy_parameter").state
== STATE_UNAVAILABLE
)
async def test_parameter_read_error_triggers_refetch(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test ParameterReadError flags parameters for re-fetch on the next cycle."""
await setup_integration(hass, mock_config_entry)
mock_wolflink.fetch_parameters.reset_mock()
mock_wolflink.fetch_value.side_effect = ParameterReadError("stale")
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.test_device_energy_parameter").state
== STATE_UNAVAILABLE
)
mock_wolflink.fetch_value.side_effect = None
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_wolflink.fetch_parameters.call_count == 1
assert hass.states.get("sensor.test_device_energy_parameter").state == "183"
async def test_refetch_flag_reset_on_fetch_failure(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_wolflink: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test _refetch_parameters is reset even when the re-fetch itself fails.
Regression test: if fetch_parameters raises during a re-fetch triggered by
ParameterReadError, the flag must be cleared so subsequent cycles do not
endlessly retry the parameter fetch on every update.
"""
await setup_integration(hass, mock_config_entry)
# Trigger the flag via ParameterReadError.
mock_wolflink.fetch_value.side_effect = ParameterReadError("stale")
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Re-fetch itself fails — flag must still be cleared.
mock_wolflink.fetch_parameters.reset_mock()
mock_wolflink.fetch_parameters.side_effect = RequestError("cannot reach API")
mock_wolflink.fetch_value.side_effect = None
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_wolflink.fetch_parameters.call_count == 1
# Next cycle must NOT retry the re-fetch — flag was cleared.
mock_wolflink.fetch_parameters.side_effect = None
freezer.tick(timedelta(seconds=120))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_wolflink.fetch_parameters.call_count == 1