Test and doc improvements

This commit is contained in:
Jack Kingsman
2026-03-01 14:53:18 -08:00
parent 9c4b049c8d
commit e504f4de33
6 changed files with 390 additions and 2 deletions
+184
View File
@@ -0,0 +1,184 @@
"""Tests for DM ACK tracking wiring in the send_direct_message endpoint.
Verifies that expected_ack from the radio result is correctly extracted,
hex-encoded, and passed to track_pending_ack.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshcore import EventType
from app.models import SendDirectMessageRequest
from app.radio import radio_manager
from app.repository import ContactRepository
from app.routers.messages import send_direct_message
@pytest.fixture(autouse=True)
def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
def _make_mc(name="TestNode"):
mc = MagicMock()
mc.self_info = {"name": name}
mc.commands = MagicMock()
mc.commands.add_contact = AsyncMock(return_value=MagicMock(type=EventType.OK, payload={}))
mc.get_contact_by_key_prefix = MagicMock(return_value=None)
return mc
async def _insert_contact(public_key, name="Alice"):
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"type": 0,
"flags": 0,
"last_path": None,
"last_path_len": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
}
)
class TestDMAckTrackingWiring:
"""Verify that send_direct_message correctly wires ACK tracking."""
@pytest.mark.asyncio
async def test_expected_ack_bytes_tracked_as_hex(self, test_db):
"""expected_ack bytes from radio are hex-encoded and tracked."""
mc = _make_mc()
ack_bytes = b"\xde\xad\xbe\xef"
result = MagicMock()
result.type = EventType.MSG_SENT
result.payload = {
"expected_ack": ack_bytes,
"suggested_timeout": 8000,
}
mc.commands.send_msg = AsyncMock(return_value=result)
pub_key = "aa" * 32
await _insert_contact(pub_key)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
await asyncio.sleep(0)
mock_track.assert_called_once_with(
"deadbeef", # hex-encoded ack bytes
message.id,
8000, # suggested_timeout
)
@pytest.mark.asyncio
async def test_expected_ack_string_tracked_directly(self, test_db):
"""expected_ack already a string is passed without hex conversion."""
mc = _make_mc()
result = MagicMock()
result.type = EventType.MSG_SENT
result.payload = {
"expected_ack": "abcdef01",
"suggested_timeout": 5000,
}
mc.commands.send_msg = AsyncMock(return_value=result)
pub_key = "bb" * 32
await _insert_contact(pub_key)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
await asyncio.sleep(0)
mock_track.assert_called_once_with(
"abcdef01",
message.id,
5000,
)
@pytest.mark.asyncio
async def test_missing_expected_ack_skips_tracking(self, test_db):
"""No ACK tracking when expected_ack is missing from result payload."""
mc = _make_mc()
result = MagicMock()
result.type = EventType.MSG_SENT
result.payload = {} # no expected_ack
mc.commands.send_msg = AsyncMock(return_value=result)
pub_key = "cc" * 32
await _insert_contact(pub_key)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
await send_direct_message(request)
await asyncio.sleep(0)
mock_track.assert_not_called()
@pytest.mark.asyncio
async def test_default_timeout_used_when_missing(self, test_db):
"""Default 10000ms timeout used when suggested_timeout is missing."""
mc = _make_mc()
result = MagicMock()
result.type = EventType.MSG_SENT
result.payload = {
"expected_ack": b"\x01\x02\x03\x04",
# no suggested_timeout
}
mc.commands.send_msg = AsyncMock(return_value=result)
pub_key = "dd" * 32
await _insert_contact(pub_key)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch("app.routers.messages.track_pending_ack") as mock_track,
patch("app.routers.messages.broadcast_event"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
message = await send_direct_message(request)
await asyncio.sleep(0)
mock_track.assert_called_once_with(
"01020304",
message.id,
10000, # default
)
+99
View File
@@ -0,0 +1,99 @@
"""Tests for health endpoint MQTT status field.
Verifies that build_health_data correctly reports MQTT status as
'connected', 'disconnected', or 'disabled' based on publisher state.
"""
from unittest.mock import patch
import pytest
from app.routers.health import build_health_data
class TestHealthMqttStatus:
"""Test MQTT status in build_health_data."""
@pytest.mark.asyncio
async def test_mqtt_disabled_when_not_configured(self, test_db):
"""MQTT status is 'disabled' when broker host is empty."""
from app.mqtt import mqtt_publisher
original_settings = mqtt_publisher._settings
original_connected = mqtt_publisher.connected
try:
from app.models import AppSettings
mqtt_publisher._settings = AppSettings(mqtt_broker_host="")
mqtt_publisher.connected = False
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["mqtt_status"] == "disabled"
finally:
mqtt_publisher._settings = original_settings
mqtt_publisher.connected = original_connected
@pytest.mark.asyncio
async def test_mqtt_connected_when_publisher_connected(self, test_db):
"""MQTT status is 'connected' when publisher is connected."""
from app.mqtt import mqtt_publisher
original_settings = mqtt_publisher._settings
original_connected = mqtt_publisher.connected
try:
from app.models import AppSettings
mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local")
mqtt_publisher.connected = True
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
assert data["mqtt_status"] == "connected"
finally:
mqtt_publisher._settings = original_settings
mqtt_publisher.connected = original_connected
@pytest.mark.asyncio
async def test_mqtt_disconnected_when_configured_but_not_connected(self, test_db):
"""MQTT status is 'disconnected' when configured but not connected."""
from app.mqtt import mqtt_publisher
original_settings = mqtt_publisher._settings
original_connected = mqtt_publisher.connected
try:
from app.models import AppSettings
mqtt_publisher._settings = AppSettings(mqtt_broker_host="broker.local")
mqtt_publisher.connected = False
data = await build_health_data(False, None)
assert data["mqtt_status"] == "disconnected"
finally:
mqtt_publisher._settings = original_settings
mqtt_publisher.connected = original_connected
@pytest.mark.asyncio
async def test_health_status_ok_when_connected(self, test_db):
"""Health status is 'ok' when radio is connected."""
with patch(
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
):
data = await build_health_data(True, "Serial: /dev/ttyUSB0")
assert data["status"] == "ok"
assert data["radio_connected"] is True
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
@pytest.mark.asyncio
async def test_health_status_degraded_when_disconnected(self, test_db):
"""Health status is 'degraded' when radio is disconnected."""
with patch(
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
):
data = await build_health_data(False, None)
assert data["status"] == "degraded"
assert data["radio_connected"] is False
assert data["connection_info"] is None
+92
View File
@@ -0,0 +1,92 @@
"""Tests for on_rx_log_data event handler integration.
Verifies that the primary RF packet entry point correctly extracts hex payload,
SNR, and RSSI from MeshCore events and passes them to process_raw_packet.
"""
from unittest.mock import AsyncMock, patch
import pytest
class TestOnRxLogData:
"""Test the on_rx_log_data event handler."""
@pytest.mark.asyncio
async def test_extracts_hex_and_calls_process_raw_packet(self):
"""Hex payload is converted to bytes and forwarded correctly."""
from app.event_handlers import on_rx_log_data
class MockEvent:
payload = {
"payload": "deadbeef01020304",
"snr": 7.5,
"rssi": -85,
}
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(MockEvent())
mock_process.assert_called_once_with(
raw_bytes=bytes.fromhex("deadbeef01020304"),
snr=7.5,
rssi=-85,
)
@pytest.mark.asyncio
async def test_missing_payload_field_returns_early(self):
"""Event without 'payload' field is silently skipped."""
from app.event_handlers import on_rx_log_data
class MockEvent:
payload = {"snr": 5.0, "rssi": -90} # no 'payload' key
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(MockEvent())
mock_process.assert_not_called()
@pytest.mark.asyncio
async def test_missing_snr_rssi_passes_none(self):
"""Missing SNR and RSSI fields pass None to process_raw_packet."""
from app.event_handlers import on_rx_log_data
class MockEvent:
payload = {"payload": "ff00"}
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(MockEvent())
mock_process.assert_called_once_with(
raw_bytes=bytes.fromhex("ff00"),
snr=None,
rssi=None,
)
@pytest.mark.asyncio
async def test_empty_hex_payload_produces_empty_bytes(self):
"""Empty hex string produces empty bytes (not an error)."""
from app.event_handlers import on_rx_log_data
class MockEvent:
payload = {"payload": ""}
with patch("app.event_handlers.process_raw_packet", new_callable=AsyncMock) as mock_process:
await on_rx_log_data(MockEvent())
mock_process.assert_called_once_with(
raw_bytes=b"",
snr=None,
rssi=None,
)
@pytest.mark.asyncio
async def test_invalid_hex_raises_valueerror(self):
"""Invalid hex payload raises ValueError (not silently swallowed)."""
from app.event_handlers import on_rx_log_data
class MockEvent:
payload = {"payload": "not_valid_hex"}
with pytest.raises(ValueError):
await on_rx_log_data(MockEvent())