mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(meshcore): resolve DM dest to full contact object before send (canonical pattern) (#22)
MeshCore DM replies were passed a bare 6-byte pubkey prefix to send_msg_with_retry. Per every working meshcore project (meshcore_py examples, meshcore-cli, meshcore-bot, meshcore-ha), the destination must be resolved to the FULL contact object (dict w/ 64-hex public_key) after ensure_contacts — otherwise the lib can't upgrade the prefix to the full key, skips reset_path, blind-floods, gets no ACK, and the DM silently never delivers (matches our live symptom: inbound + channel send work, DM reply dies). - Add _resolve_contact(dest): ensure_contacts() then get_contact_by_key_prefix(). - DM branch now passes the resolved contact object to send_msg_with_retry; if the contact can't be resolved, log + return False (no blind-flood). - Subscribe to EventType.ACK + log received ACKs (instrumentation to confirm whether ACKs reach the dispatcher at all). Tests updated (+2); 0 new failures. Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ddd47afa87
commit
e7c8ef507e
3 changed files with 105 additions and 22 deletions
|
|
@ -1,10 +1,11 @@
|
|||
"""Focused tests for the MeshCore DM delivery fix.
|
||||
|
||||
Verifies that send_message(..., destination=...) calls send_msg_with_retry
|
||||
(not the old fire-and-forget send_msg) and correctly maps its return value
|
||||
to True/False:
|
||||
Verifies that send_message(..., destination=...) resolves the destination to
|
||||
the full contact object before calling send_msg_with_retry (never passes a bare
|
||||
prefix string), and correctly maps the return value to True/False:
|
||||
- non-error Event returned → True (ACKed, delivered)
|
||||
- None returned → False (no ACK, delivery not confirmed)
|
||||
- contact not in roster → False (logged warning, send never called)
|
||||
|
||||
The meshcore lib is mocked via sys.modules (same pattern as the existing
|
||||
transport test module). _run_coro is patched to execute the coroutine
|
||||
|
|
@ -18,6 +19,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
# Contact dict returned by the fake roster — 64-hex public_key, adv_name, out_path_len.
|
||||
_CONTACT_DICT = {
|
||||
"public_key": "a" * 64,
|
||||
"adv_name": "K7ZVX Matt",
|
||||
"out_path_len": -1,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal fake meshcore module (guards against import errors if the real lib
|
||||
|
|
@ -35,6 +43,7 @@ def _ensure_fake_meshcore():
|
|||
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
|
||||
DISCONNECTED = "DISCONNECTED"
|
||||
CONNECTED = "CONNECTED"
|
||||
ACK = "ACK"
|
||||
|
||||
mod.EventType = EventType
|
||||
|
||||
|
|
@ -55,7 +64,10 @@ def _ensure_fake_meshcore():
|
|||
pass
|
||||
|
||||
def get_contact_by_key_prefix(self, prefix):
|
||||
return None
|
||||
return _CONTACT_DICT
|
||||
|
||||
async def ensure_contacts(self, follow=False):
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
async def create_tcp(cls, host, port,
|
||||
|
|
@ -97,16 +109,22 @@ def _mc_config():
|
|||
return ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050)
|
||||
|
||||
|
||||
def _transport_with_mc_mock():
|
||||
def _transport_with_mc_mock(contact=_CONTACT_DICT):
|
||||
"""Return a MeshCoreTransport with _mc as a MagicMock (no loop thread).
|
||||
|
||||
_run_coro is patched on the instance to run the coroutine synchronously via
|
||||
asyncio.get_event_loop().run_until_complete(), bypassing the thread bridge.
|
||||
asyncio.new_event_loop().run_until_complete(), bypassing the thread bridge.
|
||||
This keeps tests fast and deterministic.
|
||||
|
||||
The mock exposes:
|
||||
- mc.get_contact_by_key_prefix(prefix) → contact dict (or None when contact=None)
|
||||
- mc.ensure_contacts is an AsyncMock (async, returns True)
|
||||
"""
|
||||
cfg = _mc_config()
|
||||
t = MeshCoreTransport(cfg)
|
||||
mc = MagicMock()
|
||||
mc.get_contact_by_key_prefix.return_value = contact
|
||||
mc.ensure_contacts = AsyncMock(return_value=True)
|
||||
t._mc = mc
|
||||
t._connected = True
|
||||
|
||||
|
|
@ -126,10 +144,10 @@ def _transport_with_mc_mock():
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeshCoreDMDelivery:
|
||||
"""send_message with destination= must use send_msg_with_retry, not send_msg."""
|
||||
"""send_message with destination= must resolve the contact and use send_msg_with_retry."""
|
||||
|
||||
def test_acked_dm_returns_true_and_uses_send_msg_with_retry(self):
|
||||
"""ACK received (non-error Event) → returns True; send_msg_with_retry called."""
|
||||
"""ACK received (non-error Event) → returns True; send_msg_with_retry called with CONTACT OBJECT."""
|
||||
t, mc = _transport_with_mc_mock()
|
||||
|
||||
ok_event = MagicMock()
|
||||
|
|
@ -140,7 +158,8 @@ class TestMeshCoreDMDelivery:
|
|||
result = t.send_message("reply text", destination="aabbccdd1122")
|
||||
|
||||
assert result is True
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbccdd1122", "reply text")
|
||||
# Must pass the CONTACT OBJECT (dict), not the bare prefix string.
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with(_CONTACT_DICT, "reply text")
|
||||
mc.commands.send_msg.assert_not_awaited()
|
||||
|
||||
def test_no_ack_returns_false(self):
|
||||
|
|
@ -152,7 +171,7 @@ class TestMeshCoreDMDelivery:
|
|||
result = t.send_message("reply text", destination="aabbccdd1122")
|
||||
|
||||
assert result is False
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbccdd1122", "reply text")
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with(_CONTACT_DICT, "reply text")
|
||||
|
||||
def test_error_event_returns_false(self):
|
||||
"""Error event returned by send_msg_with_retry → returns False."""
|
||||
|
|
@ -179,3 +198,25 @@ class TestMeshCoreDMDelivery:
|
|||
"not ACKed" in r.getMessage() or "delivery not confirmed" in r.getMessage()
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
def test_unresolved_contact_returns_false_without_calling_send(self):
|
||||
"""When get_contact_by_key_prefix returns None, send_message returns False and never calls send_msg_with_retry."""
|
||||
t, mc = _transport_with_mc_mock(contact=None)
|
||||
|
||||
mc.commands.send_msg_with_retry = AsyncMock()
|
||||
|
||||
result = t.send_message("hello", destination="deadbeef0011")
|
||||
|
||||
assert result is False
|
||||
mc.commands.send_msg_with_retry.assert_not_awaited()
|
||||
|
||||
def test_unresolved_contact_logs_warning(self, caplog):
|
||||
"""Unresolved contact → a warning about 'not in roster' is logged."""
|
||||
import logging
|
||||
t, mc = _transport_with_mc_mock(contact=None)
|
||||
mc.commands.send_msg_with_retry = AsyncMock()
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
t.send_message("hello", destination="deadbeef0011")
|
||||
|
||||
assert any("not in roster" in r.getMessage() for r in caplog.records)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ def _build_fake_meshcore():
|
|||
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
|
||||
DISCONNECTED = "DISCONNECTED"
|
||||
CONNECTED = "CONNECTED"
|
||||
ACK = "ACK"
|
||||
|
||||
mod.EventType = EventType
|
||||
|
||||
|
|
@ -46,6 +47,9 @@ def _build_fake_meshcore():
|
|||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def ensure_contacts(self, follow=False):
|
||||
return True
|
||||
|
||||
def subscribe(self, event_type, callback):
|
||||
pass
|
||||
|
||||
|
|
@ -325,22 +329,32 @@ class TestSendMessageChannel:
|
|||
# 3. send_message — DM (destination provided)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DM_CONTACT = {"public_key": "a" * 64, "adv_name": "TestContact", "out_path_len": -1}
|
||||
|
||||
|
||||
class TestSendMessageDM:
|
||||
def test_dispatches_send_msg_with_retry(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
# Supply a resolved contact so _resolve_contact succeeds.
|
||||
mc.get_contact_by_key_prefix.return_value = _DM_CONTACT
|
||||
mc.ensure_contacts = AsyncMock(return_value=True)
|
||||
ok = MagicMock()
|
||||
ok.is_error.return_value = False
|
||||
mc.commands.send_msg_with_retry = AsyncMock(return_value=ok)
|
||||
result = t.send_message("hi DM", destination="aabbcc")
|
||||
assert result is True
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with("aabbcc", "hi DM")
|
||||
# Must pass the CONTACT OBJECT (not the bare prefix) so the lib can
|
||||
# upgrade to the full 32-byte key and attempt reset_path->flood.
|
||||
mc.commands.send_msg_with_retry.assert_awaited_once_with(_DM_CONTACT, "hi DM")
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_send_msg_with_retry_error_returns_false(self):
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.get_contact_by_key_prefix.return_value = _DM_CONTACT
|
||||
mc.ensure_contacts = AsyncMock(return_value=True)
|
||||
err = MagicMock()
|
||||
err.is_error.return_value = True
|
||||
mc.commands.send_msg_with_retry = AsyncMock(return_value=err)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue