diff --git a/README.md b/README.md index 2e1ec6b..5c70617 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ historical data. > > Removing the *repository* in HACS is safe and is exactly what the migration expects. > The config entry stays where it is, showing up as `Integration not found`, which looks broken but is the state the new integration needs in order to take your entities over with their history intact. +> Do it before you install the new integration: as long as the old version is still on disk it holds the webhook routes, and the new one refuses to start with `Webhook routes are already registered by another instance of this integration`. > > Both actions are called "remove" in the UI and look interchangeable, so this is worth saying out loud. > The full step-by-step procedure, with the new repository's name and URL, will be published together with the new integration. diff --git a/tests/test_predecessor_history_e2e.py b/tests/test_predecessor_history_e2e.py new file mode 100644 index 0000000..83e4476 --- /dev/null +++ b/tests/test_predecessor_history_e2e.py @@ -0,0 +1,341 @@ +"""End-to-end proof that the rename does not cost a single measurement. + +`test_predecessor_adoption.py` proves the registry side of the move: the `entity_id` +does not change. This file closes the loop the user actually cares about by putting a +real recorder behind it - short-term history *and* long-term statistics are written for +the predecessor's entity, the new integration is then set up exactly as a user would set +it up (config flow options, real HTTP upload from the station), and the same rows are +read back afterwards through the same API the History and Statistics panels use. + +The scenario mirrors the documented upgrade procedure: + +1. The old integration ran and recorded measurements. +2. Its repository was swapped in HACS, so its files are gone and its entities sit at + `unavailable` with `restored: true` while its config entry stays behind. +3. The user adds the renamed integration, typing only the credentials. + +Set ``SWS_MIGRATION_REPORT`` to a file path to dump the before/after transcript. +""" + +from __future__ import annotations + +from datetime import timedelta +import os +from pathlib import Path +from typing import Any + +import pytest +from pytest_homeassistant_custom_component.common import MockConfigEntry +from pytest_homeassistant_custom_component.components.recorder.common import async_wait_recording_done + +from custom_components.sws12500.const import ( + API_ID, + API_KEY, + DOMAIN, + OUTSIDE_HUMIDITY, + OUTSIDE_TEMP, + SENSORS_TO_LOAD, + WSLINK, + WSLINK_URL, +) +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.components.recorder.models import StatisticMeanType +from homeassistant.components.recorder.statistics import async_import_statistics, statistics_during_period +from homeassistant.components.recorder.util import get_instance +from homeassistant.const import ATTR_RESTORED, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +OLD_DOMAIN = "sws12500_legacy" + +STATION_ID = "migration-station" +STATION_PW = "migration-secret" + +# The object id the old integration handed out. Deliberately unlike anything the current +# naming scheme produces, so a recreated entity is impossible to confuse with a moved one. +OLD_OBJECT_ID = "sws12500_outside_temp" +OLD_ENTITY_ID = f"sensor.{OLD_OBJECT_ID}" + +# Three readings recorded by the old integration, before anything was renamed. +HISTORIC_READINGS = ("18.6", "19.4", "20.2") + +# What the station uploads once the renamed integration is running. +NEW_READING = "21.7" + +ATTRS: dict[str, Any] = { + "unit_of_measurement": UnitOfTemperature.CELSIUS, + "device_class": "temperature", + "state_class": "measurement", + "friendly_name": "Outside temperature", +} + + +@pytest.fixture(autouse=True) +def _recorder(recorder_mock) -> None: + """Every test here needs a real recorder behind the registries.""" + + +def _upload(**overrides: str) -> dict[str, str]: + """A minimal WSLink upload carrying the outside temperature and humidity.""" + return { + "wsid": STATION_ID, + "wspw": STATION_PW, + "apiver": "0.6", + "t1tem": NEW_READING, + "t1hum": "62", + **overrides, + } + + +async def _record_predecessor_history(hass: HomeAssistant) -> MockConfigEntry: + """Run the old integration's lifetime: entities, measurements, then uninstalled.""" + old = MockConfigEntry( + domain=OLD_DOMAIN, + title="Sencor SWS 12500", + options={ + API_ID: STATION_ID, + API_KEY: STATION_PW, + WSLINK: True, + SENSORS_TO_LOAD: [OUTSIDE_TEMP, OUTSIDE_HUMIDITY], + }, + ) + old.add_to_hass(hass) + + registry = er.async_get(hass) + registered = registry.async_get_or_create( + "sensor", + OLD_DOMAIN, + OUTSIDE_TEMP, + config_entry=old, + suggested_object_id=OLD_OBJECT_ID, + original_name="Outside temperature", + ) + assert registered.entity_id == OLD_ENTITY_ID + + for reading in HISTORIC_READINGS: + hass.states.async_set(OLD_ENTITY_ID, reading, ATTRS) + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # Long-term statistics, the rows behind the "Statistics" graph. Keyed on the same + # entity_id, which is the whole point of the exercise. + start = dt_util.utcnow().replace(minute=0, second=0, microsecond=0) - timedelta(hours=2) + async_import_statistics( + hass, + { + "mean_type": StatisticMeanType.ARITHMETIC, + "has_sum": False, + "name": None, + "source": "recorder", + "statistic_id": OLD_ENTITY_ID, + "unit_class": "temperature", + "unit_of_measurement": UnitOfTemperature.CELSIUS, + }, + [ + {"start": start, "mean": 18.6, "min": 18.0, "max": 19.0}, + {"start": start + timedelta(hours=1), "mean": 19.8, "min": 19.4, "max": 20.2}, + ], + ) + await async_wait_recording_done(hass) + + # HACS swap done, files gone: this is the placeholder state Home Assistant writes for + # a registered entity nothing is driving any more. + hass.states.async_set(OLD_ENTITY_ID, STATE_UNAVAILABLE, {**ATTRS, ATTR_RESTORED: True}) + await hass.async_block_till_done() + + return old + + +async def _history_for(hass: HomeAssistant, entity_id: str, since) -> list[str]: + """Read back the recorded states the History panel would draw.""" + states = await get_instance(hass).async_add_executor_job( + get_significant_states, hass, since, None, [entity_id] + ) + return [state.state for state in states.get(entity_id, [])] + + +async def _statistics_for(hass: HomeAssistant, entity_id: str, since) -> list[float]: + """Read back the long-term statistics the Statistics panel would draw.""" + rows = await get_instance(hass).async_add_executor_job( + statistics_during_period, hass, since, None, {entity_id}, "hour", None, {"mean"} + ) + return [row["mean"] for row in rows.get(entity_id, [])] + + +@pytest.fixture +async def migrated( + hass: HomeAssistant, + enable_custom_integrations, + hass_client_no_auth, + monkeypatch: pytest.MonkeyPatch, +): + """The full move: recorded predecessor, renamed integration set up over the top.""" + assert await async_setup_component(hass, "http", {"http": {}}) + since = dt_util.utcnow() - timedelta(hours=3) + + old = await _record_predecessor_history(hass) + before = await _history_for(hass, OLD_ENTITY_ID, since) + statistics_before = await _statistics_for(hass, OLD_ENTITY_ID, since) + + # The rename itself. In the new repository this is a source change; here it is the + # one thing that has to be simulated, because both domains are still `sws12500`. + monkeypatch.setattr("custom_components.sws12500.predecessor.PREDECESSOR_DOMAIN", OLD_DOMAIN) + + # What the user types into the new integration's config flow: the credentials, and + # nothing else. The protocol flag and the sensor list are inherited. + new = MockConfigEntry( + domain=DOMAIN, + title="Sencor SWS 12500", + options={API_ID: STATION_ID, API_KEY: STATION_PW}, + ) + new.add_to_hass(hass) + assert await hass.config_entries.async_setup(new.entry_id) + await hass.async_block_till_done() + + # The station uploads again, to the endpoint the inherited WSLINK flag opened. + client = await hass_client_no_auth() + response = await client.get(WSLINK_URL, params=_upload()) + assert response.status == 200 + assert await response.text() == "OK" + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + return { + "old_entry": old, + "new_entry": new, + "since": since, + "history_before": before, + "history_after": await _history_for(hass, OLD_ENTITY_ID, since), + "statistics_before": statistics_before, + "statistics_after": await _statistics_for(hass, OLD_ENTITY_ID, since), + } + + +async def test_the_measurements_survive_the_move(hass: HomeAssistant, migrated) -> None: + """One series, one entity_id, spanning both integrations.""" + assert migrated["history_before"] == [*HISTORIC_READINGS, STATE_UNAVAILABLE] + + # Everything recorded before the move is still there, and the reading the renamed + # integration just took continues the same series. + after = migrated["history_after"] + assert after[: len(HISTORIC_READINGS)] == list(HISTORIC_READINGS) + assert after[-1] == NEW_READING + + # Long-term statistics hang off the same entity_id and are untouched. + assert migrated["statistics_after"] == migrated["statistics_before"] == [18.6, 19.8] + + +async def test_the_entity_is_the_same_row_now_owned_by_the_new_integration( + hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry +) -> None: + """Moved, not recreated: same entity_id, new platform, no duplicate left behind.""" + moved = entity_registry.async_get(OLD_ENTITY_ID) + assert moved is not None + assert moved.platform == DOMAIN + assert moved.config_entry_id == migrated["new_entry"].entry_id + + # Nothing was created under the current naming scheme for the same reading, which is + # what an unmigrated install would have produced. + duplicates = [ + entry.entity_id + for entry in er.async_entries_for_config_entry(entity_registry, migrated["new_entry"].entry_id) + if entry.unique_id == OUTSIDE_TEMP and entry.entity_id != OLD_ENTITY_ID + ] + assert duplicates == [] + + # The live state is the new upload, on the old entity_id. + state = hass.states.get(OLD_ENTITY_ID) + assert state is not None + assert state.state == NEW_READING + + # The predecessor's config entry is gone, so Settings is clean. + assert hass.config_entries.async_entries(OLD_DOMAIN) == [] + + +async def test_options_the_user_never_retyped_came_across(hass: HomeAssistant, migrated) -> None: + """The upload above only worked because WSLINK was inherited before the routes.""" + options = migrated["new_entry"].options + assert options[WSLINK] is True + assert OUTSIDE_TEMP in options[SENSORS_TO_LOAD] + # And the credentials the user did retype are the ones in force. + assert options[API_ID] == STATION_ID + + +async def test_without_the_move_the_history_is_orphaned( + hass: HomeAssistant, + enable_custom_integrations, + hass_client_no_auth, + entity_registry: er.EntityRegistry, +) -> None: + """The counterfactual, and the shipped default: `PREDECESSOR_DOMAIN == DOMAIN`. + + Nothing is adopted, so the new integration mints its own entity under the current + naming scheme and the recorded series stops at `unavailable`. This is what the + migration exists to prevent - and, until the domain actually changes, also proof that + the adoption pass is inert rather than quietly rewriting a live installation. + """ + assert await async_setup_component(hass, "http", {"http": {}}) + since = dt_util.utcnow() - timedelta(hours=3) + + old = await _record_predecessor_history(hass) + + new = MockConfigEntry( + domain=DOMAIN, + title="Sencor SWS 12500", + options={API_ID: STATION_ID, API_KEY: STATION_PW, WSLINK: True}, + ) + new.add_to_hass(hass) + assert await hass.config_entries.async_setup(new.entry_id) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + assert (await client.get(WSLINK_URL, params=_upload())).status == 200 + await hass.async_block_till_done() + await async_wait_recording_done(hass) + + # A second entity id for the same reading; the old series ends where it ended. + fresh = entity_registry.async_get_entity_id("sensor", DOMAIN, OUTSIDE_TEMP) + assert fresh is not None + assert fresh != OLD_ENTITY_ID + assert await _history_for(hass, OLD_ENTITY_ID, since) == [*HISTORIC_READINGS, STATE_UNAVAILABLE] + # The new entity starts from `unknown`, as any freshly created one does, and knows + # nothing of what came before. + assert await _history_for(hass, fresh, since) == [STATE_UNKNOWN, NEW_READING] + + # The predecessor's entry is untouched, exactly as before this change. + assert hass.config_entries.async_entries(OLD_DOMAIN) == [old] + + +async def test_write_migration_report(hass: HomeAssistant, migrated, entity_registry: er.EntityRegistry) -> None: + """Dump the before/after transcript when SWS_MIGRATION_REPORT names a file.""" + target = os.environ.get("SWS_MIGRATION_REPORT") + if not target: + pytest.skip("SWS_MIGRATION_REPORT not set") + + moved = entity_registry.async_get(OLD_ENTITY_ID) + assert moved is not None + lines = [ + f"Domain move: {OLD_DOMAIN} -> {DOMAIN}", + "", + "BEFORE (old integration recorded, then uninstalled)", + f" entity_id : {OLD_ENTITY_ID}", + f" platform : {OLD_DOMAIN}", + f" recorded states : {', '.join(migrated['history_before'])}", + f" long-term statistics : {migrated['statistics_before']}", + "", + f"AFTER (renamed integration set up, station uploaded {NEW_READING} C)", + f" entity_id : {moved.entity_id}", + f" platform : {moved.platform}", + f" config entry : {moved.config_entry_id} ({migrated['new_entry'].domain})", + f" recorded states : {', '.join(migrated['history_after'])}", + f" long-term statistics : {migrated['statistics_after']}", + f" live state : {hass.states.get(OLD_ENTITY_ID).state} C", + f" inherited options : {WSLINK}={migrated['new_entry'].options[WSLINK]}, " + f"{SENSORS_TO_LOAD}={migrated['new_entry'].options[SENSORS_TO_LOAD]}", + f" predecessor entries : {hass.config_entries.async_entries(OLD_DOMAIN)}", + "", + "entity_id unchanged, history and statistics continue in one series.", + ] + Path(target).write_text("\n".join(lines) + "\n", encoding="utf-8")