From e504f4de33b7088de1969742ca4dcb72ddcdccf6 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sun, 1 Mar 2026 14:53:18 -0800 Subject: [PATCH] Test and doc improvements --- AGENTS.md | 7 +- app/AGENTS.md | 9 +- frontend/AGENTS.md | 1 + tests/test_ack_tracking_wiring.py | 184 ++++++++++++++++++++++++++++++ tests/test_health_mqtt_status.py | 99 ++++++++++++++++ tests/test_rx_log_data.py | 92 +++++++++++++++ 6 files changed, 390 insertions(+), 2 deletions(-) create mode 100644 tests/test_ack_tracking_wiring.py create mode 100644 tests/test_health_mqtt_status.py create mode 100644 tests/test_rx_log_data.py diff --git a/AGENTS.md b/AGENTS.md index ae25e9e..3da7bbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup │ ├── main.py # App entry, lifespan │ ├── routers/ # API endpoints │ ├── packet_processor.py # Raw packet pipeline, dedup, path handling -│ ├── repository.py # Database CRUD +│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings) │ ├── event_handlers.py # Radio events │ ├── decoder.py # Packet decryption │ ├── websocket.py # Real-time broadcasts @@ -229,9 +229,13 @@ Key test files: - `tests/test_decoder.py` - Channel + direct message decryption, key exchange - `tests/test_keystore.py` - Ephemeral key store - `tests/test_event_handlers.py` - ACK tracking, repeat detection +- `tests/test_packet_pipeline.py` - End-to-end packet processing - `tests/test_api.py` - API endpoints, read state tracking - `tests/test_migrations.py` - Database migration system - `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling) +- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration +- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring +- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field ### Frontend (Vitest) @@ -282,6 +286,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio | | POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read | | POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater | +| POST | `/api/contacts/{public_key}/reset-path` | Reset contact path to flood | | POST | `/api/contacts/{public_key}/trace` | Trace route to contact | | POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater | | POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry | diff --git a/app/AGENTS.md b/app/AGENTS.md index f018c1d..d9ee962 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -20,7 +20,7 @@ app/ ├── database.py # SQLite connection + base schema + migration runner ├── migrations.py # Schema migrations (SQLite user_version) ├── models.py # Pydantic request/response models -├── repository.py # Data access layer +├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings) ├── radio.py # RadioManager + auto-reconnect monitor ├── radio_sync.py # Polling, sync, periodic advertisement loop ├── decoder.py # Packet parsing/decryption @@ -41,6 +41,7 @@ app/ ├── packets.py ├── read_state.py ├── settings.py + ├── repeaters.py ├── statistics.py └── ws.py ``` @@ -139,6 +140,7 @@ app/ - `POST /contacts/{public_key}/remove-from-radio` - `POST /contacts/{public_key}/mark-read` - `POST /contacts/{public_key}/command` +- `POST /contacts/{public_key}/reset-path` - `POST /contacts/{public_key}/trace` - `POST /contacts/{public_key}/repeater/login` - `POST /contacts/{public_key}/repeater/status` @@ -242,14 +244,17 @@ Test suites: ```text tests/ ├── conftest.py # Shared fixtures +├── test_ack_tracking_wiring.py # DM ACK tracking extraction and wiring ├── test_api.py # REST endpoint integration tests ├── test_bot.py # Bot execution and sandboxing +├── test_channels_router.py # Channels router endpoints ├── test_config.py # Configuration validation ├── test_contacts_router.py # Contacts router endpoints ├── test_decoder.py # Packet parsing/decryption ├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent) ├── test_event_handlers.py # ACK tracking, event registration, cleanup ├── test_frontend_static.py # Frontend static file serving +├── test_health_mqtt_status.py # Health endpoint MQTT status field ├── test_key_normalization.py # Public key normalization ├── test_keystore.py # Ephemeral keystore ├── test_message_pagination.py # Cursor-based message pagination @@ -257,12 +262,14 @@ tests/ ├── test_migrations.py # Schema migration system ├── test_mqtt.py # MQTT publisher topic routing and lifecycle ├── test_packet_pipeline.py # End-to-end packet processing +├── test_packets_router.py # Packets router endpoints (decrypt, maintenance) ├── test_radio.py # RadioManager, serial detection ├── test_radio_operation.py # radio_operation() context manager ├── test_radio_router.py # Radio router endpoints ├── test_radio_sync.py # Polling, sync, advertisement ├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints ├── test_repository.py # Data access layer +├── test_rx_log_data.py # on_rx_log_data event handler integration ├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends ├── test_settings_router.py # Settings endpoints, advert validation ├── test_statistics.py # Statistics aggregation diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8bb6e76..b8e3dec 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -87,6 +87,7 @@ frontend/src/ ├── messageCache.test.ts ├── messageParser.test.ts ├── pathUtils.test.ts + ├── prefetch.test.ts ├── radioPresets.test.ts ├── rawPacketIdentity.test.ts ├── repeaterDashboard.test.tsx diff --git a/tests/test_ack_tracking_wiring.py b/tests/test_ack_tracking_wiring.py new file mode 100644 index 0000000..c4e6a5d --- /dev/null +++ b/tests/test_ack_tracking_wiring.py @@ -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 + ) diff --git a/tests/test_health_mqtt_status.py b/tests/test_health_mqtt_status.py new file mode 100644 index 0000000..6cd44f5 --- /dev/null +++ b/tests/test_health_mqtt_status.py @@ -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 diff --git a/tests/test_rx_log_data.py b/tests/test_rx_log_data.py new file mode 100644 index 0000000..3242a0b --- /dev/null +++ b/tests/test_rx_log_data.py @@ -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())