mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
ea4c010967
commit
c04daa6e4d
6 changed files with 442 additions and 12 deletions
|
|
@ -297,7 +297,24 @@ export async function updateConfig(
|
|||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`)
|
||||
// Surface the server's `detail` when there is one. A bare
|
||||
// "API error: 500 Internal Server Error" hid the actual cause of the
|
||||
// 2026-07-17 outage from the operator -- the real message was
|
||||
// "[Errno 13] Permission denied: '/data/config/local.yaml'", which would
|
||||
// have named the problem outright.
|
||||
let detail = ''
|
||||
try {
|
||||
const body = await response.json() as { detail?: unknown }
|
||||
if (typeof body?.detail === 'string') detail = body.detail
|
||||
else if (body?.detail != null) detail = JSON.stringify(body.detail)
|
||||
} catch {
|
||||
// non-JSON error body — fall back to the status line
|
||||
}
|
||||
throw new Error(
|
||||
detail
|
||||
? `${detail} (${response.status})`
|
||||
: `API error: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,9 +61,14 @@ export default function MeshCoreCompanion() {
|
|||
|
||||
// Auto-advert control state — interval in hours (0 = disabled)
|
||||
// Loaded from connection config; editable in-page and PUTted back.
|
||||
const [advertIntervalHours, setAdvertIntervalHours] = useState<number>(3)
|
||||
const [advertIntervalHours, setAdvertIntervalHours] = useState<number>(24)
|
||||
const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false)
|
||||
const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false)
|
||||
const [advertIntervalError, setAdvertIntervalError] = useState<string | null>(null)
|
||||
// The FULL connection section as fetched. Saving spreads this so the PUT
|
||||
// carries every field, matching every other updateConfig('connection', ...)
|
||||
// caller. See handleSaveAdvertInterval.
|
||||
const [connConfig, setConnConfig] = useState<Record<string, unknown> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Companion & Channels - MeshAI'
|
||||
|
|
@ -101,6 +106,7 @@ export default function MeshCoreCompanion() {
|
|||
const resp = await fetch('/api/config/connection')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json() as Record<string, unknown>
|
||||
setConnConfig(data)
|
||||
const sec = data['meshcore_advert_interval_seconds']
|
||||
if (typeof sec === 'number') {
|
||||
setAdvertIntervalHours(sec > 0 ? sec / 3600 : 0)
|
||||
|
|
@ -140,17 +146,32 @@ export default function MeshCoreCompanion() {
|
|||
const handleSaveAdvertInterval = useCallback(async () => {
|
||||
setAdvertIntervalSaving(true)
|
||||
setAdvertIntervalSaved(false)
|
||||
setAdvertIntervalError(null)
|
||||
try {
|
||||
const seconds = Math.round(advertIntervalHours * 3600)
|
||||
await updateConfig('connection', { meshcore_advert_interval_seconds: seconds })
|
||||
// Spread the full fetched section, don't PUT a lone key. On 2026-07-17 a
|
||||
// single-key body here reset every OMITTED connection field to its
|
||||
// dataclass default (type -> serial, meshcore_host -> '', ...) and took
|
||||
// both radios offline. The route now merges partial bodies server-side,
|
||||
// but this page still sends the whole object like every other caller:
|
||||
// belt and braces, and it keeps the PUT's meaning explicit.
|
||||
const current = connConfig ?? {}
|
||||
await updateConfig('connection', {
|
||||
...current,
|
||||
meshcore_advert_interval_seconds: seconds,
|
||||
})
|
||||
setConnConfig({ ...current, meshcore_advert_interval_seconds: seconds })
|
||||
setAdvertIntervalSaved(true)
|
||||
setTimeout(() => setAdvertIntervalSaved(false), 2000)
|
||||
} catch {
|
||||
// keep saving=false, let UI show failure implicitly
|
||||
} catch (err) {
|
||||
// A failed save MUST be visible. This handler used to swallow the error
|
||||
// and "let the UI show failure implicitly" -- it showed nothing at all,
|
||||
// so the operator saw a silent no-op while the write had already failed.
|
||||
setAdvertIntervalError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setAdvertIntervalSaving(false)
|
||||
}
|
||||
}, [advertIntervalHours])
|
||||
}, [advertIntervalHours, connConfig])
|
||||
|
||||
const handleCopyKey = useCallback(async (key: string) => {
|
||||
try {
|
||||
|
|
@ -343,10 +364,10 @@ export default function MeshCoreCompanion() {
|
|||
>
|
||||
<option value={0}>Disabled</option>
|
||||
<option value={1}>Every 1 hour</option>
|
||||
<option value={3}>Every 3 hours (default)</option>
|
||||
<option value={3}>Every 3 hours</option>
|
||||
<option value={6}>Every 6 hours</option>
|
||||
<option value={12}>Every 12 hours</option>
|
||||
<option value={24}>Every 24 hours</option>
|
||||
<option value={24}>Every 24 hours (default)</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveAdvertInterval}
|
||||
|
|
@ -356,6 +377,11 @@ export default function MeshCoreCompanion() {
|
|||
{advertIntervalSaving ? 'Saving…' : advertIntervalSaved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{advertIntervalError && (
|
||||
<p className="text-xs text-red-400" role="alert">
|
||||
Save failed — {advertIntervalError}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[#555]">
|
||||
AIDA sends a flood advertisement at this interval so it stays discoverable.
|
||||
Stored in <code className="text-accent/80">connection.meshcore_advert_interval_seconds</code>.
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class ConnectionConfig:
|
|||
meshcore_port: int = 5050 # pyMC companion frame server port
|
||||
meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect
|
||||
meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited)
|
||||
meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled)
|
||||
meshcore_advert_interval_seconds: int = 86400 # periodic self-advert interval, 24h (0 = disabled)
|
||||
# MeshCore connection type: tcp | serial | ble (default tcp for back-compat)
|
||||
meshcore_conn_type: str = "tcp"
|
||||
meshcore_serial_port: str = "" # prefer stable /dev/serial/by-id/... path
|
||||
|
|
|
|||
|
|
@ -105,6 +105,53 @@ async def get_config_section(section: str, request: Request):
|
|||
return section_data
|
||||
|
||||
|
||||
def _is_dataclass_type(tp) -> bool:
|
||||
"""True for a nested dataclass FIELD TYPE (config.py uses real classes, not
|
||||
string annotations, so __dataclass_fields__ is reachable here)."""
|
||||
return hasattr(tp, "__dataclass_fields__")
|
||||
|
||||
|
||||
def _merge_over_current(base: dict, body: dict, cls) -> dict:
|
||||
"""Merge a PARTIAL `body` over the CURRENT section dict `base`.
|
||||
|
||||
Keys ABSENT from `body` keep their current value; keys PRESENT in `body`
|
||||
win -- including falsy ones ('' / False / 0 / []), so intentional clearing
|
||||
still works. Only key presence matters, never the value.
|
||||
|
||||
Nested semantics (deliberate, see the module tests):
|
||||
* dict -> nested DATACLASS field : DEEP-MERGE. Fixed, known schema, so a
|
||||
partial like {"region_routes": {"mc_enabled": true}} must not drop the
|
||||
sibling `cells`/`mt_enabled` fields.
|
||||
* dict -> bare `dict` field : REPLACE-AT-KEY. These are dynamic maps
|
||||
(region_routes.cells, notifications.toggles/destinations,
|
||||
webhook_headers). Deep-merging them would make key DELETION impossible
|
||||
-- a removed cell/toggle would be resurrected from `base`.
|
||||
* list : REPLACE-AT-KEY. Same deletion argument
|
||||
(notifications.rules, mesh_sources, regions).
|
||||
|
||||
Keying the recursion off the dataclass schema -- not off "is it a dict" --
|
||||
is what keeps deletion working for maps while still protecting nested
|
||||
dataclasses from the partial-payload wipe.
|
||||
"""
|
||||
if not isinstance(base, dict) or not isinstance(body, dict):
|
||||
return body
|
||||
|
||||
field_types = {}
|
||||
if cls is not None and _is_dataclass_type(cls):
|
||||
field_types = {f.name: f.type for f in cls.__dataclass_fields__.values()}
|
||||
|
||||
merged = dict(base)
|
||||
for key, value in body.items():
|
||||
field_type = field_types.get(key)
|
||||
if (isinstance(value, dict)
|
||||
and isinstance(base.get(key), dict)
|
||||
and _is_dataclass_type(field_type)):
|
||||
merged[key] = _merge_over_current(base[key], value, field_type)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
@router.put("/config/{section}")
|
||||
async def update_config_section(section: str, request: Request):
|
||||
"""Update a configuration section."""
|
||||
|
|
@ -146,7 +193,36 @@ async def update_config_section(section: str, request: Request):
|
|||
for v in new_value
|
||||
]
|
||||
elif hasattr(field_type, "__dataclass_fields__"):
|
||||
new_value = _dict_to_dataclass(field_type, body)
|
||||
# MERGE the (possibly partial) body over the CURRENT live section
|
||||
# before coercing. _dict_to_dataclass() builds kwargs only from the
|
||||
# keys it is handed and lets `cls(**kwargs)` default the rest, so
|
||||
# coercing a partial body directly resets every omitted field to its
|
||||
# dataclass default. That is what took both radios offline on
|
||||
# 2026-07-17: a one-key PUT of meshcore_advert_interval_seconds
|
||||
# rewrote type/tcp_host/meshcore_host/meshcore_conn_type/
|
||||
# meshcore_serial_port to defaults (see tests/
|
||||
# test_config_partial_save_merge.py).
|
||||
#
|
||||
# The merge base is the LIVE config -- the same values GET
|
||||
# /api/config/{section} serves and the UI edits on top of -- so a
|
||||
# partial PUT now lands exactly where a full-object PUT from that
|
||||
# same GET would have. Full-object callers are unaffected: every key
|
||||
# they send simply wins.
|
||||
current = getattr(request.app.state, "config", None)
|
||||
base = None
|
||||
if current is not None:
|
||||
base = _section_to_plain(getattr(current, section, None))
|
||||
if isinstance(base, dict) and isinstance(body, dict):
|
||||
merged_body = _merge_over_current(base, body, field_type)
|
||||
else:
|
||||
# No live base to merge over (should not happen in prod, where
|
||||
# app.state.config is always set) -- fall back to the historical
|
||||
# coerce-the-body-as-given path rather than inventing a base.
|
||||
logger.warning(
|
||||
"Config PUT %r: no live section to merge over; "
|
||||
"applying body as-is", section)
|
||||
merged_body = body
|
||||
new_value = _dict_to_dataclass(field_type, merged_body)
|
||||
data_to_save = _dataclass_to_dict(new_value)
|
||||
else:
|
||||
new_value = body
|
||||
|
|
|
|||
308
work/tests/test_config_partial_save_merge.py
Normal file
308
work/tests/test_config_partial_save_merge.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
"""Regression tests: PUT /api/config/{section} must MERGE partial payloads.
|
||||
|
||||
The outage (2026-07-17 06:46:52 on CT108)
|
||||
-----------------------------------------
|
||||
Saving the "Auto-advert interval" dropdown on the MeshCore Companion page PUT a
|
||||
single-key body to /api/config/connection:
|
||||
|
||||
{"meshcore_advert_interval_seconds": 10800}
|
||||
|
||||
`_dict_to_dataclass(ConnectionConfig, body)` builds kwargs ONLY from keys present
|
||||
in the body, so `return cls(**kwargs)` gave every ABSENT field its dataclass
|
||||
default. One click rewrote meshtastic.yaml to defaults and took BOTH radios
|
||||
offline:
|
||||
|
||||
type: tcp -> serial (Meshtastic offline)
|
||||
tcp_host: 192.168.1.100 -> <deleted> (LOCAL_FIELDS, write failed)
|
||||
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 went unnoticed because `connection` is restart-required: the running process
|
||||
kept the good in-memory config while the file sat gutted, waiting for any
|
||||
restart.
|
||||
|
||||
This is NOT one page's bug -- the route is destructive on a partial payload for
|
||||
EVERY section. Other callers only survive because they happen to spread the full
|
||||
object first.
|
||||
|
||||
Fix: the route merges the body over the CURRENT section dict before coercing, so
|
||||
omitted keys keep their live values while explicitly-sent keys still apply.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Stub heavy optional deps so config_routes imports without them.
|
||||
for _mod in ("openai", "aiosqlite", "anthropic", "google", "google.genai"):
|
||||
sys.modules.setdefault(_mod, MagicMock())
|
||||
|
||||
from meshai.config import ( # noqa: E402
|
||||
Config,
|
||||
ConnectionConfig,
|
||||
NotificationsConfig,
|
||||
RegionRouteMatrix,
|
||||
)
|
||||
from meshai.dashboard.api.config_routes import router # noqa: E402
|
||||
|
||||
|
||||
# The live CT108 connection values that the outage destroyed.
|
||||
LIVE_CONNECTION = {
|
||||
"type": "tcp",
|
||||
"serial_port": "/dev/ttyUSB0",
|
||||
"tcp_host": "192.168.1.100",
|
||||
"tcp_port": 4404,
|
||||
"mesh_max_chars": 140,
|
||||
"meshcore_host": "192.168.1.253",
|
||||
"meshcore_port": 5050,
|
||||
"meshcore_auto_reconnect": False,
|
||||
"meshcore_advert_interval_seconds": 86400,
|
||||
"meshcore_conn_type": "serial",
|
||||
"meshcore_serial_port": "/dev/meshcore-rak",
|
||||
"meshcore_baud": 115200,
|
||||
"meshtastic_send_pacing_min_seconds": 2.2,
|
||||
"meshtastic_send_pacing_max_seconds": 2.6,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_dir(tmp_path):
|
||||
"""A minimal on-disk multi-file config dir with the live connection values."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"timezone: America/Boise\n"
|
||||
"connection: !include meshtastic.yaml\n"
|
||||
)
|
||||
(tmp_path / "meshtastic.yaml").write_text(
|
||||
yaml.safe_dump({"connection": dict(LIVE_CONNECTION)}, sort_keys=False)
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(config_dir):
|
||||
"""TestClient whose app.state.config carries the LIVE connection values."""
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
config = Config()
|
||||
config.connection = ConnectionConfig(**LIVE_CONNECTION)
|
||||
|
||||
app.state.config = config
|
||||
app.state.config_path = str(config_dir / "config.yaml")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _saved_connection(config_dir) -> dict:
|
||||
"""The EFFECTIVE persisted connection section, across both files it spans.
|
||||
|
||||
save_section() splits the section by LOCAL_FIELDS: `connection.tcp_host` is
|
||||
deliberately relocated to local.yaml as `infrastructure.tcp_host`, so it is
|
||||
absent from meshtastic.yaml by design. Reassemble the operator-visible view
|
||||
so the assertions test the config that actually takes effect.
|
||||
|
||||
That split is also the outage's second act: save_section writes the domain
|
||||
file FIRST and local.yaml SECOND, so the gutted meshtastic.yaml hit the disk
|
||||
and *then* the local.yaml write died on `[Errno 13] Permission denied`,
|
||||
stranding tcp_host in neither file.
|
||||
"""
|
||||
conn = dict(yaml.safe_load((config_dir / "meshtastic.yaml").read_text())["connection"])
|
||||
local_path = config_dir / "local.yaml"
|
||||
if local_path.exists():
|
||||
local = yaml.safe_load(local_path.read_text()) or {}
|
||||
tcp_host = (local.get("infrastructure") or {}).get("tcp_host")
|
||||
if tcp_host is not None:
|
||||
conn["tcp_host"] = tcp_host
|
||||
return conn
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# STAGE 1 -- the reproduction. Fails on unfixed code.
|
||||
# ==========================================================================
|
||||
|
||||
def test_partial_connection_put_does_not_wipe_other_fields(client, config_dir):
|
||||
"""THE OUTAGE. The exact payload that took both radios offline.
|
||||
|
||||
A PUT carrying ONLY meshcore_advert_interval_seconds must change ONLY that
|
||||
field. Every omitted field must keep its live value -- not its dataclass
|
||||
default.
|
||||
"""
|
||||
resp = client.put(
|
||||
"/api/config/connection",
|
||||
json={"meshcore_advert_interval_seconds": 86400},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
saved = _saved_connection(config_dir)
|
||||
|
||||
# The field we actually sent applied.
|
||||
assert saved["meshcore_advert_interval_seconds"] == 86400
|
||||
|
||||
# ...and NOTHING else moved. These are the fields the outage destroyed.
|
||||
assert saved["type"] == "tcp", "Meshtastic transport reset to dataclass default"
|
||||
assert saved["tcp_host"] == "192.168.1.100", "tcp_host lost"
|
||||
assert saved["tcp_port"] == 4404, "tcp_port reset to default vnode"
|
||||
assert saved["meshcore_host"] == "192.168.1.253", "MeshCore host blanked (= radio off)"
|
||||
assert saved["meshcore_conn_type"] == "serial", "MeshCore transport reset"
|
||||
assert saved["meshcore_serial_port"] == "/dev/meshcore-rak", "RAK radio path lost"
|
||||
assert saved["meshcore_auto_reconnect"] is False, "auto_reconnect flipped to default"
|
||||
assert saved["meshtastic_send_pacing_min_seconds"] == 2.2, "pacing tuning lost"
|
||||
assert saved["meshtastic_send_pacing_max_seconds"] == 2.6, "pacing tuning lost"
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# STAGE 4 -- merge semantics: multiple sections, and intentional clearing.
|
||||
# ==========================================================================
|
||||
|
||||
def test_partial_connection_put_applies_the_sent_field(client, config_dir):
|
||||
"""Merge must not make the route a no-op -- a sent field still changes."""
|
||||
resp = client.put(
|
||||
"/api/config/connection",
|
||||
json={"meshcore_advert_interval_seconds": 3600},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert _saved_connection(config_dir)["meshcore_advert_interval_seconds"] == 3600
|
||||
|
||||
|
||||
def test_explicit_empty_string_still_clears(client, config_dir):
|
||||
"""Merge must not prevent INTENTIONAL clearing.
|
||||
|
||||
Blanking meshcore_host is how an operator turns MeshCore off. An explicitly
|
||||
sent empty string must still apply -- only OMITTED keys are preserved.
|
||||
"""
|
||||
resp = client.put(
|
||||
"/api/config/connection",
|
||||
json={"meshcore_host": ""},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
saved = _saved_connection(config_dir)
|
||||
assert saved["meshcore_host"] == "", "explicit '' was swallowed by the merge"
|
||||
# ...while omitted neighbours still survive.
|
||||
assert saved["type"] == "tcp"
|
||||
assert saved["meshcore_serial_port"] == "/dev/meshcore-rak"
|
||||
|
||||
|
||||
def test_explicit_false_still_applies(client, config_dir):
|
||||
"""Falsy-but-present values (False) must apply, not be treated as absent."""
|
||||
client.put("/api/config/connection", json={"meshcore_auto_add_contacts": False})
|
||||
saved = _saved_connection(config_dir)
|
||||
assert saved["meshcore_auto_add_contacts"] is False
|
||||
assert saved["meshcore_host"] == "192.168.1.253"
|
||||
|
||||
|
||||
def test_full_object_put_still_works(client, config_dir):
|
||||
"""Existing callers spread the full object -- merge must not break them."""
|
||||
full = dict(LIVE_CONNECTION)
|
||||
full["tcp_port"] = 4405
|
||||
full["meshcore_host"] = "192.168.1.99"
|
||||
|
||||
resp = client.put("/api/config/connection", json=full)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
saved = _saved_connection(config_dir)
|
||||
assert saved["tcp_port"] == 4405
|
||||
assert saved["meshcore_host"] == "192.168.1.99"
|
||||
assert saved["meshcore_serial_port"] == "/dev/meshcore-rak"
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# The bug was never connection-specific -- PUT /api/config/{section} was
|
||||
# destructive on a partial payload for EVERY section. Prove the fix is general,
|
||||
# and pin the nested merge semantics.
|
||||
# ==========================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def notif_client(tmp_path):
|
||||
"""TestClient with a populated `notifications` section."""
|
||||
(tmp_path / "config.yaml").write_text("timezone: America/Boise\n")
|
||||
(tmp_path / "notifications.yaml").write_text("enabled: true\n")
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
config = Config()
|
||||
config.notifications = NotificationsConfig(
|
||||
enabled=True,
|
||||
cold_start_grace_seconds=90,
|
||||
band_conditions_tz="America/Boise",
|
||||
region_routes=RegionRouteMatrix(
|
||||
mt_enabled=True,
|
||||
mc_enabled=False,
|
||||
cells={"weather": {"sw-id": {"mt": 3}, "sc-id": {"mt": 2}}},
|
||||
),
|
||||
)
|
||||
|
||||
app.state.config = config
|
||||
app.state.config_path = str(tmp_path / "config.yaml")
|
||||
return TestClient(app), tmp_path
|
||||
|
||||
|
||||
def _saved_notifications(config_dir) -> dict:
|
||||
return yaml.safe_load((config_dir / "notifications.yaml").read_text())
|
||||
|
||||
|
||||
def test_partial_notifications_put_keeps_other_fields(notif_client):
|
||||
"""Same wipe, different section: a one-key PUT must not reset the rest."""
|
||||
client, config_dir = notif_client
|
||||
|
||||
resp = client.put("/api/config/notifications", json={"enabled": False})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
saved = _saved_notifications(config_dir)
|
||||
assert saved["enabled"] is False, "the sent key must apply"
|
||||
# Omitted keys keep their LIVE values, not NotificationsConfig defaults.
|
||||
assert saved["cold_start_grace_seconds"] == 90, "reset to default (60)"
|
||||
assert saved["region_routes"]["cells"] != {}, "routing matrix wiped"
|
||||
assert saved["region_routes"]["mt_enabled"] is True, "mt routing silently disabled"
|
||||
|
||||
|
||||
def test_nested_dataclass_partial_deep_merges(notif_client):
|
||||
"""dict -> nested DATACLASS field deep-merges.
|
||||
|
||||
region_routes is a RegionRouteMatrix (fixed schema), so flipping mc_enabled
|
||||
must not drop its sibling cells/mt_enabled.
|
||||
"""
|
||||
client, config_dir = notif_client
|
||||
|
||||
resp = client.put(
|
||||
"/api/config/notifications",
|
||||
json={"region_routes": {"mc_enabled": True}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rr = _saved_notifications(config_dir)["region_routes"]
|
||||
assert rr["mc_enabled"] is True, "the sent key must apply"
|
||||
assert rr["mt_enabled"] is True, "sibling field reset to default"
|
||||
assert rr["cells"] == {"weather": {"sw-id": {"mt": 3}, "sc-id": {"mt": 2}}}, (
|
||||
"cells wiped by a sibling-key save"
|
||||
)
|
||||
|
||||
|
||||
def test_dynamic_map_replaces_so_deletion_works(notif_client):
|
||||
"""dict -> bare `dict` field REPLACES at the key.
|
||||
|
||||
`cells` is a free-form map, so an operator removing a route cell must see it
|
||||
GONE. Deep-merging maps would resurrect the deleted key from the live config
|
||||
and make deletion impossible -- the mirror-image bug of the one being fixed.
|
||||
"""
|
||||
client, config_dir = notif_client
|
||||
|
||||
resp = client.put(
|
||||
"/api/config/notifications",
|
||||
json={"region_routes": {"cells": {"weather": {"sw-id": {"mt": 3}}}}},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
rr = _saved_notifications(config_dir)["region_routes"]
|
||||
assert rr["cells"] == {"weather": {"sw-id": {"mt": 3}}}, (
|
||||
"sc-id was resurrected -- a deleted route cell must stay deleted"
|
||||
)
|
||||
# ...while the enclosing dataclass's other fields still deep-merge.
|
||||
assert rr["mt_enabled"] is True
|
||||
|
|
@ -587,10 +587,13 @@ def test_meshcore_advert_no_meshcore_child():
|
|||
|
||||
|
||||
def test_connection_config_advert_interval_default():
|
||||
"""meshcore_advert_interval_seconds defaults to 10800 (3 h)."""
|
||||
"""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 == 10800
|
||||
assert cfg.meshcore_advert_interval_seconds == 86400
|
||||
|
||||
|
||||
def test_connection_config_advert_interval_zero():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue