Files
meshcore-bot/tests/unit/test_neighbors_discovery.py
agessaman a594b72a85 fix(neighbors): update zero-hop neighbor discovery and data handling
This commit enhances the handling of zero-hop neighbors in the dashboard and database. It ensures that the **One-hop neighbours** section accurately reflects radios heard directly (MeshCore hop count 0) rather than originators of relayed adverts. The `observed_paths` table now includes nullable `snr` and `rssi` columns for zero-hop advert rows, allowing for better signal reporting. Additionally, a one-time backfill process copies recent zero-hop ADVERTs from the `packet_stream` to `observed_paths`. Documentation and tests have been updated to reflect these changes.
2026-08-12 18:01:28 -07:00

866 lines
33 KiB
Python

"""Tests for zero-hop neighbor discovery (modules/neighbors_discovery.py).
Ported from the meshcore-packet-capture project's test_neighbors.py, minus the
device-lock tests (the bot serialises radio commands globally in
modules/core.py _SerializedCommands, so the module takes no lock of its own).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import sqlite3
import types
import pytest
from meshcore import EventType
from modules import neighbors_discovery as nb
from modules.db_migrations import MigrationRunner
from modules.enums import AdvertFlags
LOGGER = logging.getLogger("test-neighbors")
SELF_KEY = "ff" * 32
KEY_A = "aa" * 32
KEY_B = "bb" * 32
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class FakeEvent:
def __init__(self, event_type, payload=None):
self.type = event_type
self.payload = payload or {}
class FakeRadio:
"""Delivers DISCOVER_RESPONSE events synchronously from the send call."""
def __init__(self, responses=None, *, send_result="ok", scopes="DEN,APRS",
flood_scope="SEA", reset_result="ok"):
self.responses = responses or []
self.send_result = send_result
self.scopes = scopes
self.reset_result = reset_result
self.handler = None
self.subscribed = 0
self.unsubscribed = 0
self.sent_tag = None
self.regions_calls = []
self.reset_path_calls = []
self.self_info = {"public_key": SELF_KEY}
# A real contact cache; stage 2 must never populate it.
self.contacts = {}
self.commands = types.SimpleNamespace(
send_node_discover_req=self._send,
req_regions_sync=self._regions,
get_default_flood_scope=self._flood,
reset_path=self._reset_path,
)
self._flood_scope = flood_scope
def add_contact(self, pubkey, *, out_path_len=-1):
"""Seed the contact cache the way the bot's contact management would."""
self.contacts[pubkey] = {"public_key": pubkey, "out_path_len": out_path_len,
"out_path": ""}
def get_contact_by_key_prefix(self, prefix):
"""Mirrors MeshCore.get_contact_by_key_prefix (prefix match on the key)."""
for contact in self.contacts.values():
if contact.get("public_key", "").lower().startswith(prefix.lower()):
return contact
return None
async def _reset_path(self, pubkey):
self.reset_path_calls.append(pubkey)
if self.reset_result == "raise":
raise RuntimeError("serial write failed")
if self.reset_result == "error":
# How the library reports a device rejection, and its own response
# timeout: an ERROR event, not an exception.
return FakeEvent(EventType.ERROR, {"reason": "timeout"})
return FakeEvent(EventType.OK, {})
def subscribe(self, event_type, callback):
assert event_type == EventType.DISCOVER_RESPONSE
self.subscribed += 1
self.handler = callback
return "subscription"
def unsubscribe(self, subscription):
self.unsubscribed += 1
async def _send(self, filter_bits, prefix_only=True, tag=None):
self.sent_filter = filter_bits
self.sent_prefix_only = prefix_only
self.sent_tag = tag
if self.send_result == "error":
return FakeEvent(EventType.ERROR, {"reason": "unsupported"})
if self.send_result == "none":
return None
if self.send_result == "hang":
await asyncio.sleep(30)
little_endian = tag.to_bytes(4, "little").hex()
for response in self.responses:
payload = dict(response)
payload.setdefault("tag", little_endian)
await self.handler(FakeEvent(EventType.DISCOVER_RESPONSE, payload))
return FakeEvent(EventType.MSG_SENT, {})
async def _regions(self, pubkey, timeout=0, min_timeout=0):
self.regions_calls.append(pubkey)
if self.scopes == "raise":
raise RuntimeError("device rejected request")
if self.scopes == "hang":
await asyncio.sleep(120)
return self.scopes
async def _flood(self):
return FakeEvent(EventType.SELF_INFO, {"scope_name": self._flood_scope})
def response(pubkey, snr=5.0, node_type=None, **extra):
payload = {
"pubkey": pubkey,
"SNR": snr,
"node_type": AdvertFlags.ADV_TYPE_REPEATER.value if node_type is None else node_type,
}
payload.update(extra)
return payload
def entry(pubkey_char, snr, heard_at, **kwargs):
return nb.NeighborEntry(pubkey=pubkey_char * 64, snr=snr, heard_at=heard_at, **kwargs)
@pytest.fixture
def fast_cfg():
"""Config with the smallest window the module permits (floored at 5s).
Tests that must not wait use monkeypatched sleep instead.
"""
return nb.NeighborsConfig(discover_window=nb.MIN_DISCOVER_WINDOW)
@pytest.fixture
def no_sleep(monkeypatch):
"""Make asyncio.sleep inside the module a no-op so windows are instant."""
async def instant(_seconds):
return None
monkeypatch.setattr(nb.asyncio, "sleep", instant)
# ---------------------------------------------------------------------------
# Interval clamping (firmware band: 12-336h, default 24h)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"given,expected",
[(11, 12), (12, 12), (24, 24), (48, 48), (336, 336), (400, 336), (0, 24), (-5, 24)],
)
def test_clamp_interval_hours(given, expected):
assert nb.clamp_interval_hours(given) == expected
def test_interval_seconds_uses_clamped_value():
assert nb.NeighborsConfig(interval_hours=1).interval_seconds == 12 * 3600
assert nb.NeighborsConfig(interval_hours=9999).interval_seconds == 336 * 3600
def test_config_floors_values_that_would_never_produce_data():
cfg = nb.NeighborsConfig(
discover_window=0, max_neighbors=0, cycle_timeout=1, scope_gap=-3, command_timeout=0
)
assert cfg.discover_window == nb.MIN_DISCOVER_WINDOW
assert cfg.max_neighbors == 1
assert cfg.cycle_timeout == nb.MIN_CYCLE_TIMEOUT
assert cfg.scope_gap == 0.0
# wait_for(timeout=0) raises immediately, which would break every cycle.
assert cfg.command_timeout == nb.MIN_COMMAND_TIMEOUT
def test_scope_request_budget_exceeds_library_msg_sent_wait():
cfg = nb.NeighborsConfig(scope_min_timeout=8.0, command_timeout=20.0)
assert cfg.scope_request_budget > nb.LIBRARY_MSG_SENT_TIMEOUT
def test_cycle_budget_is_smaller_when_scopes_are_disabled():
off = nb.NeighborsConfig(collect_scopes=False)
on = nb.NeighborsConfig(collect_scopes=True)
assert off.cycle_budget < on.cycle_budget
# Must still cover the listen window plus the discover request.
assert off.cycle_budget >= off.discover_window + off.command_timeout
def test_discover_filter_targets_repeaters():
# A bitmask over advert types, so the bit index is the type value.
assert 1 << AdvertFlags.ADV_TYPE_REPEATER.value == nb.DISCOVER_FILTER_REPEATER
assert nb.DISCOVER_FILTER_REPEATER == 0x04
# ---------------------------------------------------------------------------
# Ordering: most recently heard, then stronger SNR, then pubkey
# ---------------------------------------------------------------------------
def test_sort_prefers_most_recently_heard():
older = entry("a", 20.0, 100.0)
newer = entry("b", -5.0, 200.0)
assert nb.sort_entries([older, newer], now=200.0) == [newer, older]
def test_sort_breaks_recency_tie_by_snr():
weak = entry("a", 1.0, 100.0)
strong = entry("b", 9.0, 100.0)
assert nb.sort_entries([weak, strong], now=100.0) == [strong, weak]
def test_sort_breaks_full_tie_by_pubkey_ascending():
first = entry("1", 5.0, 100.0)
second = entry("2", 5.0, 100.0)
assert nb.sort_entries([second, first], now=100.0) == [first, second]
def test_sort_compares_whole_seconds_so_snr_can_break_ties():
"""Recency is compared as the *published* heard_secs_ago.
Sorting on the raw float clock would quantise differently from the field we
publish, so SNR could never break a tie and the output could be non-monotonic
in heard_secs_ago. That order decides which entries survive the size budget.
"""
slightly_newer_weak = entry("a", 1.0, 100.4)
slightly_older_strong = entry("b", 9.0, 100.0)
ordered = nb.sort_entries([slightly_newer_weak, slightly_older_strong], now=100.9)
assert [e.heard_secs_ago(100.9) for e in ordered] == [0, 0]
assert ordered[0] is slightly_older_strong
# ---------------------------------------------------------------------------
# Payload shape and tail-drop
# ---------------------------------------------------------------------------
def test_message_matches_firmware_contract():
item = nb.NeighborEntry(pubkey=KEY_A, snr=9.75, heard_at=100.0,
scopes="DEN,APRS", status=nb.STATUS_RESPONDED)
message, dropped = nb.build_neighbors_message(
"MeshCore-HOWL", "A1B2", "DEN", [item], timestamp="TS", now=142.0
)
assert dropped == 0
# Key order is part of the contract (MQTTPayloadBuilder.cpp).
assert list(message.keys()) == [
"timestamp", "origin", "origin_id", "total_neighbors",
"queried_neighbors", "truncated", "self", "neighbors",
]
assert message["self"] == {"scopes": "DEN"}
assert message["neighbors"] == [{
"pubkey": KEY_A.upper(),
"snr": 9.75,
"heard_secs_ago": 42,
"scopes": "DEN,APRS",
"status": "responded",
}]
def test_message_reports_truncation_from_the_max_cap():
items = [entry("a", 1.0, 100.0)]
message, dropped = nb.build_neighbors_message(
"o", "i", "", items, timestamp="TS", now=100.0, total_neighbors=9
)
assert dropped == 0
assert message["total_neighbors"] == 9
assert message["queried_neighbors"] == 1
assert message["truncated"] is True
def test_message_drops_the_tail_past_the_size_budget():
items = [nb.NeighborEntry(pubkey=f"{i:064x}", snr=float(i), heard_at=100.0)
for i in range(300)]
message, dropped = nb.build_neighbors_message(
"o", "i", "", items, timestamp="TS", now=100.0
)
assert dropped > 0
assert len(message["neighbors"]) == len(items) - dropped
assert len(json.dumps(message)) < nb.NEIGHBORS_JSON_BUDGET
assert message["truncated"] is True
def test_message_publishes_pubkeys_uppercase():
message, _ = nb.build_neighbors_message(
"o", "i", "", [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)],
timestamp="TS", now=0.0,
)
assert message["neighbors"][0]["pubkey"] == KEY_A.upper()
# ---------------------------------------------------------------------------
# Stage 1: discovery
# ---------------------------------------------------------------------------
async def test_discover_collects_and_sorts_responses(fast_cfg, no_sleep):
radio = FakeRadio([response(KEY_A, 3.0), response(KEY_B, 9.0)])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert [e.pubkey for e in entries] == [KEY_B, KEY_A]
assert radio.sent_filter == nb.DISCOVER_FILTER_REPEATER
# Full 32-byte pubkeys are required, so prefix_only must be off.
assert radio.sent_prefix_only is False
async def test_discover_dedupes_keeping_strongest_snr(fast_cfg, no_sleep):
radio = FakeRadio([response(KEY_A, 3.0), response(KEY_A, 9.0), response(KEY_A, 5.0)])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert len(entries) == 1
assert entries[0].snr == 9.0
async def test_discover_accepts_uppercase_pubkeys_as_lowercase(fast_cfg, no_sleep):
radio = FakeRadio([response(KEY_A.upper(), 1.0)])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert entries[0].pubkey == KEY_A
@pytest.mark.parametrize("bad", [
pytest.param({"pubkey": "aa" * 10}, id="short-pubkey"),
pytest.param({"node_type": AdvertFlags.ADV_TYPE_CHAT.value}, id="not-a-repeater"),
pytest.param({"tag": "deadbeef"}, id="wrong-tag"),
])
async def test_discover_rejects_unwanted_responses(fast_cfg, no_sleep, bad):
payload = response(KEY_A, 5.0)
payload.update(bad)
radio = FakeRadio([payload])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert entries == []
async def test_discover_filters_out_self(fast_cfg, no_sleep):
radio = FakeRadio([response(SELF_KEY, 9.0), response(KEY_A, 1.0)])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert [e.pubkey for e in entries] == [KEY_A]
async def test_discover_tag_is_known_before_subscribing(fast_cfg, no_sleep):
"""The tag must exist before the handler goes live.
Otherwise a response arriving between subscribe and send-completion would be
accepted with no tag check, letting a stale or foreign round leak in.
"""
radio = FakeRadio([response(KEY_A, 1.0)])
await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert radio.sent_tag is not None
assert 1 <= radio.sent_tag <= 0xFFFFFFFF
@pytest.mark.parametrize("result", ["error", "none"])
async def test_discover_returns_none_when_send_fails(fast_cfg, no_sleep, result):
radio = FakeRadio([], send_result=result)
assert await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) is None
async def test_discover_bounds_a_stalled_send():
"""A stalled BLE/serial write must not hang the cycle."""
cfg = nb.NeighborsConfig(command_timeout=nb.MIN_COMMAND_TIMEOUT)
radio = FakeRadio([], send_result="hang")
assert await nb.discover_neighbors(radio, cfg, SELF_KEY, LOGGER) is None
async def test_discover_abandons_cycle_when_session_was_reset(fast_cfg, no_sleep):
"""A reconnect tears down subscriptions, so the collector goes deaf.
Without this check the cycle would record "0 neighbours" as though the mesh
were empty.
"""
radio = FakeRadio([response(KEY_A, 1.0)])
result = await nb.discover_neighbors(
radio, fast_cfg, SELF_KEY, LOGGER, still_valid=lambda: False
)
assert result is None
async def test_discover_always_unsubscribes(fast_cfg, no_sleep):
radio = FakeRadio([response(KEY_A, 1.0)])
await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert radio.subscribed == 1
assert radio.unsubscribed == 1
failing = FakeRadio([], send_result="error")
await nb.discover_neighbors(failing, fast_cfg, SELF_KEY, LOGGER)
assert failing.unsubscribed == 1
async def test_discover_ignores_stragglers_after_the_window(fast_cfg, no_sleep):
"""Callbacks are spawned as tasks, so one can land after unsubscribe returns."""
radio = FakeRadio([response(KEY_A, 1.0)])
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
await radio.handler(FakeEvent(EventType.DISCOVER_RESPONSE, response(KEY_B, 9.0)))
assert [e.pubkey for e in entries] == [KEY_A]
async def test_discover_survives_an_unsubscribe_error(fast_cfg, no_sleep):
radio = FakeRadio([response(KEY_A, 1.0)])
radio.unsubscribe = lambda s: (_ for _ in ()).throw(RuntimeError("gone"))
entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER)
assert [e.pubkey for e in entries] == [KEY_A]
# ---------------------------------------------------------------------------
# Stage 2: scopes
# ---------------------------------------------------------------------------
async def test_collect_scopes_disabled_marks_entries_responded():
"""Stage 2 off must not report a live neighbour as unreachable."""
cfg = nb.NeighborsConfig(collect_scopes=False)
radio = FakeRadio()
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_RESPONDED
assert entries[0].scopes == ""
# And it must not spend any airtime.
assert radio.regions_calls == []
async def test_collect_scopes_populates_scopes_when_enabled():
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes="DEN,APRS")
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].scopes == "DEN,APRS"
assert entries[0].status == nb.STATUS_RESPONDED
assert radio.regions_calls == [KEY_A]
async def test_collect_scopes_reports_timeout_when_response_is_none():
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes=None)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_TIMEOUT
async def test_collect_scopes_reports_send_failed_on_exception():
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes="raise")
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_SEND_FAILED
async def test_collect_scopes_bounds_a_stalled_request(monkeypatch):
"""A hung request must be cut off, not left holding the radio."""
# scope_request_budget is floored by the library's 15s MSG_SENT wait, so patch
# that constant instead of waiting it out.
monkeypatch.setattr(nb, "LIBRARY_MSG_SENT_TIMEOUT", 0.01)
cfg = nb.NeighborsConfig(
collect_scopes=True, scope_gap=0, scope_min_timeout=0.01,
command_timeout=nb.MIN_COMMAND_TIMEOUT,
)
cfg.scope_timeout = 0.01
radio = FakeRadio(scopes="hang")
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_SEND_FAILED
async def test_collect_scopes_stops_at_the_cycle_budget(monkeypatch):
"""Unreached neighbours keep the timeout status, matching the firmware."""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0, cycle_timeout=nb.MIN_CYCLE_TIMEOUT)
radio = FakeRadio(scopes="DEN")
entries = [nb.NeighborEntry(pubkey=k, snr=1.0, heard_at=0.0) for k in (KEY_A, KEY_B)]
clock = {"t": 0.0}
monkeypatch.setattr(nb.time, "time", lambda: clock["t"])
real_regions = radio._regions
async def advance_then_answer(pubkey, timeout=0, min_timeout=0):
clock["t"] += 1000.0 # blow the budget after the first request
return await real_regions(pubkey, timeout, min_timeout)
radio.commands.req_regions_sync = advance_then_answer
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_RESPONDED
assert entries[1].status == nb.STATUS_TIMEOUT
assert len(radio.regions_calls) == 1
async def test_collect_scopes_reraises_cancellation():
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio()
async def cancelled(pubkey, timeout=0, min_timeout=0):
raise asyncio.CancelledError
radio.commands.req_regions_sync = cancelled
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
with pytest.raises(asyncio.CancelledError):
await nb.collect_scopes(radio, entries, cfg, LOGGER)
@pytest.fixture
def tiny_budget(monkeypatch):
"""A scope_request_budget small enough to cut off a hung request at once.
scope_request_budget is floored by the library's 15s MSG_SENT wait, so the
constant has to be patched rather than waited out.
"""
monkeypatch.setattr(nb, "LIBRARY_MSG_SENT_TIMEOUT", 0.01)
cfg = nb.NeighborsConfig(
collect_scopes=True, scope_gap=0, scope_min_timeout=0.01,
command_timeout=nb.MIN_COMMAND_TIMEOUT,
)
cfg.scope_timeout = 0.01
return cfg
async def test_timed_out_request_restores_a_pinned_contact(tiny_budget):
"""send_anon_req pins a path-less contact to zero-hop and restores it after
the send, with no try/finally — so our own timeout has to repair it."""
radio = FakeRadio(scopes="hang")
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, tiny_budget, LOGGER)
assert entries[0].status == nb.STATUS_SEND_FAILED
assert radio.reset_path_calls == [KEY_A]
async def test_failed_request_restores_a_pinned_contact():
"""A raising request leaves the same half-applied device state."""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes="raise")
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert radio.reset_path_calls == [KEY_A]
async def test_contact_with_a_real_path_is_never_reset(tiny_budget):
"""The library only rewrites path-less contacts, so resetting one that has a
path would throw away routing we did not touch."""
radio = FakeRadio(scopes="hang")
radio.add_contact(KEY_A, out_path_len=2)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, tiny_budget, LOGGER)
assert radio.reset_path_calls == []
async def test_unknown_contact_needs_no_repair(tiny_budget):
"""An unknown pubkey is asked to reply zero-hop; no contact is mutated."""
radio = FakeRadio(scopes="hang")
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, tiny_budget, LOGGER)
assert radio.reset_path_calls == []
async def test_a_collapsed_send_error_restores_a_pinned_contact():
"""req_regions_sync returns None for a send_anon_req error too.
One of those errors is change_contact_path failing *after* the device applied
the zero-hop path (a lost acknowledgement), which returns before the library's
own reset_path — so None cannot be assumed to mean "the path was restored".
"""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes=None)
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert entries[0].status == nb.STATUS_TIMEOUT
assert radio.reset_path_calls == [KEY_A]
async def test_no_response_needs_no_repair_without_a_known_contact():
"""Nothing was mutated, so nothing may be sent to the device."""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes=None)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert radio.reset_path_calls == []
@pytest.mark.parametrize("reset_result,expected", [
("error", "rejected the flood-path restore"),
("raise", "could not restore the flood path"),
])
async def test_a_failed_restore_is_reported_as_a_failure(caplog, reset_result, expected):
"""reset_path reports a rejection as an ERROR event, not an exception.
Logging success there would hide a contact left pinned to zero-hop.
"""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes=None, reset_result=reset_result)
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
with caplog.at_level(logging.WARNING, logger=LOGGER.name):
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert radio.reset_path_calls == [KEY_A]
assert expected in caplog.text
assert "restored flood path" not in caplog.text
async def test_a_confirmed_restore_is_reported_as_success(caplog):
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes=None)
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
with caplog.at_level(logging.INFO, logger=LOGGER.name):
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert "restored flood path" in caplog.text
async def test_successful_request_leaves_the_restore_to_the_library():
"""The library's own reset_path runs on the happy path; ours must not double up."""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes="DEN")
radio.add_contact(KEY_A, out_path_len=-1)
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert radio.reset_path_calls == []
async def test_cancellation_schedules_the_repair_and_still_propagates():
"""The cycle budget cancels mid-request; the repair must outlive us."""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio()
radio.add_contact(KEY_A, out_path_len=-1)
async def cancelled(pubkey, timeout=0, min_timeout=0):
raise asyncio.CancelledError
radio.commands.req_regions_sync = cancelled
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
with pytest.raises(asyncio.CancelledError):
await nb.collect_scopes(radio, entries, cfg, LOGGER)
# Scheduled as an independent task, so it has not run yet.
assert radio.reset_path_calls == []
await asyncio.sleep(0)
assert radio.reset_path_calls == [KEY_A]
async def test_collect_scopes_never_populates_the_contact_cache():
"""Zero-hop probing depends on the neighbour not being a known contact.
Nothing in this module may add one; the bot's own contact management is a
separate concern and is why scope collection defaults off.
"""
cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0)
radio = FakeRadio(scopes="DEN")
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)]
await nb.collect_scopes(radio, entries, cfg, LOGGER)
assert radio.contacts == {}
async def test_collect_scopes_handles_no_entries():
await nb.collect_scopes(FakeRadio(), [], nb.NeighborsConfig(collect_scopes=True), LOGGER)
# ---------------------------------------------------------------------------
# Self scopes
# ---------------------------------------------------------------------------
async def test_self_scopes_override_wins_even_when_stage_two_is_off():
cfg = nb.NeighborsConfig(collect_scopes=False, self_scopes="OVERRIDE")
assert await nb.fetch_self_scopes(FakeRadio(), cfg, LOGGER) == "OVERRIDE"
async def test_self_scopes_skipped_when_stage_two_is_off():
"""No scope is published for anyone, so spending a command on ours is wrong."""
radio = FakeRadio(flood_scope="SEA")
cfg = nb.NeighborsConfig(collect_scopes=False)
assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == ""
async def test_self_scopes_read_from_device_when_stage_two_is_on():
radio = FakeRadio(flood_scope="SEA")
cfg = nb.NeighborsConfig(collect_scopes=True)
assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == "SEA"
async def test_self_scopes_tolerates_a_build_without_the_command():
radio = FakeRadio()
radio.commands = types.SimpleNamespace()
cfg = nb.NeighborsConfig(collect_scopes=True)
assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == ""
async def test_self_scopes_tolerates_a_missing_device_handle():
cfg = nb.NeighborsConfig(collect_scopes=True)
assert await nb.fetch_self_scopes(types.SimpleNamespace(commands=None), cfg, LOGGER) == ""
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
@pytest.fixture
def db(tmp_path):
"""A real file-based database at the current schema version.
File-based, not :memory:, because each in-memory connection would be a
separate database (matching the project's test_db fixture).
"""
path = tmp_path / "neighbors.db"
conn = sqlite3.connect(path)
MigrationRunner(conn, LOGGER).run()
conn.close()
class Manager:
@contextlib.contextmanager
def connection(self):
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
return Manager()
def _link_rows(db):
with db.connection() as conn:
return [dict(r) for r in conn.execute("SELECT * FROM neighbor_links")]
def test_record_neighbors_writes_history_and_aggregate(db):
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=7.5, heard_at=0.0,
status=nb.STATUS_RESPONDED)]
assert nb.record_neighbors(db, SELF_KEY, entries, LOGGER, observed_at="T1") == 1
rows = _link_rows(db)
assert len(rows) == 1
assert rows[0]["self_public_key"] == SELF_KEY
assert rows[0]["neighbor_public_key"] == KEY_A
assert rows[0]["observation_count"] == 1
assert rows[0]["best_snr"] == 7.5
with db.connection() as conn:
history = [dict(r) for r in conn.execute("SELECT * FROM neighbor_observations")]
assert len(history) == 1
assert history[0]["observed_at"] == "T1"
assert history[0]["status"] == nb.STATUS_RESPONDED
def test_record_neighbors_accumulates_snr_as_sum_and_count(db):
"""Sums, not means, so any later window re-aggregates exactly."""
for snr, stamp in ((5.0, "T1"), (9.0, "T2"), (3.0, "T3")):
nb.record_neighbors(
db, SELF_KEY,
[nb.NeighborEntry(pubkey=KEY_A, snr=snr, heard_at=0.0)],
LOGGER, observed_at=stamp,
)
row = _link_rows(db)[0]
assert row["observation_count"] == 3
assert row["snr_sum"] == pytest.approx(17.0)
assert row["snr_count"] == 3
assert row["snr_sum"] / row["snr_count"] == pytest.approx(17.0 / 3)
assert row["best_snr"] == 9.0
assert row["last_snr"] == 3.0
assert row["first_seen"] == "T1"
assert row["last_seen"] == "T3"
def test_record_neighbors_keeps_previously_learned_scopes(db):
"""A stage-2-disabled cycle must not erase scopes an earlier cycle learned."""
nb.record_neighbors(
db, SELF_KEY,
[nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0, scopes="DEN,APRS")],
LOGGER, observed_at="T1",
)
nb.record_neighbors(
db, SELF_KEY,
[nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0, scopes="")],
LOGGER, observed_at="T2",
)
assert _link_rows(db)[0]["scopes"] == "DEN,APRS"
def test_record_neighbors_stores_keys_lowercase(db):
nb.record_neighbors(
db, SELF_KEY.upper(),
[nb.NeighborEntry(pubkey=KEY_A.upper(), snr=1.0, heard_at=0.0)],
LOGGER,
)
row = _link_rows(db)[0]
assert row["self_public_key"] == SELF_KEY
assert row["neighbor_public_key"] == KEY_A
def test_record_neighbors_noops_without_entries_or_self_key(db):
assert nb.record_neighbors(db, SELF_KEY, [], LOGGER) == 0
assert nb.record_neighbors(
db, "", [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)], LOGGER
) == 0
assert _link_rows(db) == []
def test_record_neighbors_reports_zero_on_database_error(tmp_path):
class Broken:
@contextlib.contextmanager
def connection(self):
raise sqlite3.OperationalError("database is locked")
yield # pragma: no cover
written = nb.record_neighbors(
Broken(), SELF_KEY,
[nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)], LOGGER,
)
assert written == 0
def _zero_hop_rows(db):
with db.connection() as conn:
return [dict(r) for r in conn.execute(
"SELECT * FROM observed_paths WHERE path_length = 0"
)]
def test_record_neighbors_upserts_zero_hop_observed_path(db):
entries = [nb.NeighborEntry(pubkey=KEY_A, snr=7.5, heard_at=0.0,
status=nb.STATUS_RESPONDED)]
assert nb.record_neighbors(db, SELF_KEY, entries, LOGGER) == 1
rows = _zero_hop_rows(db)
assert len(rows) == 1
assert rows[0]["public_key"] == KEY_A
assert rows[0]["path_hex"] == ""
assert rows[0]["path_length"] == 0
assert rows[0]["snr"] == 7.5
assert rows[0]["rssi"] is None
def test_upsert_zero_hop_discover_does_not_clear_rssi(db):
with db.connection() as conn:
nb.upsert_zero_hop_observed_path(
conn.cursor(), KEY_A, snr=4.0, rssi=-80.0, update_rssi=True
)
conn.commit()
nb.upsert_zero_hop_observed_path(
conn.cursor(), KEY_A, snr=9.0, update_rssi=False
)
conn.commit()
rows = _zero_hop_rows(db)
assert len(rows) == 1
assert rows[0]["snr"] == 9.0
assert rows[0]["rssi"] == -80.0
assert rows[0]["observation_count"] == 2
def test_upsert_zero_hop_preserves_signal_when_refresh_has_none(db):
"""A later empty-path advert without RF figures must not NULL the row."""
with db.connection() as conn:
nb.upsert_zero_hop_observed_path(
conn.cursor(), KEY_A, snr=4.0, rssi=-80.0, update_rssi=True
)
conn.commit()
nb.upsert_zero_hop_observed_path(
conn.cursor(), KEY_A, snr=None, rssi=None, update_rssi=True
)
conn.commit()
rows = _zero_hop_rows(db)
assert rows[0]["snr"] == 4.0
assert rows[0]["rssi"] == -80.0
assert rows[0]["observation_count"] == 2