Files
MeshTerm/tests/test_retired.py
T
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00

203 lines
8.4 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""A retired name is never reused.
A value can outlive its key: a file nobody has saved since the key left still holds it. If
the name came back with a new meaning — seconds become minutes, a range moves — an old
value that happens to pass the new spec would load cleanly and mean something else, and
no validation can catch that. So every name that leaves is recorded as retired, and these
tests fail the moment one comes back:
* the preference registry against :data:`meshterm.core.preferences.RETIRED`, and the
device-setting registry against :data:`meshterm.core.device_config.RETIRED`;
* every persisted record type — a dataclass in :mod:`meshterm.core` declaring its own
``RETIRED`` set — against its own fields. The record is the path: a name retired from
one record says nothing about another, where it may be exactly the right name.
"""
from __future__ import annotations
import dataclasses
import importlib
import json
import logging
import pkgutil
from pathlib import Path
import pytest
import meshterm.core
from meshterm.core import device_config, preferences
from meshterm.core.admin_store import AdminStore
from meshterm.core.models import Contact
from meshterm.core.preferences import (
PREFERENCES,
RETIRED,
PreferenceError,
Preferences,
report_dropped,
)
from meshterm.core.remote_store import RemoteStore
from meshterm.core.settings_store import SettingsStore
def _record_types() -> list[type]:
"""Every dataclass in meshterm.core that declares a ``RETIRED`` set of field names."""
found = []
for info in pkgutil.iter_modules(meshterm.core.__path__):
module = importlib.import_module(f"meshterm.core.{info.name}")
for value in vars(module).values():
if (
isinstance(value, type)
and value.__module__ == module.__name__
and dataclasses.is_dataclass(value)
and isinstance(getattr(value, "RETIRED", None), frozenset)
):
found.append(value)
return found
def test_no_live_preference_takes_a_retired_key() -> None:
"""A preference that returns changed returns under a new name."""
assert not {spec.key for spec in PREFERENCES} & RETIRED
def test_no_live_device_setting_takes_a_retired_key() -> None:
"""The settings store restores values onto the radio; a reused key would misplace one."""
assert not {spec.key for spec in device_config.DEVICE_SETTINGS} & device_config.RETIRED
#: Every persisted record type, by the module that writes it. A store that grows a record
#: adds it here, and the sweep below checks it without being told how.
_EXPECTED_RECORDS = {
"admin_store": {"AdminCredential"},
"advert_store": {"_Device", "_Document"},
"channel_store": {"RememberedChannel"},
"contact_store": {"RememberedContact"},
"courier_store": {"QueuedMessage"},
"device_store": {"RememberedDevice"},
"remote_store": {"CachedValue", "_RemoteNode"},
"watch_store": {"WatchedNode", "Alert", "_State"},
}
def test_the_record_walk_finds_every_store() -> None:
"""The sweep below is not vacuous: it sees each store's records."""
found = {(cls.__module__.rsplit(".", 1)[1], cls.__qualname__) for cls in _record_types()}
expected = {(module, name) for module, names in _EXPECTED_RECORDS.items() for name in names}
assert expected <= found
@pytest.mark.parametrize("record", _record_types(), ids=lambda cls: cls.__qualname__)
def test_no_record_takes_back_a_retired_field(record: type) -> None:
"""Each persisted record's fields are disjoint from the names it has retired."""
fields = {f.name for f in dataclasses.fields(record)}
assert not fields & record.RETIRED
def test_loading_refuses_a_retired_key(tmp_path: Path) -> None:
"""A retired key in the file is not applied, and is named as retired."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(f"{retired} = 3\ntrace_cooldown_s = 2.0\n", encoding="utf-8")
prefs = Preferences.load(path)
assert prefs.dropped == {retired: preferences.DROPPED_RETIRED}
assert prefs.trace_cooldown_s == 2.0
def test_the_next_save_leaves_out_everything_refused(tmp_path: Path) -> None:
"""Retired, unknown and invalid entries are all gone once the file is saved."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(
f"{retired} = 3\ntrace_cooldwn_s = 2.0\nhistory_days = -4\ntrace_cooldown_s = 2.0\n",
encoding="utf-8",
)
prefs = Preferences.load(path)
assert set(prefs.dropped) == {retired, "trace_cooldwn_s", "history_days"}
prefs.save()
text = path.read_text(encoding="utf-8")
for key in (retired, "trace_cooldwn_s", "history_days"):
assert f"{key} =" not in text
assert "trace_cooldown_s = 2.0" in text
def test_a_retired_key_is_named_as_such_when_set(tmp_path: Path) -> None:
"""`preferences set` on a retired key says it is no longer a preference."""
with pytest.raises(PreferenceError, match="no longer a preference"):
Preferences().set(sorted(RETIRED)[0], 1)
def test_drops_are_logged_by_kind(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""A retired key is expected (INFO); a typo or a bad value is a WARNING naming it."""
path = tmp_path / "preferences.toml"
retired = sorted(RETIRED)[0]
path.write_text(f"{retired} = 3\ntrace_cooldwn_s = 2.0\nhistory_days = -4\n", encoding="utf-8")
log = logging.getLogger("test_retired")
with caplog.at_level(logging.INFO, logger="test_retired"):
report_dropped(Preferences.load(path), log)
by_level = {(r.levelno, r.getMessage()) for r in caplog.records}
assert any(level == logging.INFO and retired in msg for level, msg in by_level)
assert any(level == logging.WARNING and "trace_cooldwn_s" in msg for level, msg in by_level)
assert any(level == logging.WARNING and "history_days" in msg for level, msg in by_level)
# -- the stores write only what they know ----------------------------------------------
_NODE = Contact(name="Hilltop-Repeater", public_key="cd" * 32, key_prefix="cd" * 6)
def test_the_admin_store_writes_only_known_fields(tmp_path: Path) -> None:
"""A stray field on a credential, or a stray record, is gone after the next write."""
path = tmp_path / "admin.json"
path.write_text(
json.dumps(
{
"cd" * 32: {"password": "pw", "label": "H", "last_used": "", "role": "x"},
"junk": {"no": "password"},
}
)
)
store = AdminStore(path)
assert store.get(_NODE) == "pw"
store.remember(Contact(name="Other", public_key="ef" * 32, key_prefix="ef" * 6), "pw2")
written = json.loads(path.read_text())
assert set(written) == {"cd" * 32, "ef" * 32}
assert written["cd" * 32].keys() == {"password", "label", "last_used"}
def test_the_remote_store_writes_only_known_fields(tmp_path: Path) -> None:
"""Unknown fields on a node or a cached value do not survive, and the spelling holds."""
path = tmp_path / "remote.json"
stamp = "2026-09-01T12:00:00+00:00"
path.write_text(
json.dumps(
{
"cd" * 32: {
"settings": {
"txdelay": {"value": "0.5", "read_at": stamp, "unit": "s"},
"bridge.delay": {"unsupported": True, "read_at": stamp},
},
"history": ["ver"],
"notes": "stray",
}
}
)
)
store = RemoteStore(path)
store.append_history(_NODE, "clock")
record = json.loads(path.read_text())["cd" * 32]
assert record.keys() == {"settings", "history"}
assert record["settings"]["txdelay"] == {"value": "0.5", "read_at": stamp}
assert record["settings"]["bridge.delay"] == {"unsupported": True, "read_at": stamp}
assert record["history"] == ["ver", "clock"]
def test_the_settings_store_keeps_only_registered_settings(tmp_path: Path) -> None:
"""A key that is not a device setting is never offered for restore, and leaves the file."""
path = tmp_path / "settings.json"
path.write_text(json.dumps({"devices": {"ab" * 32: {"name": "Node", "no_such_key": 3}}}))
store = SettingsStore(path)
assert store.settings("ab" * 32) == {"name": "Node"}
store.remember("ab" * 32, "tx_power", 14)
assert json.loads(path.read_text())["devices"]["ab" * 32] == {"name": "Node", "tx_power": 14}