Cover the identity migration against what existing users can lose

The move onto the OCF device UUID rewrites the identity of registry rows
that a user's automations, dashboards, history and areas all hang off, and
it does so on the first live poll rather than inside async_migrate_entry.
That makes it the riskiest migration in the codebase and the one least
covered by its own tests, so it gets a dedicated suite organised around
what must not break rather than around the functions involved.

tests/localthings/test_identity_migration.py (19 tests) covers: the v3
upgrade end to end; the full v1 -> v4 walk an oldest install takes; a
host-keyed placeholder-serial board adopting a real identity; issue #381's
actual two-unit install migrating to separate identities; every
customization carried on an entity row (rename, icon, area, hidden_by) and
the device's own area; a composite appliance's subdevice identifiers and
via_device links; scoping to one config entry's rows; the key-boundary
match; upgrading while the appliance is off, and the deferred adoption
completing when it returns; restarting after the upgrade being a no-op;
and the three guards that defend an identity once it has moved.

tests/test_rekey_statistics_end_to_end.py drives a real in-memory recorder
to prove the claim the in-place rewrite exists to make: statistics are
filed under entity_id, so preserving the row preserves the history --
including on the one path that deletes a row rather than rewriting it.
It lives outside tests/localthings/ for the same reason the existing
statistics end-to-end test does (that package's autouse fixture starts HA
before recorder_mock can claim its database).

Every guarantee was checked by mutation: ten separate breakages of the
production code -- dropping the unique_id update, the subdevice prefix,
the entry scoping, the key boundary, the entity rewrite, both snapshot
guards, the no-demote rule, the rejected-serial rule, and recreating the
row under a new entity_id -- each fail the suite.

Two gaps this found and closed:

- ConfigFlow.VERSION had to move to 4. Home Assistant only calls
  async_migrate_entry for entries behind the flow's version, so the v3 ->
  v4 step silently never ran. Pinned with the reasoning written down.
- An offline load could re-key the registry from a stale snapshot, and
  freeze that answer into CONF_DEVICE_KEY so the real UUID could never be
  adopted afterwards. The guards existed; nothing proved they held.

