mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Test and doc improvements
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
184
tests/test_ack_tracking_wiring.py
Normal file
184
tests/test_ack_tracking_wiring.py
Normal 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
tests/test_health_mqtt_status.py
Normal file
99
tests/test_health_mqtt_status.py
Normal 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
tests/test_rx_log_data.py
Normal file
92
tests/test_rx_log_data.py
Normal 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())
|
||||
Reference in New Issue
Block a user