SWS-12500-custom-component/tests/test_predecessor_adoption.py

788 lines
33 KiB
Python

"""Adopting the predecessor integration must not cost a single measurement.
Recorder history and long-term statistics hang off `entity_id`. Every test here exists
to defend one sentence: after adoption the `entity_id` is byte-for-byte what it was.
Everything else - the platform, the config entry, the device - may change freely.
The tests drive the real entity and device registries rather than stubs, because the
failure modes being guarded against (a placeholder `unavailable` state blocking the
migration, a device deleted with its entities in tow, a duplicate unique id landing in
the registry index) all live inside Home Assistant's own bookkeeping.
"""
from __future__ import annotations
from typing import Any
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500.const import (
CHANNEL_TYPES,
DOMAIN,
POCASI_CZ_ENABLED,
POCASI_CZ_ENABLED_LEGACY,
SENSORS_TO_LOAD,
)
from custom_components.sws12500.data import build_device_info
from custom_components.sws12500.predecessor import (
async_adopt_predecessor,
update_predecessor_adoption_issue,
)
from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er, issue_registry as ir
OLD_DOMAIN = "sws12500_legacy"
# A representative slice: a plain sensor, a binary sensor, and the suffixed unique id
# the battery entities use.
SEEDED = (
("sensor", "outside_temp"),
("sensor", "lightning_distance"),
("binary_sensor", "leak_ch1_binary"),
)
@pytest.fixture
def entries(hass: HomeAssistant) -> tuple[MockConfigEntry, MockConfigEntry]:
"""A predecessor entry with entities, plus the new entry adopting them."""
old = MockConfigEntry(domain=OLD_DOMAIN, title="Old", options={"api_id": "x"})
old.add_to_hass(hass)
new = MockConfigEntry(domain=DOMAIN, title="New")
new.add_to_hass(hass)
return old, new
def _seed(hass: HomeAssistant, old: MockConfigEntry, **kwargs: Any) -> dict[str, str]:
"""Register the predecessor's entities; returns unique_id -> entity_id."""
registry = er.async_get(hass)
created: dict[str, str] = {}
for domain, unique_id in SEEDED:
entry = registry.async_get_or_create(
domain, OLD_DOMAIN, unique_id, config_entry=old, **kwargs
)
created[unique_id] = entry.entity_id
return created
# ---------------------------------------------------------------------------
# The one that matters
# ---------------------------------------------------------------------------
async def test_entity_ids_are_untouched(hass: HomeAssistant, entries) -> None:
"""History follows entity_id. If this test fails, users lose their measurements."""
old, new = entries
before = _seed(hass, old)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
registry = er.async_get(hass)
for unique_id, entity_id in before.items():
adopted = registry.async_get_entity_id(entity_id.split(".")[0], DOMAIN, unique_id)
assert adopted == entity_id, f"{unique_id} changed entity_id: {entity_id} -> {adopted}"
assert sorted(result.adopted) == sorted(before.values())
assert result.complete
async def test_unique_ids_are_untouched(hass: HomeAssistant, entries) -> None:
"""A rewritten unique id would break the bind between platform and registry entry."""
old, new = entries
_seed(hass, old)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
registry = er.async_get(hass)
adopted = {e.unique_id for e in er.async_entries_for_config_entry(registry, new.entry_id)}
assert adopted == {unique_id for _, unique_id in SEEDED}
async def test_platform_and_config_entry_are_repointed(hass: HomeAssistant, entries) -> None:
"""The registry entry has to end up owned by this integration."""
old, new = entries
_seed(hass, old)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
registry = er.async_get(hass)
assert not er.async_entries_for_config_entry(registry, old.entry_id)
for entry in er.async_entries_for_config_entry(registry, new.entry_id):
assert entry.platform == DOMAIN
async def test_user_customisations_survive(hass: HomeAssistant, entries) -> None:
"""Renames, icons and placement are part of what the user would lose on a recreate."""
old, new = entries
created = _seed(hass, old)
registry = er.async_get(hass)
entity_id = created["outside_temp"]
registry.async_update_entity(
entity_id,
name="Teplota na zahradě",
icon="mdi:thermometer",
area_id="garden",
)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
entry = registry.async_get(entity_id)
assert entry is not None
assert entry.name == "Teplota na zahradě"
assert entry.icon == "mdi:thermometer"
assert entry.area_id == "garden"
# ---------------------------------------------------------------------------
# The trap: the placeholder state Home Assistant writes for unloaded entities
# ---------------------------------------------------------------------------
async def test_restored_placeholder_does_not_block_adoption(hass: HomeAssistant, entries) -> None:
"""The exact production state: old files gone, entities restored as `unavailable`.
`async_update_entity_platform` accepts only a missing state or `unknown`, and on
startup HA writes `unavailable` + ATTR_RESTORED for every registered but unloaded
entity. Without clearing that placeholder first, every adoption raises ValueError -
which is to say, nobody's history would migrate at all.
"""
old, new = entries
created = _seed(hass, old)
for entity_id in created.values():
hass.states.async_set(entity_id, STATE_UNAVAILABLE, {ATTR_RESTORED: True})
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.complete
assert sorted(result.adopted) == sorted(created.values())
async def test_unknown_state_is_accepted(hass: HomeAssistant, entries) -> None:
"""`unknown` is what the registry API itself tolerates."""
old, new = entries
created = _seed(hass, old)
hass.states.async_set(created["outside_temp"], STATE_UNKNOWN)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.complete
async def test_live_entity_is_refused(hass: HomeAssistant, entries) -> None:
"""A real state means someone owns this entity; stealing it would give it two owners."""
old, new = entries
created = _seed(hass, old)
live = created["outside_temp"]
hass.states.async_set(live, "21.5") # no ATTR_RESTORED: a genuine reading
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.live == [live]
assert not result.complete
registry = er.async_get(hass)
still_old = registry.async_get(live)
assert still_old is not None
assert still_old.platform == OLD_DOMAIN, "a live entity must be left exactly as it was"
# ---------------------------------------------------------------------------
# Registry integrity
# ---------------------------------------------------------------------------
async def test_unique_id_conflict_is_refused(hass: HomeAssistant, entries) -> None:
"""HA does not guard this itself when only the platform changes.
`_async_update_entity` checks for a duplicate unique id only when `new_unique_id` is
passed. Changing just the platform onto a key this integration already uses would
put two registry entries on one index slot, and the loser becomes unreachable.
"""
old, new = entries
created = _seed(hass, old)
registry = er.async_get(hass)
ours = registry.async_get_or_create("sensor", DOMAIN, "outside_temp", config_entry=new)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.conflicting == [created["outside_temp"]]
# Both entries still exist and still resolve to different entity_ids.
assert registry.async_get_entity_id("sensor", DOMAIN, "outside_temp") == ours.entity_id
assert registry.async_get_entity_id("sensor", OLD_DOMAIN, "outside_temp") == created["outside_temp"]
async def test_predecessor_is_kept_when_anything_was_skipped(hass: HomeAssistant, entries) -> None:
"""Removing the old entry deletes whatever is still attached to it."""
old, new = entries
created = _seed(hass, old)
hass.states.async_set(created["outside_temp"], "21.5")
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert not result.predecessor_removed
assert hass.config_entries.async_get_entry(old.entry_id) is not None
registry = er.async_get(hass)
assert registry.async_get(created["outside_temp"]) is not None
async def test_predecessor_is_removed_when_complete(hass: HomeAssistant, entries) -> None:
"""A finished migration must not leave a broken entry behind in the UI."""
old, new = entries
_seed(hass, old)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.predecessor_removed
assert hass.config_entries.async_get_entry(old.entry_id) is None
# ---------------------------------------------------------------------------
# Devices - the second way to lose entities
# ---------------------------------------------------------------------------
async def test_device_is_repointed_and_keeps_its_entities(hass: HomeAssistant, entries) -> None:
"""Deleting a device deletes its entities, so the device has to move, not be replaced."""
old, new = entries
device_registry = dr.async_get(hass)
device = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station",
)
created = _seed(hass, old, device_id=device.id)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
survived = device_registry.async_get(device.id)
assert survived is not None, "the device was deleted, which takes its entities with it"
assert survived.identifiers == set(build_device_info(new)["identifiers"])
assert new.entry_id in survived.config_entries
assert old.entry_id not in survived.config_entries
registry = er.async_get(hass)
for entity_id in created.values():
entry = registry.async_get(entity_id)
assert entry is not None
assert entry.device_id == device.id
async def test_device_identifiers_match_what_the_platform_will_look_up(
hass: HomeAssistant, entries
) -> None:
"""If adoption and `build_device_info` drift, setup mints a second device.
The adopted device would then be stranded and every entity would be moved onto the
new one. History survives that, but the device page does not.
"""
old, new = entries
device_registry = dr.async_get(hass)
device = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station",
)
_seed(hass, old, device_id=device.id)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
looked_up = device_registry.async_get_device(identifiers=set(build_device_info(new)["identifiers"]))
assert looked_up is not None
assert looked_up.id == device.id
async def test_a_refused_entity_survives_its_device_being_repointed(hass: HomeAssistant, entries) -> None:
"""The quietest way to lose history, and the reason a device is never detached here.
`entity_registry.async_device_modified` reacts to a device losing a config entry by
deleting every entity of that device still pointing at it. On a partial adoption
those are precisely the entities we refused to move - so detaching the predecessor
from the device would delete them, before `AdoptionResult.complete` ever gets to
keep the old entry alive for a retry. The entity_id is freed, the recorder history
and the long-term statistics are orphaned, and nothing is logged.
"""
old, new = entries
device_registry = dr.async_get(hass)
device = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station",
)
created = _seed(hass, old, device_id=device.id)
refused = created["outside_temp"]
hass.states.async_set(refused, "21.5") # live: adoption must leave this one alone
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.live == [refused]
registry = er.async_get(hass)
survived = registry.async_get(refused)
assert survived is not None, "the refused entity was deleted along with the device link"
assert survived.entity_id == refused
assert survived.platform == OLD_DOMAIN
assert hass.config_entries.async_get_entry(old.entry_id) is not None
async def test_a_second_device_does_not_collide(hass: HomeAssistant, entries) -> None:
"""Two devices, one identifier set. Rewriting both would raise and never recover.
`device_registry._validate_identifiers` refuses an identifier another device already
holds, and the exception would escape `async_setup_entry` - after the entities were
repointed and before the old entry was removed, so every restart reproduces it and
the integration never sets up again.
"""
old, new = entries
device_registry = dr.async_get(hass)
primary = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station",
)
secondary = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN, "ecowitt_ch1")},
name="Channel 1",
)
created = _seed(hass, old, device_id=primary.id)
registry = er.async_get(hass)
extra = registry.async_get_or_create(
"sensor", OLD_DOMAIN, "ch1_temp", config_entry=old, device_id=secondary.id
)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.complete
for entity_id in (*created.values(), extra.entity_id):
assert registry.async_get(entity_id) is not None, f"{entity_id} disappeared"
# One device holds the shared identifiers, the other keeps its own and is repointed.
holder = device_registry.async_get_device(identifiers=set(build_device_info(new)["identifiers"]))
assert holder is not None
assert holder.id == primary.id
kept = device_registry.async_get(secondary.id)
assert kept is not None
assert kept.identifiers == {(OLD_DOMAIN, "ecowitt_ch1")}
assert new.entry_id in kept.config_entries
async def test_the_station_device_wins_regardless_of_registry_order(hass: HomeAssistant, entries) -> None:
"""Which device becomes ours must not be decided by storage order.
Registered here in the order that punishes "first one claims the identifiers": the
single-entity channel row comes first. After the rename nothing holds the target
identifiers yet, so nothing else would stop it - and the station device, carrying the
user's own name, area and labels, would be stranded on the old-domain identifier
while every adopted entity is moved onto the channel row.
"""
old, new = entries
device_registry = dr.async_get(hass)
channel = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN, "ecowitt_ch1")},
name="Channel 1",
)
station = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN, "station")},
name="Weather Station",
)
ordered = [d.id for d in dr.async_entries_for_config_entry(device_registry, old.entry_id)]
assert ordered[0] == channel.id, "fixture no longer puts the smaller device first"
_seed(hass, old, device_id=station.id)
registry = er.async_get(hass)
registry.async_get_or_create("sensor", OLD_DOMAIN, "ch1_temp", config_entry=old, device_id=channel.id)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
holder = device_registry.async_get_device(identifiers=set(build_device_info(new)["identifiers"]))
assert holder is not None
assert holder.id == station.id, "the device holding most of the entities has to be the one we adopt"
stayed = device_registry.async_get(channel.id)
assert stayed is not None
assert stayed.identifiers == {(OLD_DOMAIN, "ecowitt_ch1")}
async def test_an_empty_device_is_not_carried_over(hass: HomeAssistant, entries) -> None:
"""An emptied device must not be kept alive by us, or it never goes away.
`device_registry.async_cleanup` subtracts every device referenced by a live config
entry from the orphan set, so attaching this entry to a device that holds nothing
pins it in place permanently - an empty device page under the new integration that
no cleanup pass will ever reap. Leaving it alone lets `async_remove(old_entry)` take
its last config entry and delete it.
"""
old, new = entries
device_registry = dr.async_get(hass)
station = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station",
)
ghost = device_registry.async_get_or_create(
config_entry_id=old.entry_id,
identifiers={(OLD_DOMAIN, "ecowitt_ch1")},
name="Channel 1",
)
created = _seed(hass, old, device_id=station.id)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert result.complete
assert device_registry.async_get(ghost.id) is None, "the empty device outlived the migration"
survived = device_registry.async_get(station.id)
assert survived is not None
registry = er.async_get(hass)
for entity_id in created.values():
entry = registry.async_get(entity_id)
assert entry is not None
assert entry.device_id == station.id
# ---------------------------------------------------------------------------
# More than one predecessor entry
# ---------------------------------------------------------------------------
async def test_a_blocked_entry_does_not_hold_back_a_clean_one(hass: HomeAssistant, entries) -> None:
"""Completeness is per predecessor entry; the predecessor never had `single_config_entry`.
Decided on the running total, one blocked entity under the first entry would condemn
every later one to be kept forever - broken "Integration not found" rows that no
retry can ever clear, because the same blocker reproduces the state every time.
"""
old, new = entries
created = _seed(hass, old)
blocked = created["outside_temp"]
hass.states.async_set(blocked, "21.5")
second = MockConfigEntry(domain=OLD_DOMAIN, title="Old 2")
second.add_to_hass(hass)
registry = er.async_get(hass)
clean = registry.async_get_or_create("sensor", OLD_DOMAIN, "second_station_temp", config_entry=second)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert hass.config_entries.async_get_entry(second.entry_id) is None, (
"a fully adopted entry must be removed even when a sibling was blocked"
)
assert hass.config_entries.async_get_entry(old.entry_id) is not None
assert result.predecessor_removed
assert not result.complete
assert registry.async_get_entity_id("sensor", DOMAIN, "second_station_temp") == clean.entity_id
# ---------------------------------------------------------------------------
# Reporting - the user has to be told, and told what not to do
# ---------------------------------------------------------------------------
async def test_a_stalled_migration_raises_a_repair_issue(hass: HomeAssistant, entries) -> None:
"""A log line is not enough: the obvious reaction to the leftover entry destroys history."""
old, new = entries
created = _seed(hass, old)
blocked = created["outside_temp"]
hass.states.async_set(blocked, "21.5")
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, result)
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
assert issue is not None
assert issue.translation_key == "predecessor_adoption_incomplete"
assert issue.translation_placeholders == {"entities": blocked}
async def test_a_predecessor_that_will_not_unload_raises_a_repair_issue(
hass: HomeAssistant, entries, monkeypatch
) -> None:
"""The run where nothing at all moved is the one that most needs the warning.
Passed over in silence, an unloadable predecessor contributes nothing to the result,
so `attempted` stays False and the notice is deleted instead of raised - leaving the
user with a leftover entry, no explanation, and the one irreversible action a click
away.
"""
from homeassistant.config_entries import ConfigEntryState
old, new = entries
created = _seed(hass, old)
old.mock_state(hass, ConfigEntryState.LOADED)
async def _refuse(*_a: Any, **_kw: Any) -> bool:
return False
monkeypatch.setattr(hass.config_entries, "async_unload", _refuse)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, result)
assert not result.adopted
assert sorted(result.live) == sorted(created.values())
assert not result.complete
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
assert issue is not None
assert issue.translation_placeholders == {"entities": ", ".join(sorted(created.values()))}
async def test_a_finished_migration_raises_no_issue(hass: HomeAssistant, entries) -> None:
"""Nothing was left behind, so there is nothing to warn about."""
old, new = entries
_seed(hass, old)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, result)
assert ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}") is None
async def test_the_issue_is_cleared_once_the_migration_finishes(hass: HomeAssistant, entries) -> None:
"""Otherwise the notice outlives the problem and the user learns to ignore it."""
old, new = entries
created = _seed(hass, old)
blocked = created["outside_temp"]
hass.states.async_set(blocked, "21.5")
first = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, first)
issue_id = f"predecessor_adoption_{new.entry_id}"
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
hass.states.async_remove(blocked)
second = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
update_predecessor_adoption_issue(hass, new, second)
assert second.complete
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
# ---------------------------------------------------------------------------
# Repeatability
# ---------------------------------------------------------------------------
async def test_running_twice_changes_nothing(hass: HomeAssistant, entries) -> None:
"""Adoption runs on every setup, so the second pass must be inert."""
old, new = entries
before = _seed(hass, old)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
second = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert not second.attempted
registry = er.async_get(hass)
for unique_id, entity_id in before.items():
assert registry.async_get_entity_id(entity_id.split(".")[0], DOMAIN, unique_id) == entity_id
async def test_a_partial_run_can_be_finished(hass: HomeAssistant, entries) -> None:
"""Interrupted migration: the retry has to pick up what was left behind."""
old, new = entries
created = _seed(hass, old)
blocked = created["outside_temp"]
hass.states.async_set(blocked, "21.5")
first = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert first.live == [blocked]
# The blocker goes away (old integration finally unloaded / HA restarted).
hass.states.async_remove(blocked)
second = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert second.adopted == [blocked]
assert second.complete
assert second.predecessor_removed
registry = er.async_get(hass)
assert registry.async_get_entity_id("sensor", DOMAIN, "outside_temp") == blocked
async def test_no_predecessor_is_a_noop(hass: HomeAssistant) -> None:
"""The overwhelmingly common case: a fresh install, on every single restart."""
new = MockConfigEntry(domain=DOMAIN)
new.add_to_hass(hass)
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert not result.attempted
assert not result.predecessor_removed
# ---------------------------------------------------------------------------
# Settings - without them the adopted entities have nothing asking for them
# ---------------------------------------------------------------------------
async def test_settings_are_inherited(hass: HomeAssistant, entries) -> None:
"""`SENSORS_TO_LOAD` is what makes the platform create an entity at all.
Adopting the registry but not this leaves every entity showing "no longer provided"
until auto-discovery has seen a payload - and derived sensors, which are never in a
payload, only reappear on the next restart. The forwarding credentials would have
to be typed in again on top of that.
"""
old, new = entries
hass.config_entries.async_update_entry(
old,
options={
SENSORS_TO_LOAD: ["outside_temp", "wind_dir"],
"WINDY_API_KEY": "windy-secret",
CHANNEL_TYPES: {"t234c1tp": "4"},
},
)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert new.options[SENSORS_TO_LOAD] == ["outside_temp", "wind_dir"]
assert new.options["WINDY_API_KEY"] == "windy-secret"
assert new.options[CHANNEL_TYPES] == {"t234c1tp": "4"}
async def test_fresh_input_wins_over_inherited(hass: HomeAssistant, entries) -> None:
"""What the user just typed into this integration's flow is newer by definition."""
old, new = entries
hass.config_entries.async_update_entry(old, options={"API_ID": "old-id", "API_KEY": "old-key"})
hass.config_entries.async_update_entry(new, options={"API_ID": "new-id"})
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert new.options["API_ID"] == "new-id", "the freshly entered value must not be overwritten"
assert new.options["API_KEY"] == "old-key", "gaps are still filled from the predecessor"
async def test_the_misspelled_pocasi_flag_is_translated(hass: HomeAssistant, entries) -> None:
"""A version 1 entry carries the old spelling, and `async_migrate_entry` cannot help.
Migration runs against this entry's own version, which is already current, so a key
copied in afterwards would never be looked at again - and Pocasi forwarding would
silently come up disabled.
"""
old, new = entries
hass.config_entries.async_update_entry(old, options={POCASI_CZ_ENABLED_LEGACY: True})
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert new.options[POCASI_CZ_ENABLED] is True
assert POCASI_CZ_ENABLED_LEGACY not in new.options
async def test_a_correct_flag_is_not_clobbered_by_the_misspelled_one(hass: HomeAssistant, entries) -> None:
"""A partially upgraded entry can carry both; the correct spelling has to win."""
old, new = entries
hass.config_entries.async_update_entry(
old, options={POCASI_CZ_ENABLED_LEGACY: True, POCASI_CZ_ENABLED: False}
)
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
assert new.options[POCASI_CZ_ENABLED] is False
async def test_a_failed_setup_leaves_the_predecessor_untouched(hass, entries, monkeypatch) -> None:
"""The destructive step must come after setup is known to work.
Removing the predecessor's config entry is the one thing adoption cannot undo. If
it ran before the webhook routes were up, a user who still has the previous version
installed - which is exactly what causes the route clash - would get their old entry
deleted and then a failed setup, with nothing to fall back to.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from custom_components.sws12500 import async_setup_entry
from custom_components.sws12500.const import API_ID, API_KEY
from homeassistant.exceptions import ConfigEntryNotReady
old, new = entries
before = _seed(hass, old)
hass.config_entries.async_update_entry(old, options={API_ID: "id", API_KEY: "key"})
class _BrokenRouter:
def add_get(self, *_a: Any, **_kw: Any) -> Any:
raise ValueError("Duplicate '_default_route', already handled by ...")
def add_post(self, *_a: Any, **_kw: Any) -> Any:
raise ValueError("Duplicate '_wslink_post_route', already handled by ...")
hass.http = SimpleNamespace(app=SimpleNamespace(router=_BrokenRouter()))
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
monkeypatch.setattr("custom_components.sws12500.PREDECESSOR_DOMAIN", OLD_DOMAIN, raising=False)
monkeypatch.setattr("custom_components.sws12500.predecessor.PREDECESSOR_DOMAIN", OLD_DOMAIN)
with pytest.raises(ConfigEntryNotReady):
await async_setup_entry(hass, new)
assert hass.config_entries.async_get_entry(old.entry_id) is not None, (
"the predecessor was removed even though setup failed"
)
registry = er.async_get(hass)
for entity_id in before.values():
entry = registry.async_get(entity_id)
assert entry is not None
assert entry.platform == OLD_DOMAIN
# Settings are additive and safe to inherit early, so those may already be across.
assert new.options[API_ID] == "id"
async def test_adoption_runs_before_any_entity_is_created(hass, monkeypatch) -> None:
"""Ordering inside `async_setup_entry`, and it fails silently if broken.
Adoption has to finish before `async_forward_entry_setups`. Run the other way round,
our platforms register first, take the free entity_ids for themselves, and the
predecessor's entries are then left holding ids nobody reads - no error, no warning,
just every user's history detached.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
from custom_components.sws12500 import async_setup_entry
from custom_components.sws12500.const import API_ID, API_KEY, WSLINK
class _Router:
def add_get(self, *_a: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="GET")
def add_post(self, *_a: Any, **_kw: Any) -> Any:
return SimpleNamespace(method="POST")
hass.http = SimpleNamespace(app=SimpleNamespace(router=_Router()))
entry = MockConfigEntry(domain=DOMAIN, options={API_ID: "id", API_KEY: "key", WSLINK: False})
entry.add_to_hass(hass)
calls: list[str] = []
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
async def _adopt(*_a: Any, **_kw: Any) -> Any:
calls.append("adopt")
return SimpleNamespace(adopted=[], live=[], conflicting=[], complete=True, attempted=False)
async def _forward(*_a: Any, **_kw: Any) -> bool:
calls.append("forward")
return True
monkeypatch.setattr("custom_components.sws12500.async_adopt_predecessor", _adopt)
monkeypatch.setattr(hass.config_entries, "async_forward_entry_setups", _forward)
assert await async_setup_entry(hass, entry) is True
assert calls == ["adopt", "forward"], f"adoption must precede entity creation, got {calls}"
async def test_same_domain_short_circuits(hass: HomeAssistant) -> None:
"""Until the rename actually happens, this must never touch anything."""
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
registry = er.async_get(hass)
ours = registry.async_get_or_create("sensor", DOMAIN, "outside_temp", config_entry=entry)
result = await async_adopt_predecessor(hass, entry, predecessor_domain=DOMAIN)
assert not result.attempted
assert registry.async_get(ours.entity_id) is not None
assert hass.config_entries.async_get_entry(entry.entry_id) is not None