The identity-migration tests move out of test_migration.py, which stays
about the migrations that finish inside async_migrate_entry.
This commit is contained in:
Marc Billow
2026-08-17 05:24:22 +00:00
parent 81dbc6fa03
commit 1e9fbd4ec5
3 changed files with 905 additions and 314 deletions
@@ -0,0 +1,728 @@
"""Moving an existing install onto the OCF device UUID (issue #381).
Every other migration this integration has done finishes inside
`async_migrate_entry`. This one can't: the UUID is only readable from the
appliance, and an entry can load entirely from its stored snapshot while
the appliance is off (issue #295). So the config-entry version bumps up
front and the coordinator adopts the UUID on the first *live* poll,
rewriting the entity registry, the device registry and the entry's own
unique_id together.
That makes this the riskiest migration in the codebase: it rewrites the
identity of registry rows a user's automations, dashboards, history and
areas all hang off. The suite is therefore organised around what an
existing user must not lose, rather than around the functions involved:
* the entity_id, and every customization carried on that registry row
* long-term statistics (keyed by entity_id -- see the end-to-end test in
tests/test_rekey_statistics_end_to_end.py, which drives a real recorder)
* the device row, its area, and a composite appliance's subdevice links
* other config entries' rows, which this must never touch
* stability: an upgrade that happens twice, or offline, must not churn
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import patch
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_DEVICE_KEY,
CONF_HOST,
CONF_SERIAL,
DOMAIN,
)
from custom_components.localthings.registry.identity import DeviceIdentity
from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_SERIAL
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
# The two purifiers from issue #381: one serialNum, two device UUIDs.
SHARED_SERIAL = "BS7SP9AW400114A"
UUID_A = "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
UUID_B = "3771f8bf-c184-3a2d-d885-e4c9818736d2"
@contextmanager
def _reachable(resources: dict, device_id: str | None):
"""A device that answers a poll, reporting `device_id` as its /oic/d
`di` -- which `_connect_session` is what normally reads, so a test that
patches it out otherwise leaves `_identity` None (indistinguishable
from firmware that reports no UUID at all)."""
def _connect(self) -> None:
self._identity = (
None
if device_id is None
else DeviceIdentity(
manufacturer="Samsung Electronics",
model="AVT-WW-TP1-23-AXX500",
name="Samsung AirPurifier",
serial=None,
device_id=device_id,
)
)
with (
patch(f"{_COORD}._connect_session", _connect),
patch(f"{_COORD}._poll_once", return_value=resources),
patch(f"{_COORD}._close_session"),
):
yield
@contextmanager
def _unreachable():
with (
patch(f"{_COORD}._connect_session"),
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
patch(f"{_COORD}._close_session"),
):
yield
def _entry(
hass: HomeAssistant,
*,
version: int,
key: str,
serial: str | None = None,
device_key: str | None = None,
host: str = MOCK_HOST,
) -> MockConfigEntry:
"""An entry as it sits on disk at `version`. Pre-v4 entries carry no
CONF_DEVICE_KEY at all -- that absence is what tells the coordinator it
is looking at an entry that has never adopted a UUID."""
data = {**LEGACY_ENTRY_DATA, CONF_HOST: host}
if version >= 2:
data[CONF_SERIAL] = serial if serial is not None else key
if device_key is not None:
data[CONF_DEVICE_KEY] = device_key
entry = MockConfigEntry(
domain=DOMAIN,
data=data,
unique_id=f"{DOMAIN}_{key}",
version=version,
)
entry.add_to_hass(hass)
return entry
def _seed_registry(hass: HomeAssistant, entry: MockConfigEntry, key: str, **entity_kwargs):
"""A device and one entity keyed on `key`, as a running install has."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, key)},
name=f"Samsung Air Purifier ({key})",
)
entity = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{key}_connection_mode",
config_entry=entry,
device_id=device.id,
**entity_kwargs,
)
return device, entity
# ---------------------------------------------------------------------------
# The upgrade itself
# ---------------------------------------------------------------------------
async def test_v3_entry_moves_onto_the_device_uuid_keeping_its_entity_ids(
hass: HomeAssistant, fridge_resources
) -> None:
"""The migration promise for an existing user, asserted end to end: all
three permanent places move together, and the entity_id doesn't."""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
device, existing = _seed_registry(
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
)
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 4
assert entry.data[CONF_DEVICE_KEY] == UUID_A
# The serial is kept alongside the key, not replaced by it: it is what
# corroborates a later change of UUID.
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
rekeyed_device = dr.async_get(hass).async_get(device.id)
assert rekeyed_device is not None
assert rekeyed_device.identifiers == {(DOMAIN, UUID_A)}
kept = er.async_get(hass).async_get(existing.entity_id)
assert kept is not None
assert kept.entity_id == "sensor.kitchen_purifier_connection"
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
# Nothing left behind on the old key.
assert dr.async_get(hass).async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) is None
async def test_the_oldest_install_walks_all_the_way_from_v1(
hass: HomeAssistant, fridge_resources
) -> None:
"""A v1 entry -- no stored identity at all, from before issue #236 --
walks v1 -> v2 -> v3 -> v4 and then adopts the UUID on its first poll.
The oldest installs take the longest path, and each step rewrites what
the next one reads, so the chain is worth pinning as one journey rather
than trusting the individual steps to compose.
"""
entry = _entry(hass, version=1, key=MOCK_SERIAL)
device, existing = _seed_registry(
hass, entry, MOCK_SERIAL, suggested_object_id="old_install_connection"
)
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 4
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
kept = er.async_get(hass).async_get(existing.entity_id)
assert kept is not None
assert kept.entity_id == "sensor.old_install_connection"
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, UUID_A)}
async def test_a_host_keyed_entry_adopts_a_real_identity(
hass: HomeAssistant, fridge_resources
) -> None:
"""A placeholder-serial board (issues #83/#189) was keyed on its IP,
which is an address rather than an identity -- a new DHCP lease silently
makes it someone else's. Such an entry never made an identity claim to
defend, so a real UUID is adopted without needing the serial to
corroborate it; requiring corroboration would strand exactly these
boards, since their serial resolves to the host and can never match."""
entry = _entry(hass, version=3, key=MOCK_HOST)
device, _ = _seed_registry(hass, entry, MOCK_HOST)
with _reachable(fridge_resources, UUID_B):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_B
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, UUID_B)}
async def test_the_two_units_from_the_issue_migrate_to_separate_identities(
hass: HomeAssistant, fridge_resources
) -> None:
"""Issue #381's actual install: two entries whose stored identity is the
same shared serial. Before this, they could not both exist. Migrating
them must give each its own key rather than collapsing them again --
including their entity unique_ids, which is where issue #83's Bug 4
silently dropped the second unit's entities even once its entry existed.
"""
first = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
_, first_entity = _seed_registry(hass, first, SHARED_SERIAL, suggested_object_id="purifier_a")
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(first.entry_id)
await hass.async_block_till_done()
# Added only now: setting up the first entry loads the integration, which
# brings up every entry already registered -- so a second one created up
# front would come up inside the first one's patched identity and adopt
# its UUID, which is the collision this test exists to disprove.
second = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
_, second_entity = _seed_registry(hass, second, SHARED_SERIAL, suggested_object_id="purifier_b")
with _reachable(fridge_resources, UUID_B):
await hass.config_entries.async_setup(second.entry_id)
await hass.async_block_till_done()
assert first.data[CONF_DEVICE_KEY] == UUID_A
assert second.data[CONF_DEVICE_KEY] == UUID_B
assert first.unique_id != second.unique_id
ent_reg = er.async_get(hass)
assert ent_reg.async_get(first_entity.entity_id).unique_id == (
f"{DOMAIN}_{UUID_A}_connection_mode"
)
assert ent_reg.async_get(second_entity.entity_id).unique_id == (
f"{DOMAIN}_{UUID_B}_connection_mode"
)
# ---------------------------------------------------------------------------
# What the user must not lose
# ---------------------------------------------------------------------------
async def test_every_user_customization_on_the_row_survives(
hass: HomeAssistant, fridge_resources
) -> None:
"""Re-keying rewrites the registry row in place rather than replacing
it, which is the whole reason to do it this way -- so everything the
user attached to that row rides along. A rename, an area, an icon
override and a deliberate hide are each things they would have to redo
by hand if the row were recreated instead.
"""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
ent_reg = er.async_get(hass)
device, existing = _seed_registry(
hass, entry, MOCK_SERIAL, suggested_object_id="kitchen_purifier_connection"
)
dr.async_get(hass).async_update_device(device.id, area_id="kitchen")
ent_reg.async_update_entity(
existing.entity_id,
name="Purifier link",
icon="mdi:air-filter",
area_id="kitchen",
hidden_by=er.RegistryEntryHider.USER,
)
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
kept = ent_reg.async_get(existing.entity_id)
assert kept is not None
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
assert kept.name == "Purifier link"
assert kept.icon == "mdi:air-filter"
assert kept.area_id == "kitchen"
assert kept.hidden_by is er.RegistryEntryHider.USER
assert dr.async_get(hass).async_get(device.id).area_id == "kitchen"
async def test_a_composite_appliance_keeps_its_subdevice_links(
hass: HomeAssistant, fridge_resources
) -> None:
"""A composite appliance (issue #177) registers one device per logical
subdevice, keyed f"{key}_{subdevice}" and linked via_device to the
master's bare key. Rewriting only the exact-match identifier would
strand every sibling under a via_device pointing at a device that no
longer exists, collapsing the user's device tree."""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
dev_reg = dr.async_get(hass)
master, _ = _seed_registry(hass, entry, MOCK_SERIAL)
sub = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{MOCK_SERIAL}_subdevice_1")},
via_device=(DOMAIN, MOCK_SERIAL),
)
assert sub.via_device_id == master.id
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert dev_reg.async_get(master.id).identifiers == {(DOMAIN, UUID_A)}
rekeyed_sub = dev_reg.async_get(sub.id)
assert rekeyed_sub.identifiers == {(DOMAIN, f"{UUID_A}_subdevice_1")}
# Still the same parent row, so the device tree the user sees is intact.
assert rekeyed_sub.via_device_id == master.id
async def test_a_re_key_never_touches_another_entrys_rows(hass: HomeAssistant) -> None:
"""The rewrite is scoped to one config entry's own registry rows.
Issue #381's install is the case that makes this sharp: *both* entries
are keyed on the same shared serial, so both have registry rows under
the identical old key, and they migrate one at a time. An unscoped
rewrite -- matching on the key prefix alone -- would sweep up the other
appliance's device and entities and hand them to the first one to
migrate, which is the worst outcome this change could have.
The two entries hold *different* entities under that one shared prefix
(HA's registry won't let two rows share a unique_id, which is issue
#83's Bug 4 in the first place), so only the config-entry scoping can
tell them apart -- prefix matching alone cannot.
Calls rekey_entry directly so the scoping is what's under test, rather
than the coordinator's decision about whether to call it at all.
"""
from custom_components.localthings.rekey import rekey_entry
migrating = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.3")
bystander = _entry(hass, version=3, key=SHARED_SERIAL, host="192.168.0.14")
ent_reg = er.async_get(hass)
moved = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_connection_mode", config_entry=migrating
)
stays = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{SHARED_SERIAL}_power", config_entry=bystander
)
rekey_entry(hass, migrating, SHARED_SERIAL, UUID_A)
assert ent_reg.async_get(moved.entity_id).unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
# The other appliance is still on the shared serial, waiting its turn.
assert ent_reg.async_get(stays.entity_id).unique_id == f"{DOMAIN}_{SHARED_SERIAL}_power"
assert bystander.unique_id == f"{DOMAIN}_{SHARED_SERIAL}"
async def test_a_re_key_stops_at_the_key_boundary(hass: HomeAssistant) -> None:
"""Matching is on the whole key or the key plus a separator, never a
bare prefix. Serial numbers of one model are routinely prefixes of each
other, so a naive startswith would drag a *different* appliance's rows
along -- and the identifiers it would rewrite them to are nonsense."""
from custom_components.localthings.rekey import rekey_entry
entry = _entry(hass, version=3, key="TEST-SERIAL")
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
target = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL")}
)
# Same config entry, so scoping can't save this one -- only the boundary.
neighbour = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id, identifiers={(DOMAIN, "TEST-SERIAL-0000")}
)
neighbour_entity = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_TEST-SERIAL-0000_connection_mode", config_entry=entry
)
rekey_entry(hass, entry, "TEST-SERIAL", UUID_A)
assert dev_reg.async_get(target.id).identifiers == {(DOMAIN, UUID_A)}
assert dev_reg.async_get(neighbour.id).identifiers == {(DOMAIN, "TEST-SERIAL-0000")}
assert ent_reg.async_get(neighbour_entity.entity_id).unique_id == (
f"{DOMAIN}_TEST-SERIAL-0000_connection_mode"
)
# ---------------------------------------------------------------------------
# Stability: upgrading twice, or offline, must not churn
# ---------------------------------------------------------------------------
async def test_upgrading_while_the_appliance_is_off_changes_nothing(
hass: HomeAssistant, fridge_resources, hass_storage
) -> None:
"""The case a large share of users will actually hit: HA restarts onto
the new release while the appliance is unplugged or asleep.
The entry comes up from its snapshot (issue #295), which never reached
the device -- so it has no standing to claim an identity. It must load
under the key its registry rows already carry and write nothing, or the
real UUID would later look like a *changed* identity to defend against
rather than the one-time adoption it is.
"""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
# Bank a snapshot from a run on the old release, then take it down.
with _reachable(fridge_resources, None):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
# Now the appliance is off. Simulate the pre-v4 shape the upgrade finds.
hass.config_entries.async_update_entry(
entry, data={k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY}, version=3
)
with _unreachable():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == MOCK_SERIAL
# No key claimed, and the registry is exactly as it was.
assert CONF_DEVICE_KEY not in entry.data
assert entry.unique_id == f"{DOMAIN}_{MOCK_SERIAL}"
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
)
async def test_an_offline_load_never_rewrites_the_registry(
hass: HomeAssistant, fridge_resources, hass_storage
) -> None:
"""A snapshot replay must not re-key on the strength of what the
snapshot says, because that is last run's answer rather than the
device's.
Constructed so the two disagree: the snapshot was banked against a
device reporting one serial, while the entry is registered under
another. A replay that trusted the snapshot would rewrite every
registry row to match it -- without the appliance having been reachable
at any point -- and would then freeze that answer into CONF_DEVICE_KEY,
so the real UUID could never be adopted afterwards.
"""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
with _reachable(fridge_resources, None):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
# The entry is registered under a different identity than the snapshot's,
# and back on the pre-v4 shape the upgrade finds.
hass.config_entries.async_update_entry(
entry,
data={
**{k: v for k, v in entry.data.items() if k != CONF_DEVICE_KEY},
CONF_SERIAL: "LEGACY-KEY",
},
unique_id=f"{DOMAIN}_LEGACY-KEY",
version=3,
)
device, existing = _seed_registry(hass, entry, "LEGACY-KEY")
with _unreachable():
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == "LEGACY-KEY"
assert CONF_DEVICE_KEY not in entry.data
assert entry.unique_id == f"{DOMAIN}_LEGACY-KEY"
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, "LEGACY-KEY")}
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_LEGACY-KEY_connection_mode"
)
# And the deferred adoption still works once the appliance answers --
# the offline load left nothing frozen behind it.
with _reachable(fridge_resources, UUID_A):
coordinator._connect_session()
coordinator._run_discovery(fridge_resources)
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{UUID_A}_connection_mode"
)
async def test_the_appliance_coming_back_completes_the_upgrade(
hass: HomeAssistant, fridge_resources
) -> None:
"""The other half of the offline case: the deferred adoption is not
abandoned, it just waits. Once the device answers, the same re-key runs
and the entry finishes its upgrade."""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
with _reachable(fridge_resources, None):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == MOCK_SERIAL
# The device is reachable again, now reporting its UUID.
coordinator = hass.data[DOMAIN][entry.entry_id]
with _reachable(fridge_resources, UUID_A):
coordinator._connect_session()
coordinator._run_discovery(fridge_resources)
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{UUID_A}_connection_mode"
)
async def test_restarting_after_the_upgrade_is_a_no_op(
hass: HomeAssistant, fridge_resources
) -> None:
"""Every restart re-runs discovery, so the adoption path runs again on
an entry that has already moved. It must recognise its own work and do
nothing -- a re-key that fired every boot would churn the registry
forever."""
entry = _entry(hass, version=3, key=MOCK_SERIAL)
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
after_first = dict(entry.data)
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert dict(entry.data) == after_first
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
kept = er.async_get(hass).async_get(existing.entity_id)
assert kept is not None
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
# Exactly one device, not a duplicate alongside it.
assert len(dr.async_entries_for_config_entry(dr.async_get(hass), entry.entry_id)) == 1
async def test_an_already_migrated_entry_is_left_alone(
hass: HomeAssistant, fridge_resources
) -> None:
"""A v4 entry created by the current config flow has nothing to migrate
and nothing to re-key -- it was minted on its UUID."""
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
device, existing = _seed_registry(hass, entry, UUID_A)
with _reachable(fridge_resources, UUID_A):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 4
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, UUID_A)}
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{UUID_A}_connection_mode"
)
# ---------------------------------------------------------------------------
# Guarding the identity once it has moved
# ---------------------------------------------------------------------------
async def test_a_poll_that_reads_no_uuid_does_not_demote_a_keyed_entry(
hass: HomeAssistant, fridge_resources
) -> None:
"""The device saying nothing is not the device saying something
different. A reconnect that can't read /oic/d (a timeout, a firmware
hiccup) must leave the key alone -- demoting back onto the serial would
re-key every entity the user has for the duration of an outage, and
re-key them all back afterwards."""
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
with _reachable(fridge_resources, None):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
async def test_a_rotated_uuid_on_the_same_serial_is_followed(
hass: HomeAssistant, fridge_resources
) -> None:
"""OCF permits a hard factory reset to regenerate `di`. The serialNum is
what tells that apart from a different appliance moving onto the
address, and following it keeps the user's history rather than stranding
it on a UUID the device will never report again."""
entry = _entry(hass, version=4, key=UUID_A, serial=MOCK_SERIAL, device_key=UUID_A)
device, existing = _seed_registry(hass, entry, UUID_A)
# fridge_resources reports MOCK_SERIAL, matching what the entry stored.
with _reachable(fridge_resources, UUID_B):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_B
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, UUID_B)}
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{UUID_B}_connection_mode"
)
async def test_a_different_appliance_on_the_same_address_keeps_the_registered_identity(
hass: HomeAssistant, fridge_resources
) -> None:
"""Neither the UUID nor the serial matches what this entry was
registered with, so this is a different appliance answering at this
address -- not a reset of the registered one. Re-keying here would hand
one appliance's entities, history and automations to another; re-adding
is the user's call."""
entry = _entry(hass, version=4, key=UUID_A, serial="SOME-OTHER-APPLIANCE", device_key=UUID_A)
device, _ = _seed_registry(hass, entry, UUID_A)
with _reachable(fridge_resources, UUID_B):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert dr.async_get(hass).async_get(device.id).identifiers == {(DOMAIN, UUID_A)}
# The rejected appliance's serial is not written either. The serial
# is what corroborates a later change of key, so adopting it here
# would hand the intruder exactly the corroboration it needs to win
# the *next* poll -- defending the identity once and then
# surrendering it on the following cycle.
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator._run_discovery(fridge_resources)
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
# ---------------------------------------------------------------------------
# rekey_entry's own contract
# ---------------------------------------------------------------------------
async def test_rekey_is_idempotent(hass: HomeAssistant) -> None:
"""Safe to attempt on every poll rather than having to track whether it
has already run -- a second call finds nothing under the old key.
Calls rekey_entry directly: what it leaves behind is the contract, and
going through a setup would let the platforms re-adding their entities
hide a row that had in fact been orphaned.
"""
from custom_components.localthings.rekey import rekey_entry
entry = _entry(hass, version=3, key=MOCK_SERIAL)
_, existing = _seed_registry(hass, entry, MOCK_SERIAL)
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
kept = er.async_get(hass).async_get(existing.entity_id)
assert kept is not None
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
async def test_rekey_removes_a_stale_row_rather_than_colliding(hass: HomeAssistant) -> None:
"""Where the destination key is already taken, the old-key row is the
dead one -- unavailable since whichever restart created the split -- so
it goes rather than being rewritten onto a key that exists. Same rule
the #236 repair has always applied, now on the identity move."""
from custom_components.localthings.rekey import rekey_entry
entry = _entry(hass, version=3, key=MOCK_SERIAL)
ent_reg = er.async_get(hass)
live = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{UUID_A}_connection_mode", config_entry=entry
)
stale = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", config_entry=entry
)
assert stale.entity_id != live.entity_id
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
assert ent_reg.async_get(stale.entity_id) is None
assert ent_reg.async_get(live.entity_id) is not None
async def test_rekey_to_the_same_key_does_nothing(hass: HomeAssistant) -> None:
"""The no-op guard that lets callers pass whatever they resolved without
checking first."""
from custom_components.localthings.rekey import rekey_entry
entry = _entry(hass, version=4, key=UUID_A, device_key=UUID_A)
_, existing = _seed_registry(hass, entry, UUID_A)
rekey_entry(hass, entry, UUID_A, UUID_A)
assert er.async_get(hass).async_get(existing.entity_id).unique_id == (
f"{DOMAIN}_{UUID_A}_connection_mode"
)
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
+6 -314
View File
@@ -1,17 +1,18 @@
"""Config-entry migration, the placeholder-identity repair (issue #236), and """Config-entry migration and the placeholder-identity repair (issue #236).
the move onto the OCF device UUID (issue #381)."""
The move onto the OCF device UUID (issue #381) has its own suite in
test_identity_migration.py -- it is the one migration step that can't
finish inside async_migrate_entry, so it needs a device to talk to.
"""
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import ( from custom_components.localthings.const import (
CONF_DEVICE_KEY,
CONF_HOST, CONF_HOST,
CONF_SERIAL, CONF_SERIAL,
DOMAIN, DOMAIN,
@@ -279,312 +280,3 @@ async def test_migration_without_a_unique_id_falls_back_to_host(hass: HomeAssist
assert await async_migrate_entry(hass, entry) is True assert await async_migrate_entry(hass, entry) is True
assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST] assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST]
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}" assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
# ---------------------------------------------------------------------------
# v3 -> v4: onto the OCF device UUID (issue #381)
# ---------------------------------------------------------------------------
# The two purifiers from issue #381: one serialNum, two device UUIDs.
SHARED_SERIAL = "BS7SP9AW400114A"
UUID_A = "ccfd73b3-aeb4-792a-1100-68f06f5d603b"
UUID_B = "3771f8bf-c184-3a2d-d885-e4c9818736d2"
def _identity_reporting(device_id: str | None):
"""A patched _connect_session that hands the coordinator the identity a
real DTLS connect would have read off /oic/p and /oic/d."""
from custom_components.localthings.registry.identity import DeviceIdentity
def _connect(self):
self._identity = (
None
if device_id is None
else DeviceIdentity(
manufacturer="Samsung Electronics",
model="AVT-WW-TP1-23-AXX500",
name="Samsung AirPurifier",
serial=None,
device_id=device_id,
)
)
return patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session",
_connect,
)
def _v3_entry(hass: HomeAssistant, key: str, *, serial: str | None = None) -> MockConfigEntry:
"""An entry as it sits on disk before this release: keyed on CONF_SERIAL,
with no CONF_DEVICE_KEY."""
data = {**LEGACY_ENTRY_DATA, CONF_SERIAL: serial if serial is not None else key}
entry = MockConfigEntry(domain=DOMAIN, data=data, unique_id=f"{DOMAIN}_{key}", version=3)
entry.add_to_hass(hass)
return entry
async def test_v3_entry_moves_onto_the_device_uuid_keeping_its_entity_ids(
hass: HomeAssistant, fridge_resources
) -> None:
"""The whole migration promise for an existing user, asserted end to end.
The re-key happens on the first live poll rather than in
async_migrate_entry, because the UUID is only readable from the device
and an entry can load entirely from its snapshot while the appliance is
off (issue #295). What the user must keep across it: the entity_id (and
with it the entity's name, area, long-term statistics, and every
automation and dashboard that references it), and the device row.
"""
entry = _v3_entry(hass, MOCK_SERIAL)
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_SERIAL)},
)
existing = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode",
config_entry=entry,
device_id=device.id,
suggested_object_id="kitchen_purifier_connection",
)
with (
_identity_reporting(UUID_A),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.version == 4
assert entry.data[CONF_DEVICE_KEY] == UUID_A
# The serial is kept alongside the key, not replaced by it: it is what
# corroborates a later change of UUID.
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
# All three permanent places moved together.
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
rekeyed_device = dev_reg.async_get(device.id)
assert rekeyed_device is not None
assert rekeyed_device.identifiers == {(DOMAIN, UUID_A)}
kept = ent_reg.async_get(existing.entity_id)
assert kept is not None
assert kept.entity_id == "sensor.kitchen_purifier_connection"
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
# And nothing is left behind on the old key.
assert dev_reg.async_get_device(identifiers={(DOMAIN, MOCK_SERIAL)}) is None
async def test_a_host_keyed_entry_adopts_a_real_identity(
hass: HomeAssistant, fridge_resources
) -> None:
"""A placeholder-serial board (issues #83/#189) was keyed on its IP, which
is an address rather than an identity -- a new DHCP lease silently makes
it someone else's. Such an entry never made an identity claim to defend,
so a real UUID is adopted without needing the serial to corroborate it;
requiring corroboration would strand exactly these boards, since their
serial resolves to the host and can never match."""
entry = _v3_entry(hass, MOCK_HOST)
dev_reg = dr.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_HOST)},
)
with (
_identity_reporting(UUID_B),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_B
rekeyed = dev_reg.async_get(device.id)
assert rekeyed is not None
assert rekeyed.identifiers == {(DOMAIN, UUID_B)}
async def test_a_poll_that_reads_no_uuid_does_not_demote_a_keyed_entry(
hass: HomeAssistant, fridge_resources
) -> None:
"""The device saying nothing is not the device saying something different.
A reconnect that can't read /oic/d (a timeout, a firmware hiccup) must
leave the key alone -- demoting back onto the serial would re-key every
entity the user has for the duration of an outage, and re-key them all
back afterwards."""
entry = MockConfigEntry(
domain=DOMAIN,
data={**LEGACY_ENTRY_DATA, CONF_SERIAL: MOCK_SERIAL, CONF_DEVICE_KEY: UUID_A},
unique_id=f"{DOMAIN}_{UUID_A}",
version=4,
)
entry.add_to_hass(hass)
with (
_identity_reporting(None),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][entry.entry_id]
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
async def test_a_rotated_uuid_on_the_same_serial_is_followed(
hass: HomeAssistant, fridge_resources
) -> None:
"""OCF permits a hard factory reset to regenerate `di`. The serialNum is
what tells that apart from a different appliance moving onto the address,
and following it keeps the user's history rather than stranding it on a
UUID the device will never report again."""
entry = MockConfigEntry(
domain=DOMAIN,
data={**LEGACY_ENTRY_DATA, CONF_SERIAL: MOCK_SERIAL, CONF_DEVICE_KEY: UUID_A},
unique_id=f"{DOMAIN}_{UUID_A}",
version=4,
)
entry.add_to_hass(hass)
dev_reg = dr.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, UUID_A)},
)
# fridge_resources reports MOCK_SERIAL, matching what the entry stored.
with (
_identity_reporting(UUID_B),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_B
rekeyed = dev_reg.async_get(device.id)
assert rekeyed is not None
assert rekeyed.identifiers == {(DOMAIN, UUID_B)}
async def test_a_different_appliance_on_the_same_address_keeps_the_registered_identity(
hass: HomeAssistant, fridge_resources
) -> None:
"""Neither the UUID nor the serial matches what this entry was registered
with, so this is a different appliance answering at this address -- not a
reset of the registered one. Re-keying here would hand one appliance's
entities, history and automations to another; re-adding is the user's
call."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
**LEGACY_ENTRY_DATA,
CONF_SERIAL: "SOME-OTHER-APPLIANCE",
CONF_DEVICE_KEY: UUID_A,
},
unique_id=f"{DOMAIN}_{UUID_A}",
version=4,
)
entry.add_to_hass(hass)
dev_reg = dr.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, UUID_A)},
)
with (
_identity_reporting(UUID_B),
patch(
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_DEVICE_KEY] == UUID_A
unchanged = dev_reg.async_get(device.id)
assert unchanged is not None
assert unchanged.identifiers == {(DOMAIN, UUID_A)}
# The rejected appliance's serial is not written either. The serial is
# what corroborates a later change of key, so adopting it here would
# hand the intruder exactly the corroboration it needs to win the *next*
# poll -- defending the identity once and then surrendering it on the
# following cycle.
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator._run_discovery(fridge_resources)
assert coordinator.device_key == UUID_A
assert entry.data[CONF_DEVICE_KEY] == UUID_A
assert entry.data[CONF_SERIAL] == "SOME-OTHER-APPLIANCE"
async def test_rekey_moves_subdevice_identifiers_too(hass: HomeAssistant) -> None:
"""A composite appliance (issue #177) registers one device per logical
subdevice, keyed f"{key}_{subdevice}" and linked via_device to the
master's bare key. Rewriting only the exact-match identifier would strand
every sibling, so the prefix form moves with it.
Calls rekey_entry directly: what it leaves behind is the contract, and
going through a setup would let the platforms re-adding their entities
hide a row that had in fact been orphaned.
"""
from custom_components.localthings.rekey import rekey_entry
entry = _v3_entry(hass, MOCK_SERIAL)
dev_reg = dr.async_get(hass)
master = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, MOCK_SERIAL)},
)
sub = dev_reg.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"{MOCK_SERIAL}_subdevice_1")},
)
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
assert dev_reg.async_get(master.id).identifiers == {(DOMAIN, UUID_A)}
assert dev_reg.async_get(sub.id).identifiers == {(DOMAIN, f"{UUID_A}_subdevice_1")}
async def test_rekey_is_idempotent(hass: HomeAssistant) -> None:
"""Safe to attempt on every poll rather than having to track whether it
has already run -- a second call finds nothing under the old key."""
from custom_components.localthings.rekey import rekey_entry
entry = _v3_entry(hass, MOCK_SERIAL)
ent_reg = er.async_get(hass)
existing = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{MOCK_SERIAL}_connection_mode", config_entry=entry
)
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
rekey_entry(hass, entry, MOCK_SERIAL, UUID_A)
kept = ent_reg.async_get(existing.entity_id)
assert kept is not None
assert kept.unique_id == f"{DOMAIN}_{UUID_A}_connection_mode"
assert entry.unique_id == f"{DOMAIN}_{UUID_A}"
+171
View File
@@ -0,0 +1,171 @@
"""Long-term statistics survive the move onto the OCF device UUID (#381).
The identity migration rewrites entity registry rows in place rather than
letting Home Assistant create replacements. The reason that matters most to
an existing user is history: statistics are keyed by `statistic_id`, which
for a sensor *is* its entity_id, so an entity that came back as
`sensor.foo_2` would leave years of recorded data stranded under a name
nothing writes to any more -- silently, with no error and no repair.
tests/localthings/test_identity_migration.py proves the entity_id is
preserved. It cannot prove that preserving it preserves the history,
because no recorder is running there. This drives a real in-memory recorder
end to end: statistics recorded, entry re-keyed, values read back.
Lives here rather than under tests/localthings/ for the same reason
test_statistics_migration_end_to_end.py does: that package's autouse
`enable_custom_integrations` fixture depends on `hass`, which starts Home
Assistant before `recorder_mock` can claim its database URL. Nothing here
loads the integration -- `rekey_entry` is called directly.
"""
from __future__ import annotations
from datetime import timedelta
from functools import partial
from typing import cast
import pytest
from homeassistant.components.recorder.models import StatisticMeanType
from homeassistant.components.recorder.statistics import (
async_import_statistics,
get_metadata,
statistics_during_period,
)
from homeassistant.components.recorder.util import get_instance
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import MockConfigEntry
from pytest_homeassistant_custom_component.components.recorder.common import (
async_wait_recording_done,
)
from custom_components.localthings.const import CONF_HOST, CONF_SERIAL, DOMAIN
from custom_components.localthings.rekey import rekey_entry
OLD_KEY = "BS7SP9AW400114A" # the shared serial from issue #381
NEW_KEY = "ccfd73b3-aeb4-792a-1100-68f06f5d603b" # that unit's own /oic/d `di`
RECORDED = [11.0, 9.0, 14.0]
async def _seed_statistics(hass: HomeAssistant, entity_id: str) -> None:
start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=4)
async_import_statistics(
hass,
{
"mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
"name": None,
"source": "recorder",
"statistic_id": entity_id,
"unit_class": None,
"unit_of_measurement": None,
},
[
{"start": start + timedelta(hours=i), "mean": value, "min": value, "max": value}
for i, value in enumerate(RECORDED)
],
)
await async_wait_recording_done(hass)
async def _means(hass: HomeAssistant, entity_id: str) -> list[float]:
rows = await get_instance(hass).async_add_executor_job(
statistics_during_period,
hass,
dt_util.utcnow() - timedelta(days=1),
None,
{entity_id},
"hour",
None,
{"mean"},
)
return [cast(float, row["mean"]) for row in rows.get(entity_id, [])]
@pytest.fixture
def purifier_entry(hass: HomeAssistant) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.0.3", CONF_SERIAL: OLD_KEY},
unique_id=f"{DOMAIN}_{OLD_KEY}",
version=3,
)
entry.add_to_hass(hass)
return entry
async def test_recorded_history_survives_the_re_key(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
"""The claim the whole in-place rewrite exists to make good on."""
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
device = dev_reg.async_get_or_create(
config_entry_id=purifier_entry.entry_id, identifiers={(DOMAIN, OLD_KEY)}
)
dust = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{OLD_KEY}_dust",
config_entry=purifier_entry,
device_id=device.id,
suggested_object_id="bedroom_purifier_dust",
)
await _seed_statistics(hass, dust.entity_id)
assert await _means(hass, dust.entity_id) == RECORDED
rekey_entry(hass, purifier_entry, OLD_KEY, NEW_KEY)
await async_wait_recording_done(hass)
# The row moved to the new identity...
moved = ent_reg.async_get(dust.entity_id)
assert moved is not None
assert moved.unique_id == f"{DOMAIN}_{NEW_KEY}_dust"
# ...without moving the entity_id, which is what the statistics are
# filed under -- so the history is still there, unchanged and still
# attached to the entity the user sees.
assert moved.entity_id == "sensor.bedroom_purifier_dust"
assert await _means(hass, dust.entity_id) == RECORDED
# The metadata row is still filed under this entity_id too, so the
# recorder has not quietly started a second series alongside it.
metadata = await get_instance(hass).async_add_executor_job(
partial(get_metadata, hass, statistic_ids={dust.entity_id})
)
assert set(metadata) == {dust.entity_id}
async def test_a_removed_duplicate_does_not_take_the_surviving_history_with_it(
recorder_mock, hass: HomeAssistant, purifier_entry: MockConfigEntry
) -> None:
"""Where the destination key is already taken, the old-key row is deleted
rather than rewritten. The history belongs to whichever entity_id the
user has been looking at all along -- the surviving row -- so deleting
the dead duplicate must not disturb it.
This is the one path in the re-key that destroys a registry row, so it
is the one worth proving keeps its hands off the recorder.
"""
ent_reg = er.async_get(hass)
live = ent_reg.async_get_or_create(
"sensor",
DOMAIN,
f"{DOMAIN}_{NEW_KEY}_dust",
config_entry=purifier_entry,
suggested_object_id="bedroom_purifier_dust",
)
stale = ent_reg.async_get_or_create(
"sensor", DOMAIN, f"{DOMAIN}_{OLD_KEY}_dust", config_entry=purifier_entry
)
assert stale.entity_id != live.entity_id
await _seed_statistics(hass, live.entity_id)
rekey_entry(hass, purifier_entry, OLD_KEY, NEW_KEY)
await async_wait_recording_done(hass)
assert ent_reg.async_get(stale.entity_id) is None
assert ent_reg.async_get(live.entity_id) is not None
assert await _means(hass, live.entity_id) == RECORDED