From d3f6d72775a7a019883a53bf69c7e1fa7a044e00 Mon Sep 17 00:00:00 2001 From: Louis King Date: Sun, 19 Jul 2026 23:05:41 +0100 Subject: [PATCH] fix(routes): clearing observers in the edit modal now persists The route edit modal builds its PUT body with a ternary that collapses an empty observer list to null: observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null, Pydantic parses null as None, and the PUT handler's guard if body.observer_public_keys is not None: _sync_observers(session, route, observer_nodes) skips the sync entirely when the field is None. So removing all observers in the modal sent null -> no DB change. Adding observers worked because a non-empty array passed the guard and _sync_observers deleted + recreated. The None-means-skip semantic is correct for true partial updates, so the fix is on the frontend: the edit modal is a full-form PUT, so it must always send the array. When empty, it sends [], which Pydantic parses as [] (not None), the guard passes, and _sync_observers deletes every existing RouteObserver row. Tests: add test_update_clear_observers_with_empty_list next to the existing test_update_observers as a regression guard. It seeds one observer via PUT, then PUTs observer_public_keys: [] and asserts both the response and a fresh GET come back with an empty list. --- .../web/static/js/spa/pages/routes.js | 2 +- tests/test_api/test_routes.py | 36 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 8ba9c89..7c730b6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -809,7 +809,7 @@ export async function render(container, params, router) { enabled: enabledEl.checked, reversible: reversibleEl.checked, node_public_keys: nodePublicKeys, - observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null, + observer_public_keys: observerPublicKeys, }; const clearVal = clearEl.value.trim(); diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 0d1cdfb..072006e 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -995,6 +995,42 @@ class TestUpdateRouteFields: assert len(data["route_observers"]) == 1 assert data["route_observers"][0]["public_key"] == obs.public_key + def test_update_clear_observers_with_empty_list( + self, client_no_auth, api_db_session + ): + """Sending ``observer_public_keys: []`` must clear existing observers. + + Regression guard for a frontend bug where the edit modal sent + ``null`` instead of ``[]`` when the user removed all observers, + causing the backend's ``is not None`` guard to skip the sync. + """ + route = self._make_route(api_db_session) + obs = _make_node(api_db_session, "c" * 64, "Observer") + api_db_session.commit() + + # Seed one observer. + client_no_auth.put( + f"/api/v1/routes/{route.id}", + json={"observer_public_keys": [obs.public_key]}, + headers={"X-User-Roles": "admin"}, + ) + + # Clear with an explicit empty list. + resp = client_no_auth.put( + f"/api/v1/routes/{route.id}", + json={"observer_public_keys": []}, + headers={"X-User-Roles": "admin"}, + ) + assert resp.status_code == 200 + assert resp.json()["route_observers"] == [] + + # Confirm persistence via a fresh GET. + detail = client_no_auth.get( + f"/api/v1/routes/{route.id}", + headers={"X-User-Roles": "admin"}, + ).json() + assert detail["route_observers"] == [] + def test_update_unresolved_path_nodes_400(self, client_no_auth, api_db_session): route = self._make_route(api_db_session) api_db_session.commit()