meshai/work/tests/test_meshcore_roster.py

714 lines
26 KiB
Python
Raw Normal View History

feat(meshcore): report the true connection + add roster/channel management (#153) self_info() reported host/port straight from config regardless of conn_type, so a serial companion still advertised whatever stale meshcore_host sat in the config — the API named a device meshai was not talking to, which is enough to send an investigation to the wrong radio. Connection details now come from one _connection_descriptor() shared with connect(), so the log line and the API can't drift; only the live conn_type's fields are populated and the rest are null. meshai's device view is otherwise built once at connect and never re-read — contacts via ensure_contacts(), channels via _enumerate_channels(). The lib's contact handler only ever merges (meshcore.py::_update_contacts), so a cached roster can never shrink, and a channel provisioned on the radio stays invisible until the process restarts. There was no refetch path at all. Adds an explicit resync that re-reads BOTH halves: a FULL get_contacts(lastmod=0) reconciled with replace semantics (absent contacts are dropped) plus a channel re-enumeration, each reporting what changed. Also adds a preventive route-health check: every region_routes cell whose MeshCore target cannot be resolved against the live roster/channel table is surfaced, since such a send fails silently. Room targets are matched by pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored prefix is not misreported as dangling. Same-name/different-pubkey roster entries are flagged too — a name alone cannot identify a contact, which is the trap behind a room rebuilt under a new keypair. Backend: - meshcore_roster.py: pure reconcile_contacts / check_route_health / find_name_collisions (no device I/O — unit-testable without a radio) - transport: _connection_descriptor, resync, refresh_contacts, remove_contact, import_contact, export_roster, contacts_synced_at; auto_update_contacts enabled (configurable — it costs one incremental fetch per advert heard, which is real chatter on a dense mesh) - API: POST contacts/refresh, DELETE contacts/{pubkey}, GET contacts/export, POST contacts/import, GET route-health Frontend (existing Contacts & Companion page — no new page or nav entry): - dangling-route + name-collision banners; resync/export/add-contact toolbar with last-synced and the added/removed counts; staleness badges; search, filters and sortable columns; per-contact delete behind a confirm; Companion tab shows the real transport + target. A full pubkey is required to delete or add: the lib resolves by prefix, and a prefix could silently hit the wrong node. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:24:49 -06:00
"""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() == []