mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
714 lines
26 KiB
Python
714 lines
26 KiB
Python
|
|
"""Tests for MeshCore roster management: reconcile, route health, name collisions.
|
||
|
|
|
||
|
|
Covers:
|
||
|
|
- reconcile_contacts(): full-refetch replace semantics (the crux — a merge
|
||
|
|
can never remove, so this is what lets a resync drop stale entries)
|
||
|
|
- check_route_health(): region-routing cells pointing at absent rooms/channels
|
||
|
|
- find_name_collisions(): same name, different pubkey
|
||
|
|
- MeshCoreTransport.self_info(): reports the ACTUAL connection, never a
|
||
|
|
config value belonging to a different conn_type
|
||
|
|
- MeshCoreTransport._refresh_contacts_async / remove_contact / import_contact
|
||
|
|
|
||
|
|
The companion is mocked throughout: no device I/O, no mesh traffic, nothing
|
||
|
|
removed from a real radio.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import sys
|
||
|
|
import types
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from meshai.meshcore_roster import (
|
||
|
|
check_route_health,
|
||
|
|
find_name_collisions,
|
||
|
|
reconcile_contacts,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _contact(pubkey: str, name: str = "node", type_: int = 1, **extra) -> dict:
|
||
|
|
"""A lib-shaped contact dict (public_key is the lib's key field)."""
|
||
|
|
base = {
|
||
|
|
"public_key": pubkey,
|
||
|
|
"adv_name": name,
|
||
|
|
"type": type_,
|
||
|
|
"last_advert": 1000,
|
||
|
|
"out_path_len": -1,
|
||
|
|
"out_path": "",
|
||
|
|
"out_path_hash_mode": 0,
|
||
|
|
"flags": 0,
|
||
|
|
"adv_lat": 0.0,
|
||
|
|
"adv_lon": 0.0,
|
||
|
|
}
|
||
|
|
base.update(extra)
|
||
|
|
return base
|
||
|
|
|
||
|
|
|
||
|
|
def _roster(pubkey: str, name: str = "node", type_: int = 1) -> dict:
|
||
|
|
"""A roster-projection contact dict (as get_contacts() returns)."""
|
||
|
|
return {"pubkey": pubkey, "name": name, "type": type_, "last_advert": 1000}
|
||
|
|
|
||
|
|
|
||
|
|
KEY_A = "aa" * 32
|
||
|
|
KEY_B = "bb" * 32
|
||
|
|
KEY_C = "cc" * 32
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Part 1: reconcile_contacts — replace semantics
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestReconcileContacts:
|
||
|
|
def test_absent_contact_is_dropped(self):
|
||
|
|
"""The crux: a contact missing from the FULL refetch is removed.
|
||
|
|
|
||
|
|
The lib's own fetch handler only merges, so without this a deleted
|
||
|
|
contact survives in the cache forever.
|
||
|
|
"""
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")}
|
||
|
|
fresh = {KEY_A: _contact(KEY_A, "alpha")}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert KEY_B not in reconciled
|
||
|
|
assert set(reconciled) == {KEY_A}
|
||
|
|
assert stats["removed"] == 1
|
||
|
|
assert stats["removed_keys"] == [KEY_B]
|
||
|
|
assert stats["before"] == 2
|
||
|
|
assert stats["after"] == 1
|
||
|
|
|
||
|
|
def test_new_contact_is_added(self):
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha")}
|
||
|
|
fresh = {KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert set(reconciled) == {KEY_A, KEY_B}
|
||
|
|
assert stats["added"] == 1
|
||
|
|
assert stats["added_keys"] == [KEY_B]
|
||
|
|
assert stats["removed"] == 0
|
||
|
|
|
||
|
|
def test_changed_contact_is_updated(self):
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha", last_advert=1000)}
|
||
|
|
fresh = {KEY_A: _contact(KEY_A, "alpha", last_advert=2000)}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert reconciled[KEY_A]["last_advert"] == 2000
|
||
|
|
assert stats["updated"] == 1
|
||
|
|
assert stats["added"] == 0
|
||
|
|
assert stats["removed"] == 0
|
||
|
|
|
||
|
|
def test_unchanged_contact_not_counted_as_updated(self):
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha")}
|
||
|
|
fresh = {KEY_A: _contact(KEY_A, "alpha")}
|
||
|
|
|
||
|
|
_, stats = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert stats["updated"] == 0
|
||
|
|
assert stats["added"] == 0
|
||
|
|
assert stats["removed"] == 0
|
||
|
|
assert stats["after"] == 1
|
||
|
|
|
||
|
|
def test_fields_merge_rather_than_replace(self):
|
||
|
|
"""A fresh record missing an optional field must not blank the cached one."""
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha", nickname="local-only")}
|
||
|
|
fresh = {KEY_A: {"public_key": KEY_A, "adv_name": "alpha-renamed"}}
|
||
|
|
|
||
|
|
reconciled, _ = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert reconciled[KEY_A]["adv_name"] == "alpha-renamed" # fresh wins
|
||
|
|
assert reconciled[KEY_A]["nickname"] == "local-only" # survives
|
||
|
|
assert reconciled[KEY_A]["type"] == 1 # survives
|
||
|
|
|
||
|
|
def test_add_remove_and_update_together(self):
|
||
|
|
cached = {
|
||
|
|
KEY_A: _contact(KEY_A, "alpha", last_advert=1000),
|
||
|
|
KEY_B: _contact(KEY_B, "bravo"),
|
||
|
|
}
|
||
|
|
fresh = {
|
||
|
|
KEY_A: _contact(KEY_A, "alpha", last_advert=2000),
|
||
|
|
KEY_C: _contact(KEY_C, "charlie"),
|
||
|
|
}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert set(reconciled) == {KEY_A, KEY_C}
|
||
|
|
assert (stats["added"], stats["removed"], stats["updated"]) == (1, 1, 1)
|
||
|
|
assert stats["added_keys"] == [KEY_C]
|
||
|
|
assert stats["removed_keys"] == [KEY_B]
|
||
|
|
|
||
|
|
def test_empty_fresh_empties_roster(self):
|
||
|
|
"""An authoritative full fetch of zero contacts means zero contacts.
|
||
|
|
|
||
|
|
Guarding this would mean a genuinely-wiped companion could never be
|
||
|
|
reflected; the caller is responsible for only passing a SUCCESSFUL fetch.
|
||
|
|
"""
|
||
|
|
cached = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts(cached, {})
|
||
|
|
|
||
|
|
assert reconciled == {}
|
||
|
|
assert stats["removed"] == 2
|
||
|
|
assert stats["after"] == 0
|
||
|
|
|
||
|
|
def test_empty_cache_adds_everything(self):
|
||
|
|
fresh = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
|
||
|
|
|
||
|
|
reconciled, stats = reconcile_contacts({}, fresh)
|
||
|
|
|
||
|
|
assert set(reconciled) == {KEY_A, KEY_B}
|
||
|
|
assert stats["added"] == 2
|
||
|
|
assert stats["before"] == 0
|
||
|
|
|
||
|
|
def test_does_not_mutate_inputs(self):
|
||
|
|
cached = {KEY_A: _contact(KEY_A, "alpha", last_advert=1000)}
|
||
|
|
fresh = {KEY_B: _contact(KEY_B, "bravo")}
|
||
|
|
cached_snapshot = {KEY_A: dict(cached[KEY_A])}
|
||
|
|
|
||
|
|
reconcile_contacts(cached, fresh)
|
||
|
|
|
||
|
|
assert cached == cached_snapshot # caller's cache untouched
|
||
|
|
assert set(fresh) == {KEY_B}
|
||
|
|
|
||
|
|
def test_merge_semantics_alone_can_never_remove(self):
|
||
|
|
"""Contrast: the lib's merge keeps a stale entry that reconcile drops.
|
||
|
|
|
||
|
|
Documents exactly what the reconcile adds over the lib's behavior.
|
||
|
|
"""
|
||
|
|
cached = {KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)}
|
||
|
|
fresh = {KEY_A: _contact(KEY_A)}
|
||
|
|
|
||
|
|
merged = dict(cached)
|
||
|
|
merged.update(fresh) # what the lib does
|
||
|
|
reconciled, _ = reconcile_contacts(cached, fresh) # what we do
|
||
|
|
|
||
|
|
assert KEY_B in merged # stale entry survives a merge
|
||
|
|
assert KEY_B not in reconciled # ...and is dropped by reconcile
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Part 2: check_route_health — dangling routing cells
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestCheckRouteHealth:
|
||
|
|
CHANNELS = ["Public", "#aida", "#sw-id-aida"]
|
||
|
|
|
||
|
|
def test_healthy_cells_report_nothing(self):
|
||
|
|
cells = {
|
||
|
|
"weather": {
|
||
|
|
"SW Idaho": {"mc": "#sw-id-aida", "enabled": True},
|
||
|
|
"SC Idaho": {"mc": f"room:{KEY_A}", "enabled": True},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
contacts = [_roster(KEY_A, "SC Room", type_=3)]
|
||
|
|
|
||
|
|
assert check_route_health(cells, self.CHANNELS, contacts) == []
|
||
|
|
|
||
|
|
def test_cell_pointing_at_missing_room_is_flagged(self):
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": True}}}
|
||
|
|
contacts = [_roster(KEY_A, "SC Room", type_=3)]
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, contacts)
|
||
|
|
|
||
|
|
assert len(problems) == 1
|
||
|
|
assert problems[0]["family"] == "weather"
|
||
|
|
assert problems[0]["region"] == "SC Idaho"
|
||
|
|
assert problems[0]["kind"] == "room"
|
||
|
|
assert problems[0]["reason"] == "room_not_found"
|
||
|
|
assert problems[0]["enabled"] is True
|
||
|
|
|
||
|
|
def test_cell_pointing_at_missing_channel_is_flagged(self):
|
||
|
|
cells = {"fire": {"East Idaho": {"mc": "#e-id-aida", "enabled": True}}}
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, [])
|
||
|
|
|
||
|
|
assert len(problems) == 1
|
||
|
|
assert problems[0]["kind"] == "channel"
|
||
|
|
assert problems[0]["reason"] == "channel_not_found"
|
||
|
|
assert problems[0]["target"] == "#e-id-aida"
|
||
|
|
|
||
|
|
def test_room_cell_resolving_to_non_room_is_flagged(self):
|
||
|
|
"""A room cell that resolves to a plain node would address the wrong kind."""
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A}", "enabled": True}}}
|
||
|
|
contacts = [_roster(KEY_A, "Just A Node", type_=1)]
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, contacts)
|
||
|
|
|
||
|
|
assert len(problems) == 1
|
||
|
|
assert problems[0]["reason"] == "not_a_room"
|
||
|
|
|
||
|
|
def test_room_cell_matches_by_prefix(self):
|
||
|
|
"""The send path resolves rooms by pubkey PREFIX — so must this check.
|
||
|
|
|
||
|
|
A 12-hex prefix (what the room picker stores) is a legitimate cell
|
||
|
|
value; treating it as dangling would be a false alarm.
|
||
|
|
"""
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A[:12]}", "enabled": True}}}
|
||
|
|
contacts = [_roster(KEY_A, "SC Room", type_=3)]
|
||
|
|
|
||
|
|
assert check_route_health(cells, self.CHANNELS, contacts) == []
|
||
|
|
|
||
|
|
def test_room_prefix_match_is_case_insensitive(self):
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_A[:12].upper()}", "enabled": True}}}
|
||
|
|
contacts = [_roster(KEY_A, "SC Room", type_=3)]
|
||
|
|
|
||
|
|
assert check_route_health(cells, self.CHANNELS, contacts) == []
|
||
|
|
|
||
|
|
def test_disabled_cell_still_reported_but_marked(self):
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": False}}}
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, [])
|
||
|
|
|
||
|
|
assert len(problems) == 1
|
||
|
|
assert problems[0]["enabled"] is False
|
||
|
|
|
||
|
|
def test_cell_without_mc_target_is_skipped(self):
|
||
|
|
cells = {"weather": {"SW Idaho": {"mt": 3, "mc": None, "enabled": True}}}
|
||
|
|
|
||
|
|
assert check_route_health(cells, self.CHANNELS, []) == []
|
||
|
|
|
||
|
|
def test_empty_room_pubkey_treated_as_channel_name(self):
|
||
|
|
"""``room:`` with no pubkey is not a room target (parser yields None)."""
|
||
|
|
cells = {"weather": {"SC Idaho": {"mc": "room:", "enabled": True}}}
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, [])
|
||
|
|
|
||
|
|
assert len(problems) == 1
|
||
|
|
assert problems[0]["kind"] == "channel"
|
||
|
|
|
||
|
|
def test_empty_cells_and_missing_families(self):
|
||
|
|
assert check_route_health({}, self.CHANNELS, []) == []
|
||
|
|
assert check_route_health({"weather": None}, self.CHANNELS, []) == []
|
||
|
|
|
||
|
|
def test_multiple_families_and_regions(self):
|
||
|
|
cells = {
|
||
|
|
"weather": {
|
||
|
|
"SW Idaho": {"mc": "#sw-id-aida", "enabled": True}, # healthy
|
||
|
|
"SC Idaho": {"mc": f"room:{KEY_B}", "enabled": True}, # dangling
|
||
|
|
},
|
||
|
|
"fire": {
|
||
|
|
"East Idaho": {"mc": "#gone", "enabled": True}, # dangling
|
||
|
|
},
|
||
|
|
}
|
||
|
|
contacts = [_roster(KEY_A, "SC Room", type_=3)]
|
||
|
|
|
||
|
|
problems = check_route_health(cells, self.CHANNELS, contacts)
|
||
|
|
|
||
|
|
assert len(problems) == 2
|
||
|
|
assert {p["reason"] for p in problems} == {"room_not_found", "channel_not_found"}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Part 3: find_name_collisions
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestFindNameCollisions:
|
||
|
|
def test_same_name_different_pubkey_is_a_collision(self):
|
||
|
|
contacts = [
|
||
|
|
_roster(KEY_A, "SC ID AIDA Alerts", type_=3),
|
||
|
|
_roster(KEY_B, "SC ID AIDA Alerts", type_=3),
|
||
|
|
]
|
||
|
|
|
||
|
|
collisions = find_name_collisions(contacts)
|
||
|
|
|
||
|
|
assert len(collisions) == 1
|
||
|
|
assert collisions[0]["name"] == "SC ID AIDA Alerts"
|
||
|
|
assert collisions[0]["count"] == 2
|
||
|
|
assert {c["pubkey"] for c in collisions[0]["contacts"]} == {KEY_A, KEY_B}
|
||
|
|
|
||
|
|
def test_distinct_names_are_not_collisions(self):
|
||
|
|
contacts = [_roster(KEY_A, "SC ID AIDA"), _roster(KEY_B, "SC ID AIDA Alerts")]
|
||
|
|
|
||
|
|
assert find_name_collisions(contacts) == []
|
||
|
|
|
||
|
|
def test_same_pubkey_twice_is_not_a_collision(self):
|
||
|
|
contacts = [_roster(KEY_A, "dup"), _roster(KEY_A, "dup")]
|
||
|
|
|
||
|
|
assert find_name_collisions(contacts) == []
|
||
|
|
|
||
|
|
def test_unnamed_contacts_ignored(self):
|
||
|
|
contacts = [_roster(KEY_A, None), _roster(KEY_B, None)]
|
||
|
|
|
||
|
|
assert find_name_collisions(contacts) == []
|
||
|
|
|
||
|
|
def test_empty_roster(self):
|
||
|
|
assert find_name_collisions([]) == []
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Part 4: transport — self_info() connection reporting
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def fake_meshcore(monkeypatch):
|
||
|
|
"""Register a fake ``meshcore`` module for the lazy imports in the transport.
|
||
|
|
|
||
|
|
monkeypatch.setitem (not sys.modules.setdefault) so this wins even when the
|
||
|
|
real lib — or another test module's fake — is already imported, and is
|
||
|
|
restored afterwards.
|
||
|
|
"""
|
||
|
|
mod = types.ModuleType("meshcore")
|
||
|
|
|
||
|
|
class EventType:
|
||
|
|
ERROR = "ERROR"
|
||
|
|
CONTACTS = "CONTACTS"
|
||
|
|
OK = "OK"
|
||
|
|
|
||
|
|
mod.EventType = EventType
|
||
|
|
monkeypatch.setitem(sys.modules, "meshcore", mod)
|
||
|
|
return mod
|
||
|
|
|
||
|
|
|
||
|
|
class _Event:
|
||
|
|
def __init__(self, type_, payload=None):
|
||
|
|
self.type = type_
|
||
|
|
self.payload = payload or {}
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeCommands:
|
||
|
|
"""Records calls; returns whatever the test queues up."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.get_contacts_calls = []
|
||
|
|
self.removed = []
|
||
|
|
self.added = []
|
||
|
|
self.get_contacts_result = None
|
||
|
|
self.remove_result = _Event("OK")
|
||
|
|
self.add_result = _Event("OK")
|
||
|
|
|
||
|
|
async def get_contacts(self, lastmod=0, timeout=5):
|
||
|
|
self.get_contacts_calls.append(lastmod)
|
||
|
|
return self.get_contacts_result
|
||
|
|
|
||
|
|
async def remove_contact(self, key):
|
||
|
|
self.removed.append(key)
|
||
|
|
return self.remove_result
|
||
|
|
|
||
|
|
async def add_contact(self, contact):
|
||
|
|
self.added.append(contact)
|
||
|
|
return self.add_result
|
||
|
|
|
||
|
|
|
||
|
|
class _FakeMC:
|
||
|
|
def __init__(self, contacts=None):
|
||
|
|
self._contacts = dict(contacts or {})
|
||
|
|
self._contacts_dirty = True
|
||
|
|
self._lastmod = 500
|
||
|
|
self.commands = _FakeCommands()
|
||
|
|
self.auto_update_contacts = False
|
||
|
|
|
||
|
|
@property
|
||
|
|
def contacts(self):
|
||
|
|
return self._contacts
|
||
|
|
|
||
|
|
|
||
|
|
def _transport(**cfg_kwargs):
|
||
|
|
"""Build a transport with a fake MC attached and marked connected."""
|
||
|
|
from meshai.config import ConnectionConfig
|
||
|
|
from meshai.transport.meshcore_transport import MeshCoreTransport
|
||
|
|
|
||
|
|
transport = MeshCoreTransport(ConnectionConfig(**cfg_kwargs))
|
||
|
|
return transport
|
||
|
|
|
||
|
|
|
||
|
|
class TestSelfInfoConnectionReporting:
|
||
|
|
"""self_info() must describe the ACTUAL connection, never a stale config value."""
|
||
|
|
|
||
|
|
def test_serial_does_not_report_config_host(self):
|
||
|
|
"""The bug: a serial connection reporting a leftover meshcore_host.
|
||
|
|
|
||
|
|
meshcore_host/port are never read on the serial path, so surfacing them
|
||
|
|
names a device meshai is not talking to — which is what sends an
|
||
|
|
investigation to the wrong physical radio.
|
||
|
|
"""
|
||
|
|
t = _transport(
|
||
|
|
meshcore_conn_type="serial",
|
||
|
|
meshcore_serial_port="/dev/meshcore-rak",
|
||
|
|
meshcore_baud=115200,
|
||
|
|
# A stale TCP host left in config from a previous companion:
|
||
|
|
meshcore_host="192.168.1.253",
|
||
|
|
meshcore_port=5050,
|
||
|
|
)
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
t._self_info = {"name": "AIDA", "public_key": KEY_A}
|
||
|
|
|
||
|
|
info = t.self_info()
|
||
|
|
|
||
|
|
assert info["conn_type"] == "serial"
|
||
|
|
assert info["serial_port"] == "/dev/meshcore-rak"
|
||
|
|
assert info["baud"] == 115200
|
||
|
|
assert info["target"] == "serial:/dev/meshcore-rak@115200"
|
||
|
|
# The stale host must NOT be surfaced:
|
||
|
|
assert info["host"] is None
|
||
|
|
assert info["port"] is None
|
||
|
|
# Identity still comes from the real device:
|
||
|
|
assert info["name"] == "AIDA"
|
||
|
|
assert info["pubkey"] == KEY_A
|
||
|
|
|
||
|
|
def test_tcp_reports_host_and_port(self):
|
||
|
|
t = _transport(
|
||
|
|
meshcore_conn_type="tcp",
|
||
|
|
meshcore_host="100.64.0.9",
|
||
|
|
meshcore_port=5050,
|
||
|
|
)
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
t._self_info = {"name": "TCPNode", "public_key": KEY_B}
|
||
|
|
|
||
|
|
info = t.self_info()
|
||
|
|
|
||
|
|
assert info["conn_type"] == "tcp"
|
||
|
|
assert info["host"] == "100.64.0.9"
|
||
|
|
assert info["port"] == 5050
|
||
|
|
assert info["target"] == "100.64.0.9:5050"
|
||
|
|
assert info["serial_port"] is None
|
||
|
|
assert info["baud"] is None
|
||
|
|
|
||
|
|
def test_ble_reports_address_only(self):
|
||
|
|
t = _transport(
|
||
|
|
meshcore_conn_type="ble",
|
||
|
|
meshcore_ble_address="AA:BB:CC:DD:EE:FF",
|
||
|
|
meshcore_host="192.168.1.253",
|
||
|
|
)
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
t._self_info = {"name": "BleNode", "public_key": KEY_C}
|
||
|
|
|
||
|
|
info = t.self_info()
|
||
|
|
|
||
|
|
assert info["conn_type"] == "ble"
|
||
|
|
assert info["ble_address"] == "AA:BB:CC:DD:EE:FF"
|
||
|
|
assert info["target"] == "ble:AA:BB:CC:DD:EE:FF"
|
||
|
|
assert info["host"] is None
|
||
|
|
assert info["port"] is None
|
||
|
|
|
||
|
|
def test_not_connected_reports_only_connected_false(self):
|
||
|
|
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
|
||
|
|
|
||
|
|
assert t.self_info() == {"connected": False}
|
||
|
|
|
||
|
|
def test_descriptor_matches_connect_log_target(self):
|
||
|
|
"""connect() and self_info() must never disagree about the target."""
|
||
|
|
t = _transport(
|
||
|
|
meshcore_conn_type="serial",
|
||
|
|
meshcore_serial_port="/dev/meshcore-rak",
|
||
|
|
meshcore_baud=115200,
|
||
|
|
)
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
t._self_info = {}
|
||
|
|
|
||
|
|
assert t.self_info()["target"] == t._connection_descriptor()["target"]
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Part 5: transport — refresh / remove / import against a mocked companion
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestRefreshContactsAsync:
|
||
|
|
def test_full_refetch_uses_lastmod_zero_and_reconciles(self, fake_meshcore):
|
||
|
|
"""The resync must be FULL (lastmod=0), not the lib's incremental fetch.
|
||
|
|
|
||
|
|
An incremental fetch cannot see a contact whose last_advert predates
|
||
|
|
_lastmod, and merging its result could never drop the stale KEY_B.
|
||
|
|
"""
|
||
|
|
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
|
||
|
|
mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")})
|
||
|
|
mc.commands.get_contacts_result = _Event(
|
||
|
|
"CONTACTS", {KEY_A: _contact(KEY_A, "alpha"), KEY_C: _contact(KEY_C, "charlie")}
|
||
|
|
)
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
stats = asyncio.run(t._refresh_contacts_async())
|
||
|
|
|
||
|
|
assert mc.commands.get_contacts_calls == [0] # FULL, not _lastmod
|
||
|
|
assert set(mc._contacts) == {KEY_A, KEY_C} # cache replaced in place
|
||
|
|
assert stats["removed"] == 1 and stats["added"] == 1
|
||
|
|
assert t._contacts_synced_at is not None
|
||
|
|
|
||
|
|
def test_payload_rekeyed_by_public_key(self, fake_meshcore):
|
||
|
|
"""The event payload may be keyed by anything; the cache is by pubkey."""
|
||
|
|
t = _transport()
|
||
|
|
mc = _FakeMC()
|
||
|
|
mc.commands.get_contacts_result = _Event(
|
||
|
|
"CONTACTS", {"some-other-key": _contact(KEY_A, "alpha")}
|
||
|
|
)
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
asyncio.run(t._refresh_contacts_async())
|
||
|
|
|
||
|
|
assert set(mc._contacts) == {KEY_A}
|
||
|
|
|
||
|
|
def test_error_event_leaves_cache_untouched(self, fake_meshcore):
|
||
|
|
"""A failed fetch must never be treated as authoritative — that would
|
||
|
|
delete the entire roster."""
|
||
|
|
t = _transport()
|
||
|
|
mc = _FakeMC({KEY_A: _contact(KEY_A), KEY_B: _contact(KEY_B)})
|
||
|
|
mc.commands.get_contacts_result = _Event("ERROR", {"reason": "timeout"})
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="timeout"):
|
||
|
|
asyncio.run(t._refresh_contacts_async())
|
||
|
|
|
||
|
|
assert set(mc._contacts) == {KEY_A, KEY_B} # intact
|
||
|
|
|
||
|
|
def test_no_response_raises(self, fake_meshcore):
|
||
|
|
t = _transport()
|
||
|
|
mc = _FakeMC({KEY_A: _contact(KEY_A)})
|
||
|
|
mc.commands.get_contacts_result = None
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="no response"):
|
||
|
|
asyncio.run(t._refresh_contacts_async())
|
||
|
|
|
||
|
|
assert set(mc._contacts) == {KEY_A}
|
||
|
|
|
||
|
|
def test_cache_object_identity_preserved(self, fake_meshcore):
|
||
|
|
"""The lib mutates _contacts in place; replacing the dict would orphan it."""
|
||
|
|
t = _transport()
|
||
|
|
mc = _FakeMC({KEY_B: _contact(KEY_B)})
|
||
|
|
original = mc._contacts
|
||
|
|
mc.commands.get_contacts_result = _Event("CONTACTS", {KEY_A: _contact(KEY_A)})
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
asyncio.run(t._refresh_contacts_async())
|
||
|
|
|
||
|
|
assert mc._contacts is original
|
||
|
|
|
||
|
|
|
||
|
|
class TestResync:
|
||
|
|
"""resync() must re-read BOTH halves of the connect-time device view.
|
||
|
|
|
||
|
|
Channels are enumerated once at connect (_enumerate_channels) and never
|
||
|
|
re-read, so a channel provisioned on the radio afterwards stays invisible
|
||
|
|
until the process restarts — the resync is the only path that picks it up.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def _transport_with_loop(self):
|
||
|
|
"""A transport whose _run_coro works (real loop, fake device)."""
|
||
|
|
import threading
|
||
|
|
|
||
|
|
t = _transport(meshcore_conn_type="serial", meshcore_serial_port="/dev/x")
|
||
|
|
mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha"), KEY_B: _contact(KEY_B, "bravo")})
|
||
|
|
mc.commands.get_contacts_result = _Event("CONTACTS", {KEY_A: _contact(KEY_A, "alpha")})
|
||
|
|
t._mc = mc
|
||
|
|
t._connected = True
|
||
|
|
t._loop = asyncio.new_event_loop()
|
||
|
|
threading.Thread(
|
||
|
|
target=lambda: (asyncio.set_event_loop(t._loop), t._loop.run_forever()),
|
||
|
|
daemon=True,
|
||
|
|
).start()
|
||
|
|
for _ in range(50):
|
||
|
|
if t._loop.is_running():
|
||
|
|
break
|
||
|
|
__import__("time").sleep(0.02)
|
||
|
|
return t, mc
|
||
|
|
|
||
|
|
def test_resync_reports_contact_and_channel_deltas(self, fake_meshcore, monkeypatch):
|
||
|
|
t, mc = self._transport_with_loop()
|
||
|
|
try:
|
||
|
|
t._chan_name_to_idx = {"#aida": 1, "#old": 2}
|
||
|
|
|
||
|
|
# Stand in for the companion's channel table on re-enumeration:
|
||
|
|
# #old is gone, #new appeared.
|
||
|
|
def fake_enumerate():
|
||
|
|
t._chan_name_to_idx = {"#aida": 1, "#new": 3}
|
||
|
|
|
||
|
|
monkeypatch.setattr(t, "_enumerate_channels", fake_enumerate)
|
||
|
|
|
||
|
|
result = t.resync()
|
||
|
|
|
||
|
|
assert result["contacts"]["removed"] == 1 # KEY_B dropped
|
||
|
|
assert result["channels"]["added"] == ["#new"]
|
||
|
|
assert result["channels"]["removed"] == ["#old"]
|
||
|
|
assert result["channels"]["before"] == 2
|
||
|
|
assert result["channels"]["after"] == 2
|
||
|
|
finally:
|
||
|
|
t._loop.call_soon_threadsafe(t._loop.stop)
|
||
|
|
|
||
|
|
def test_resync_raises_when_not_connected(self):
|
||
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
||
|
|
_transport().resync()
|
||
|
|
|
||
|
|
|
||
|
|
class TestRemoveContact:
|
||
|
|
def test_rejects_prefix_requiring_full_key(self):
|
||
|
|
"""A prefix could match the wrong node — and a wrong delete is permanent."""
|
||
|
|
t = _transport()
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
with pytest.raises(ValueError, match="full 64-character"):
|
||
|
|
t.remove_contact(KEY_A[:12])
|
||
|
|
|
||
|
|
def test_rejects_non_hex(self):
|
||
|
|
t = _transport()
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
with pytest.raises(ValueError, match="Invalid pubkey hex"):
|
||
|
|
t.remove_contact("z" * 64)
|
||
|
|
|
||
|
|
def test_raises_when_not_connected(self):
|
||
|
|
t = _transport()
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
||
|
|
t.remove_contact(KEY_A)
|
||
|
|
|
||
|
|
|
||
|
|
class TestImportContact:
|
||
|
|
def test_rejects_record_without_full_pubkey(self):
|
||
|
|
t = _transport()
|
||
|
|
t._mc = _FakeMC()
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
with pytest.raises(ValueError, match="full 64-character"):
|
||
|
|
t.import_contact({"pubkey": "abcd", "name": "x"})
|
||
|
|
|
||
|
|
def test_raises_when_not_connected(self):
|
||
|
|
t = _transport()
|
||
|
|
|
||
|
|
with pytest.raises(RuntimeError, match="not connected"):
|
||
|
|
t.import_contact({"pubkey": KEY_A})
|
||
|
|
|
||
|
|
|
||
|
|
class TestExportRoster:
|
||
|
|
def test_export_carries_importable_fields(self):
|
||
|
|
"""An export missing the update_contact field set cannot be re-imported."""
|
||
|
|
t = _transport()
|
||
|
|
t._mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha", out_path_len=2, out_path="abcd")})
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
records = t.export_roster()
|
||
|
|
|
||
|
|
assert len(records) == 1
|
||
|
|
record = records[0]
|
||
|
|
for field in (
|
||
|
|
"name", "pubkey", "type", "flags", "last_advert",
|
||
|
|
"adv_lat", "adv_lon", "out_path", "out_path_len", "out_path_hash_mode",
|
||
|
|
):
|
||
|
|
assert field in record
|
||
|
|
assert record["pubkey"] == KEY_A
|
||
|
|
assert record["name"] == "alpha"
|
||
|
|
assert record["path_established"] is True
|
||
|
|
|
||
|
|
def test_export_marks_flood_only_contact(self):
|
||
|
|
t = _transport()
|
||
|
|
t._mc = _FakeMC({KEY_A: _contact(KEY_A, "alpha", out_path_len=-1)})
|
||
|
|
t._connected = True
|
||
|
|
|
||
|
|
assert t.export_roster()[0]["path_established"] is False
|
||
|
|
|
||
|
|
def test_export_empty_when_not_connected(self):
|
||
|
|
assert _transport().export_roster() == []
|