Files
meshcore-bot/tests/test_message_handler_contact_path.py
T
agessaman 2f0dd667a4 fix(message_handler): release a cancelled add's claim and match last_advert 0 exactly
Codex re-review follow-up. A cancelled add (reconnect cancelling callbacks) kept its claim, so a later NEW_CONTACT for the same advert never added; it now releases and re-raises. A last_advert of 0 is a real advert timestamp, not a missing one. The expiry test now fills the advert cache it is meant to exercise.
2026-10-02 17:33:18 -07:00

375 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for NEW_CONTACT / meshcore contact path wire encoding helpers."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from meshcore import EventType
from modules.message_handler import MessageHandler
from modules.repeater_manager import RepeaterManager, TrackAdvertResult
def _make_config_get():
defaults = {
("Bot", "rf_data_timeout"): "15.0",
("Bot", "message_correlation_timeout"): "10.0",
}
def _get(section, key, **kw):
return defaults.get((section, key), kw.get("fallback", ""))
return _get
@pytest.fixture
def message_handler():
bot = MagicMock()
bot.logger = MagicMock()
bot.config = MagicMock()
bot.config.get = MagicMock(side_effect=_make_config_get())
bot.config.getboolean = MagicMock(return_value=True)
return MessageHandler(bot)
@pytest.fixture
def companion_new_contact_setup():
"""Bot + MessageHandler wired for companion NEW_CONTACT → add_contact (bot auto-manage)."""
bot = MagicMock()
bot.logger = MagicMock()
def _config_get(section, key, **kw):
defaults = {
("Bot", "rf_data_timeout"): "15.0",
("Bot", "message_correlation_timeout"): "10.0",
("Bot", "auto_manage_contacts"): "bot",
}
return defaults.get((section, key), kw.get("fallback", ""))
bot.config = MagicMock()
bot.config.get = MagicMock(side_effect=_config_get)
bot.config.getboolean = MagicMock(return_value=True)
bot.prefix_hex_chars = 8
mh = MessageHandler(bot)
bot.message_handler = mh
rm = MagicMock()
rm.bot = bot
rm.logger = bot.logger
rm._is_repeater_device = MagicMock(return_value=False)
rm.track_contact_advertisement = AsyncMock(
return_value=TrackAdvertResult(ok=True, duplicate_packet=False)
)
rm.check_and_auto_purge = AsyncMock()
rm.get_contact_list_status = AsyncMock(
return_value={"is_near_limit": False, "usage_percentage": 0.0}
)
rm.manage_contact_list = AsyncMock()
rm.db_manager = MagicMock()
rm.db_manager.execute_update = MagicMock()
async def _add_companion(contact_data, contact_name, public_key):
return await RepeaterManager.add_companion_from_contact_data(rm, contact_data, contact_name, public_key)
rm.add_companion_from_contact_data = AsyncMock(side_effect=_add_companion)
bot.repeater_manager = rm
ok = MagicMock()
ok.type = EventType.OK
bot.meshcore = MagicMock()
bot.meshcore.commands = MagicMock()
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
mh._update_mesh_graph_from_advert = MagicMock()
mh._store_observed_path = MagicMock()
return bot, mh
class TestEnsureContactMeshcorePathEncoding:
def test_no_op_when_hash_mode_not_negative_one(self, message_handler):
c = {"out_path_hash_mode": 0, "out_path_len": 4}
message_handler._ensure_contact_meshcore_path_encoding(c)
assert c["out_path_hash_mode"] == 0
assert c["out_path_len"] == 4
def test_no_op_when_flood_sentinel(self, message_handler):
c = {"out_path_hash_mode": -1, "out_path_len": -1}
message_handler._ensure_contact_meshcore_path_encoding(c)
assert c["out_path_hash_mode"] == -1
assert c["out_path_len"] == -1
def test_fixes_inconsistent_flood_hash_with_plain_hop_count(self, message_handler):
c = {
"out_path_hash_mode": -1,
"out_path_len": 4,
"out_bytes_per_hop": 1,
}
message_handler._ensure_contact_meshcore_path_encoding(c)
assert c["out_path_hash_mode"] == 0
assert c["out_path_len"] == 4
def test_fixes_with_multi_byte_path(self, message_handler):
c = {
"out_path_hash_mode": -1,
"out_path_len": 3,
"out_bytes_per_hop": 2,
}
message_handler._ensure_contact_meshcore_path_encoding(c)
assert c["out_path_hash_mode"] == 1
assert c["out_path_len"] == 3
assert (c["out_path_len"] | (c["out_path_hash_mode"] << 6)) == 0x43
def test_fixes_when_hash_mode_is_string_and_out_path_len_missing(self, message_handler):
c = {
"out_path_hash_mode": "-1",
"out_path": "01020304",
"out_bytes_per_hop": 1,
}
message_handler._ensure_contact_meshcore_path_encoding(c)
assert c["out_path_hash_mode"] == 0
assert c["out_path_len"] == 4
assert (c["out_path_len"] | (c["out_path_hash_mode"] << 6)) == 0x04
class TestHandleNewContactAddContact:
"""handle_new_contact + mocked add_contact: path fields match wire encoding (no OverflowError)."""
@staticmethod
def _pack_path_byte(contact: dict) -> int:
"""Same combination as meshcore update_contact after flood check."""
opl = contact["out_path_len"]
hm = contact["out_path_hash_mode"]
if opl == -1 and hm == -1:
return 255
return (opl & 0x3F) | ((hm & 0x03) << 6)
@pytest.mark.asyncio
async def test_add_contact_receives_merged_path_from_rf_flood_event(self, companion_new_contact_setup):
"""Flood NEW_CONTACT (-1/-1) + RF routing with path_len_byte fixes hash_mode for add_contact."""
bot, mh = companion_new_contact_setup
mh._advert_rf = [
{
"routing_info": {
"path_hex": "0102030405060708",
"path_length": 4,
"path_len_byte": 0x04,
"bytes_per_hop": 1,
"path_byte_length": 4,
},
"snr": 13.5,
"timestamp": time.time(),
"public_key": "a95b4becd36e185eae392d48f11825143d8505d9421a15c7d9f99bc51da70f66",
"advert_timestamp": 1,
}
]
event = MagicMock()
event.payload = {
"public_key": "a95b4becd36e185eae392d48f11825143d8505d9421a15c7d9f99bc51da70f66",
"type": 1,
"flags": 0,
"out_path_hash_mode": -1,
"out_path_len": -1,
"out_path": "",
"adv_name": "Test Companion",
"last_advert": 1,
"adv_lat": 0.0,
"adv_lon": 0.0,
"lastmod": 1,
}
await mh.handle_new_contact(event)
add = bot.meshcore.commands.add_contact
add.assert_awaited_once()
passed = add.await_args[0][0]
assert passed["out_path_hash_mode"] == 0
assert passed["out_path_len"] == 4
assert passed["out_path"] == "0102030405060708"
pb = self._pack_path_byte(passed)
assert int(pb).to_bytes(1, "little", signed=False) == b"\x04"
@pytest.mark.asyncio
async def test_add_contact_uses_path_len_byte_for_two_byte_hops(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
path_hex = "414243444546" # 3 hops × 2 bytes = 6 bytes = 12 hex chars
mh._advert_rf = [
{
"routing_info": {
"path_hex": path_hex,
"path_length": 3,
"path_len_byte": 0x43,
"bytes_per_hop": 2,
"path_byte_length": 6,
},
"timestamp": time.time(),
"public_key": "b95b4becd36e185eae392d48f11825143d8505d9421a15c7d9f99bc51da70f66",
"advert_timestamp": 1,
}
]
event = MagicMock()
event.payload = {
"public_key": "b95b4becd36e185eae392d48f11825143d8505d9421a15c7d9f99bc51da70f66",
"type": 1,
"flags": 0,
"out_path_hash_mode": -1,
"out_path_len": -1,
"out_path": "",
"adv_name": "MultiByte",
"last_advert": 1,
"adv_lat": 0.0,
"adv_lon": 0.0,
"lastmod": 1,
}
await mh.handle_new_contact(event)
passed = bot.meshcore.commands.add_contact.await_args[0][0]
assert passed["out_path_hash_mode"] == 1
assert passed["out_path_len"] == 3
assert self._pack_path_byte(passed) == 0x43
int(self._pack_path_byte(passed)).to_bytes(1, "little", signed=False)
def _rf(public_key, path_hex, *, packet_hash, advert_timestamp=1, age=0.0):
return {
"timestamp": time.time() - age,
"public_key": public_key,
"advert_timestamp": advert_timestamp,
"routing_info": {
"path_hex": path_hex,
"path_length": len(path_hex) // 2,
"bytes_per_hop": 1,
"path_byte_length": len(path_hex) // 2,
"packet_hash": packet_hash,
},
"snr": 11.0,
"rssi": -80,
}
def _flood_contact(public_key):
return {
"public_key": public_key,
"type": 1,
"flags": 0,
"out_path_hash_mode": -1,
"out_path_len": -1,
"out_path": "",
"adv_name": "Route Test",
"last_advert": 1,
"adv_lat": 0.0,
"adv_lon": 0.0,
"lastmod": 1,
}
class TestNewContactRouteSource:
"""NEW_CONTACT takes its route only from the new contact's own ADVERT packet."""
PK = "c1" * 32
OTHER = "d2" * 32
async def _added(self, bot, mh):
event = MagicMock()
event.payload = _flood_contact(self.PK)
await mh.handle_new_contact(event)
return bot.meshcore.commands.add_contact.await_args[0][0]
@pytest.mark.asyncio
async def test_an_unrelated_packet_route_is_not_used(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
mh._advert_rf = [_rf(self.OTHER, "a1a2a3", packet_hash="00aa00aa00aa00aa")] # another node's advert
mh.recent_rf_data = [ # a channel message heard just before
{"timestamp": time.time(), "payload_type_int": 5, "routing_info": {"path_hex": "b1b2b3", "path_length": 3}},
]
passed = await self._added(bot, mh)
assert passed["out_path"] == ""
assert passed["out_path_len"] == -1
mh._update_mesh_graph_from_advert.assert_not_called()
mh._store_observed_path.assert_not_called()
@pytest.mark.asyncio
async def test_the_contact_advert_route_is_used_and_not_recorded_again(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
mh._advert_rf = [
_rf(self.OTHER, "a1a2a3", packet_hash="00aa00aa00aa00aa"),
_rf(self.PK, "c1c2", packet_hash="00cc00cc00cc00cc"),
_rf(self.OTHER, "a4a5", packet_hash="00dd00dd00dd00dd"),
]
passed = await self._added(bot, mh)
assert passed["out_path"] == "c1c2"
assert passed["out_path_len"] == 2
mh._update_mesh_graph_from_advert.assert_not_called()
mh._store_observed_path.assert_not_called()
hash_passed = bot.repeater_manager.track_contact_advertisement.await_args.kwargs["packet_hash"]
assert hash_passed == "00cc00cc00cc00cc"
@pytest.mark.asyncio
async def test_the_first_copy_of_the_newest_advert_is_used(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
mh._advert_rf = [
_rf(self.PK, "0f0f", packet_hash="0001000100010001", advert_timestamp=0, age=8.0), # an older advert
_rf(self.PK, "e1e2", packet_hash="0002000200020002", age=2.0), # first copy heard
_rf(self.PK, "e3e4e5", packet_hash="0002000200020002", age=1.0), # a later copy
]
passed = await self._added(bot, mh)
assert passed["out_path"] == "e1e2"
@pytest.mark.asyncio
async def test_an_advert_older_than_the_rf_window_is_not_used(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
mh._advert_rf = [_rf(self.PK, "c1c2", packet_hash="00cc00cc00cc00cc", age=60.0)]
passed = await self._added(bot, mh)
assert passed["out_path"] == ""
@pytest.mark.asyncio
async def test_the_advert_named_by_last_advert_is_used(self, companion_new_contact_setup):
"""A delayed copy of an older advert does not stand in for the one NEW_CONTACT is about."""
bot, mh = companion_new_contact_setup
mh._advert_rf = [
_rf(self.PK, "a1a1", packet_hash="000a000a000a000a", advert_timestamp=1, age=5.0),
_rf(self.PK, "b2b2", packet_hash="000b000b000b000b", advert_timestamp=2, age=3.0),
_rf(self.PK, "a3a3", packet_hash="000a000a000a000a", advert_timestamp=1, age=1.0), # delayed copy
]
event = MagicMock()
event.payload = {**_flood_contact(self.PK), "last_advert": 2}
await mh.handle_new_contact(event)
assert bot.meshcore.commands.add_contact.await_args[0][0]["out_path"] == "b2b2"
@pytest.mark.asyncio
async def test_last_advert_zero_is_matched_exactly(self, companion_new_contact_setup):
bot, mh = companion_new_contact_setup
mh._advert_rf = [
_rf(self.PK, "a0a0", packet_hash="000a000a000a000a", advert_timestamp=0, age=3.0),
_rf(self.PK, "a1a1", packet_hash="000b000b000b000b", advert_timestamp=1, age=1.0),
]
event = MagicMock()
event.payload = {**_flood_contact(self.PK), "last_advert": 0}
await mh.handle_new_contact(event)
assert bot.meshcore.commands.add_contact.await_args[0][0]["out_path"] == "a0a0"
class TestAdvertSeenBeforeProcessing:
@pytest.mark.asyncio
async def test_new_contact_handled_during_advert_processing_finds_the_advert(self, message_handler):
"""The advert is recorded before _process_advertisement_packet yields."""
mh = message_handler
pk = "e7" * 32
payload_hex = pk + (1234).to_bytes(4, "little").hex() + "00" * 70
seen = {}
async def _process(packet_info, signal_info):
seen["entry"] = mh._find_advert_rf_data(pk, 1234)
mh._process_advertisement_packet = _process
routing_info = {"path_hex": "0102", "path_length": 2, "packet_hash": "00ee00ee00ee00ee"}
mh._remember_advert_rf({"payload_hex": payload_hex}, routing_info, "00ee00ee00ee00ee", {"snr": 5.0}, time.time())
await mh._process_advertisement_packet({}, {})
assert seen["entry"]["routing_info"]["path_hex"] == "0102"
assert mh._find_advert_rf_data(pk, 999) is None