meshai/work/tests/test_mesh_send_api.py
malice c04daa6e4d
fix(config): merge partial PUT bodies instead of resetting to defaults (#155)
Saving the "Auto-advert interval" dropdown on the MeshCore Companion page
took BOTH radios offline on 2026-07-17 06:46:52. One click, full outage.

The page PUT a single-key body to /api/config/connection:

    {"meshcore_advert_interval_seconds": 10800}

_dict_to_dataclass() builds kwargs only from the keys present in the body
and lets `cls(**kwargs)` default the rest, so every OMITTED field was reset
to its dataclass default and written to disk:

    type:                 tcp   -> serial            (Meshtastic offline)
    tcp_host:             192.168.1.100 -> <lost>    (LOCAL_FIELDS, see below)
    tcp_port:             4404  -> 4403              (wrong meshmonitor vnode)
    meshcore_host:        192.168.1.253 -> ''        (MeshCore off; blank = off)
    meshcore_conn_type:   serial -> tcp              (wrong transport)
    meshcore_serial_port: /dev/meshcore-rak -> ''    (RAK radio lost)

It was silent twice over. `connection` is restart-required, so the running
process kept the good in-memory config while the file sat gutted, waiting
for any restart to detonate. And save_section() writes the domain file
FIRST and local.yaml SECOND: meshtastic.yaml hit the disk already gutted,
then the local.yaml write (which owns connection.tcp_host via LOCAL_FIELDS)
died on `[Errno 13] Permission denied` -- so tcp_host landed in neither
file, and the 500 that would have named the cause was swallowed by the UI.
The operator saw nothing happen.

This was never one page's bug: PUT /api/config/{section} was destructive on
a partial payload for EVERY section. Other callers only survive because they
happen to spread the full object first.

Fixes, in depth:

* Route (the durable fix): merge the body over the CURRENT live section
  before coercing, so omitted keys keep their live values while present
  keys -- including '' / False / [] -- still apply. The base is the live
  config, the same values GET serves, so a partial PUT now lands exactly
  where a full-object PUT from that same GET would. Full-object callers are
  unaffected. Fixed at the HTTP boundary, not in _dict_to_dataclass():
  absent-key-means-default is CORRECT at config-load time, where a file
  legitimately omits fields it does not override.

* Nested semantics keyed off the dataclass schema, not "is it a dict":
  nested dataclass fields DEEP-MERGE (a partial region_routes must not drop
  sibling cells), while bare dict/list fields REPLACE at the key (cells,
  toggles, destinations, rules are dynamic maps -- deep-merging them would
  resurrect deleted keys and make deletion impossible, the mirror image of
  the bug being fixed).

* Page: send the full connection object like every other caller does.

* Errors are visible: the save handler no longer swallows the exception,
  and updateConfig() surfaces the server's `detail` rather than a bare
  "API error: 500", which is what hid Permission denied from the operator.

* Default advert interval 10800 -> 86400 (24h). 3h is far too frequent a
  default for a public mesh; the UI "(default)" label moves to match.

Tests: tests/test_config_partial_save_merge.py reproduces the outage with
the exact payload, and pins merge semantics across connection AND
notifications, intentional clearing, deep-merge, and map-deletion.

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:28:41 -06:00

613 lines
20 KiB
Python

"""API tests for the dashboard 'send test message' routes.
Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
(MagicMock-based fakes). The connector fakes mimic a CompositeTransport:
``transport_name=None`` and an explicit iterable ``children`` list.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from meshai.dashboard.api.mesh_send_routes import router
def _child(transport_name, connected=True, known=None):
"""Build a fake child transport (meshtastic/meshcore)."""
c = MagicMock()
c.transport_name = transport_name
c.connected = connected
if known is not None:
c.known_channels.return_value = list(known)
# Wire async variants so routes can await them; side_effect preserves the
# sync mock's return_value and call recording for existing assertions.
c.send_advert_async = AsyncMock(side_effect=lambda: c.send_advert())
c.req_telemetry_async = AsyncMock(side_effect=lambda cid: c.req_telemetry(cid))
return c
def _composite(children, send_result=True):
"""Build a fake CompositeTransport connector wrapping *children*.
A bare MagicMock's auto-attrs are truthy and ``children`` is not
iterable, so set both explicitly.
"""
connector = MagicMock()
connector.transport_name = None
connector.children = list(children)
connector.send_message.return_value = send_result
# Wire the async variant — routes now call send_message_async; side_effect
# delegates to the sync mock so existing call_args assertions still pass.
connector.send_message_async = AsyncMock(
side_effect=lambda *a, **kw: connector.send_message(*a, **kw)
)
return connector
def _client(connector):
app = FastAPI()
app.include_router(router, prefix="/api")
app.state.connector = connector
return TestClient(app)
# ============================================================================
# POST /api/mesh/test-send — meshcore
# ============================================================================
def test_meshcore_success():
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
connector = _composite([mc], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
assert "aida" in body["detail"]
connector.send_message.assert_called_once()
_, kwargs = connector.send_message.call_args
assert kwargs["meshcore_channel"] == "aida"
assert kwargs["transport"] == "meshcore"
assert kwargs["destination"] is None
def test_meshcore_unknown_channel():
mc = _child("meshcore", connected=True, known=["aida"])
connector = _composite([mc], send_result=False)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "ghost"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert "not on companion" in body["detail"]
assert "aida" in body["detail"]
def test_meshcore_inactive():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "meshcore not connected"
# ============================================================================
# POST /api/mesh/test-send — meshtastic
# ============================================================================
def test_meshtastic_success():
mt = _child("meshtastic", connected=True)
connector = _composite([mt], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshtastic", "channel": 0})
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
connector.send_message.assert_called_once()
_, kwargs = connector.send_message.call_args
assert kwargs["channel"] == 0
assert kwargs["transport"] == "meshtastic"
# ============================================================================
# Default text
# ============================================================================
def test_default_text_when_omitted():
mc = _child("meshcore", connected=True, known=["aida"])
connector = _composite([mc], send_result=True)
client = _client(connector)
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
assert r.status_code == 200
assert r.json()["sent"] is True
args, kwargs = connector.send_message.call_args
sent_text = kwargs["text"] if "text" in kwargs else args[0]
assert isinstance(sent_text, str)
assert sent_text.startswith("🧪 MeshAI test")
# ============================================================================
# GET /api/meshcore/channels
# ============================================================================
def test_meshcore_channels_active():
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/channels")
assert r.status_code == 200
assert r.json() == {"active": True, "channels": ["aida", "emergency"]}
def test_meshcore_channels_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/channels")
assert r.status_code == 200
assert r.json() == {"active": False, "channels": []}
# ============================================================================
# GET /api/meshcore/contacts
# ============================================================================
_SAMPLE_ROSTER = [
{
"name": "Repeater One",
"pubkey": "aa11deadbeef",
"type": "repeater",
"last_advert": 1000,
"lat": 43.6,
"lon": -116.2,
"out_path_len": 2,
},
{
"name": "Sensor Two",
"pubkey": "bb22cafef00d",
"type": "sensor",
"last_advert": 2000,
"lat": None,
"lon": None,
"out_path_len": -1,
},
]
def test_meshcore_contacts_active():
mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
mc.contacts_synced_at.return_value = 1700000000.0
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {
"active": True,
"contacts": _SAMPLE_ROSTER,
"last_synced_at": 1700000000.0,
}
def test_meshcore_contacts_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
def test_meshcore_contacts_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/contacts")
assert r.status_code == 200
assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
# ============================================================================
# POST /api/meshcore/contacts/refresh — full resync + reconcile
# ============================================================================
_REFRESH_STATS = {
"before": 3, "after": 3, "added": 1, "removed": 1, "updated": 0,
"added_keys": ["cc" * 32], "removed_keys": ["bb" * 32],
}
_CHANNEL_STATS = {"before": 4, "after": 5, "added": ["#new-chan"], "removed": []}
def test_meshcore_refresh_returns_contact_and_channel_stats():
"""The resync re-reads BOTH halves of the device view, and reports each."""
mc = _child("meshcore", connected=True, known=["#aida", "#new-chan"])
mc.resync.return_value = {"contacts": dict(_REFRESH_STATS), "channels": dict(_CHANNEL_STATS)}
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
mc.contacts_synced_at.return_value = 1700000000.0
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 200
body = r.json()
assert body["stats"] == _REFRESH_STATS
assert body["channel_stats"] == _CHANNEL_STATS
assert body["contacts"] == _SAMPLE_ROSTER
assert body["channels"] == ["#aida", "#new-chan"]
assert body["last_synced_at"] == 1700000000.0
mc.resync.assert_called_once()
def test_meshcore_refresh_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 409
mc.resync.assert_not_called()
def test_meshcore_refresh_surfaces_companion_failure():
"""A failed fetch must surface, not be reported as a successful resync."""
mc = _child("meshcore", connected=True)
mc.resync.side_effect = RuntimeError("contact refresh failed: timeout")
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/refresh")
assert r.status_code == 502
assert "timeout" in r.json()["detail"]
# ============================================================================
# DELETE /api/meshcore/contacts/{pubkey}
# ============================================================================
def test_meshcore_delete_contact_removes_and_returns_roster():
mc = _child("meshcore", connected=True)
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
client = _client(_composite([mc]))
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
assert r.status_code == 200
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
mc.remove_contact.assert_called_once_with("aa" * 32)
def test_meshcore_delete_contact_rejects_bad_key():
mc = _child("meshcore", connected=True)
mc.remove_contact.side_effect = ValueError("A full 64-character hex pubkey is required")
client = _client(_composite([mc]))
r = client.delete("/api/meshcore/contacts/aa11")
assert r.status_code == 400
def test_meshcore_delete_contact_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
assert r.status_code == 409
mc.remove_contact.assert_not_called()
# ============================================================================
# GET /api/meshcore/contacts/export
# ============================================================================
def test_meshcore_export_returns_envelope_and_attachment():
mc = _child("meshcore", connected=True)
mc.export_roster.return_value = [{"name": "N", "pubkey": "aa" * 32, "type": 1}]
mc.self_info.return_value = {
"name": "AIDA", "pubkey": "a6" * 32,
"conn_type": "serial", "target": "serial:/dev/meshcore-rak@115200",
}
mc.contacts_synced_at.return_value = 1700000000.0
client = _client(_composite([mc]))
r = client.get("/api/meshcore/contacts/export")
assert r.status_code == 200
assert "attachment" in r.headers["content-disposition"]
body = r.json()
assert body["format"] == "meshai.meshcore.roster"
assert body["count"] == 1
# The roster is only meaningful paired with the device it came from.
assert body["device"]["conn_type"] == "serial"
assert body["device"]["target"] == "serial:/dev/meshcore-rak@115200"
def test_meshcore_export_conflict_when_disconnected():
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
assert client.get("/api/meshcore/contacts/export").status_code == 409
# ============================================================================
# POST /api/meshcore/contacts/import
# ============================================================================
def test_meshcore_import_writes_each_record():
mc = _child("meshcore", connected=True)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/import", json={
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "bb" * 32}],
})
assert r.status_code == 200
assert r.json() == {"active": True, "imported": 2, "failed": 0, "errors": []}
assert mc.import_contact.call_count == 2
def test_meshcore_import_collects_per_record_errors():
"""One bad record must not strand the batch with no report of what landed."""
mc = _child("meshcore", connected=True)
mc.import_contact.side_effect = [None, ValueError("bad pubkey")]
client = _client(_composite([mc]))
r = client.post("/api/meshcore/contacts/import", json={
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "nope"}],
})
body = r.json()
assert body["imported"] == 1
assert body["failed"] == 1
assert body["errors"][0]["pubkey"] == "nope"
def test_meshcore_import_rejects_empty_payload():
mc = _child("meshcore", connected=True)
client = _client(_composite([mc]))
assert client.post("/api/meshcore/contacts/import", json={"contacts": []}).status_code == 400
# ============================================================================
# GET /api/meshcore/route-health
# ============================================================================
def _config_with_cells(cells, mc_enabled=True):
return SimpleNamespace(
notifications=SimpleNamespace(
region_routes=SimpleNamespace(mt_enabled=True, mc_enabled=mc_enabled, cells=cells)
)
)
def _health_client(connector, config):
app = FastAPI()
app.include_router(router, prefix="/api")
app.state.connector = connector
app.state.config = config
return TestClient(app)
def test_route_health_flags_dangling_room_cell():
mc = _child("meshcore", connected=True, known=["#aida"])
mc.get_contacts.return_value = []
config = _config_with_cells({"fire": {"SC Idaho": {"mc": f"room:{'de' * 32}", "enabled": True}}})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["active"] is True
assert len(body["dangling"]) == 1
assert body["dangling"][0]["reason"] == "room_not_found"
assert body["dangling_enabled"] == 1
def test_route_health_clean_when_targets_resolve():
mc = _child("meshcore", connected=True, known=["#aida"])
mc.get_contacts.return_value = [
{"pubkey": "aa" * 32, "name": "Room", "type": 3},
]
config = _config_with_cells({
"weather": {
"SW Idaho": {"mc": "#aida", "enabled": True},
"SC Idaho": {"mc": f"room:{'aa' * 32}", "enabled": True},
}
})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["dangling"] == []
assert body["checked"] == 2
def test_route_health_reports_name_collisions():
mc = _child("meshcore", connected=True, known=[])
mc.get_contacts.return_value = [
{"pubkey": "aa" * 32, "name": "SC ID AIDA Alerts", "type": 3},
{"pubkey": "bb" * 32, "name": "SC ID AIDA Alerts", "type": 3},
]
client = _health_client(_composite([mc]), _config_with_cells({}))
body = client.get("/api/meshcore/route-health").json()
assert len(body["collisions"]) == 1
assert body["collisions"][0]["count"] == 2
def test_route_health_inactive_when_disconnected():
"""A disconnected companion is not evidence that a route is broken."""
mc = _child("meshcore", connected=False)
config = _config_with_cells({"fire": {"SC Idaho": {"mc": "room:dead", "enabled": True}}})
client = _health_client(_composite([mc]), config)
body = client.get("/api/meshcore/route-health").json()
assert body["active"] is False
assert body["dangling"] == []
# ============================================================================
# GET /api/meshcore/self
# ============================================================================
def test_meshcore_self_active():
mc = _child("meshcore", connected=True)
mc.self_info.return_value = {
"name": "AIDA",
"pubkey": "deadbeef1234",
"connected": True,
"host": "100.64.0.9",
"port": 5050,
"channel_count": 2,
}
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
body = r.json()
assert body["connected"] is True
assert body["pubkey"] == "deadbeef1234"
assert body["name"] == "AIDA"
assert body["channel_count"] == 2
def test_meshcore_self_no_meshcore():
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
assert r.json() == {"connected": False}
def test_meshcore_self_disconnected():
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.get("/api/meshcore/self")
assert r.status_code == 200
assert r.json() == {"connected": False}
# ============================================================================
# POST /api/meshcore/advert
# ============================================================================
def test_meshcore_advert_connected_returns_sent_true():
"""POST /api/meshcore/advert → {sent: true} when meshcore is connected."""
mc = _child("meshcore", connected=True)
mc.send_advert.return_value = True
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is True
assert "detail" in body
mc.send_advert.assert_called_once()
def test_meshcore_advert_connected_send_returns_false():
"""POST /api/meshcore/advert → {sent: false} when send_advert() returns False."""
mc = _child("meshcore", connected=True)
mc.send_advert.return_value = False
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
def test_meshcore_advert_not_connected():
"""POST /api/meshcore/advert → {sent: false, detail: 'MeshCore not connected'}."""
mc = _child("meshcore", connected=False)
connector = _composite([mc])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "MeshCore not connected"
def test_meshcore_advert_no_meshcore_child():
"""POST /api/meshcore/advert → {sent: false} when there is no meshcore transport."""
mt = _child("meshtastic", connected=True)
connector = _composite([mt])
client = _client(connector)
r = client.post("/api/meshcore/advert")
assert r.status_code == 200
body = r.json()
assert body["sent"] is False
assert body["detail"] == "MeshCore not connected"
# ============================================================================
# Config round-trip: meshcore_advert_interval_seconds
# ============================================================================
def test_connection_config_advert_interval_default():
"""meshcore_advert_interval_seconds defaults to 86400 (24 h).
Was 10800 (3 h) -- far too frequent a default for a public mesh.
"""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig()
assert cfg.meshcore_advert_interval_seconds == 86400
def test_connection_config_advert_interval_zero():
"""meshcore_advert_interval_seconds = 0 disables periodic advert."""
from meshai.config import ConnectionConfig
cfg = ConnectionConfig(meshcore_advert_interval_seconds=0)
assert cfg.meshcore_advert_interval_seconds == 0
def test_connection_config_advert_interval_round_trips_yaml():
"""meshcore_advert_interval_seconds survives YAML serialize → deserialize."""
from meshai.config import ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass
cfg = ConnectionConfig(meshcore_advert_interval_seconds=7200)
data = _dataclass_to_dict(cfg)
assert data["meshcore_advert_interval_seconds"] == 7200
cfg2 = _dict_to_dataclass(ConnectionConfig, data)
assert cfg2.meshcore_advert_interval_seconds == 7200