feat(config)!: migrate entries to v3 — one address, no androidtv key (t41)

ENTRY_VERSION = 3, declared once in const.py and reused as the flow's VERSION so
the stored shape and the migration cannot drift.

v2 → v3 mirrors the two shape changes:
* the duplicated address fields collapse into CONF_HOST — the protocol host
  wins, else the ADB host (both name the same Shield), reading options first and
  data second so a hand-written seed is covered too;
* the androidtv-entity key is dropped from data AND options with a logged note
  naming the dropped entity (the old value would only mislead now that nothing
  reads it).

A v1 entry still travels the whole way in one call (the v1→v2 protocol defaults
are applied, then the v3 collapse). Downgrades (version > 3) are rejected so HA
can offer a rollback, and the current version is a no-op.

Existing entries carrying the entity key keep working until the migration runs:
nothing reads it anymore (the c1 commit), which is exactly why the migration can
be a plain removal.

Test note: my first version of the version-parity pin called a bare
entity_stublib.install() and leaked the shallow stub set into the entity-module
loader of later tests (STATE_PAUSED/entity_available import failures). It now
uses the file's scoped harness, like every other test there.

Verified: 316 passed / 2 skipped, 317 / 1 with androidtvremote2, ruff clean,
drift engine exit 0 on HEAD (36 call arguments — one more awaited/checked call).
This commit is contained in:
James Mackay committed 2026-10-11 06:19:36 +01:00
1 parent 75ebeb40b0
commit 81f70585cd
5 files changed
+187 -46

No files matched your search

+82 -12
View File
@@ -15,6 +15,7 @@ import pytest
from conftest import _SHIELD_DIR, _load_module
from nsh.const import (
CONF_ADB_ENABLED,
CONF_ADB_HOST,
CONF_ADB_PORT,
CONF_ADB_STORE_APP_INFO,
CONF_HOST,
@@ -22,6 +23,7 @@ from nsh.const import (
CONF_PAIRING_CODE,
CONF_POWER_ENTITY,
CONF_PROTOCOL_ENABLED,
CONF_PROTOCOL_HOST,
CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT,
DEFAULT_ADB_PORT,
@@ -392,12 +394,25 @@ class TestReauthFlow:
class TestEntryMigration:
async def test_v1_gains_protocol_defaults_and_keeps_adb_options(self, tmp_path, monkeypatch):
"""t41: v2 → v3 collapses the two address fields into CONF_HOST.
The protocol host wins, else the ADB host (both name the same Shield); the
legacy keys are removed so nothing can read a stale duplicate. A v1 entry
travels the whole way in one call.
"""
async def test_v1_keeps_its_adb_options_and_gains_both_defaults(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=1,
options={"adb_enabled": True, "adb_port": 5555, "jellyfin_entity": "jf.x"},
data={},
options={
"adb_enabled": True,
"adb_port": 5555,
"jellyfin_entity": "jf.x",
"adb_host": "10.0.0.31", # legacy address
},
title="mdarcy",
)
@@ -407,27 +422,82 @@ class TestEntryMigration:
# ADB options carried over byte-compatibly
assert entry.options["adb_enabled"] is True
assert entry.options["adb_port"] == 5555
# Protocol fields default; the entry keeps its own address (t41: the v1
# adb_host is mapped onto CONF_HOST, empty here)
assert entry.options["jellyfin_entity"] == "jf.x"
# the v2 default, then the v3 address collapse
assert entry.options[CONF_PROTOCOL_ENABLED] is False
assert entry.options[CONF_HOST] == ""
assert hass.config_entries.updating_entry_calls == [
(entry, {"options": entry.options, "version": 2})
]
assert entry.options[CONF_HOST] == "10.0.0.31"
assert "adb_host" not in entry.options
assert entry.version == 3
async def test_v2_is_a_noop(self, tmp_path, monkeypatch):
async def test_v2_prefers_the_protocol_host_over_the_adb_host(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
options={CONF_PROTOCOL_ENABLED: True},
data={},
options={
CONF_PROTOCOL_ENABLED: True,
CONF_PROTOCOL_HOST: "10.0.0.2", # the address that was paired
CONF_ADB_HOST: "10.0.0.1",
},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.2"
assert CONF_PROTOCOL_HOST not in entry.options
assert CONF_ADB_HOST not in entry.options
assert entry.options[CONF_PROTOCOL_ENABLED] is True # untouched
assert entry.version == 3
async def test_v2_falls_back_to_the_adb_host(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "10.0.0.1"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == "10.0.0.1"
assert CONF_ADB_HOST not in entry.options
async def test_v2_without_an_address_migrates_to_an_empty_one(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=2,
data={},
options={CONF_PROTOCOL_ENABLED: False, CONF_ADB_HOST: "", CONF_PROTOCOL_HOST: ""},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert entry.options[CONF_HOST] == ""
assert CONF_ADB_HOST not in entry.options
assert CONF_PROTOCOL_HOST not in entry.options
assert entry.version == 3
async def test_the_current_version_is_a_noop(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(
version=3,
data={},
options={CONF_PROTOCOL_ENABLED: True, CONF_HOST: "shield.local"},
title="mdarcy",
)
assert await module.async_migrate_entry(hass, entry) is True
assert hass.config_entries.updating_entry_calls == []
async def test_downgrade_rejected(self, tmp_path, monkeypatch):
async def test_downgrade_rejected(self, tmp_path):
module = _entry_module()
hass = FakeHass(str(tmp_path))
entry = SimpleNamespace(version=3, options={}, title="x")
entry = SimpleNamespace(version=4, options={}, title="x")
assert await module.async_migrate_entry(hass, entry) is False