Wire ADS-B end to end: telemetry ingestion + live map overlay

node-26#9. Adds POST /telemetry/adsb (node-key authed via
require_node_service_or_firebase_token) that upserts one Firestore doc per
icao into a new `aircraft` collection, org_id stamped from the reporting
node the same way upload.py defensively stamps `calls`. firestore.rules
gets a matching docInMyOrg()-gated read rule.

Frontend: useAircraft() mirrors useNodes()'s onSnapshot pattern, filtering
docs older than 2 minutes client-side since nothing prunes a stale aircraft
doc server-side yet. MapView gets an opt-in "Aircraft" overlay (unchecked
by default, like the weather radar layer) rendering a rotated plane glyph
per sighting.

Unverified via typecheck — no Node.js/npm on this authoring box yet (see
CLAUDE.md Testing reality). Server side is pytest-covered (test_telemetry.py).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-09-20 16:08:29 -04:00
co-authored by Claude Sonnet 5
parent 8892e824fc
commit 5537b095df
8 changed files with 264 additions and 1 deletions
+60
View File
@@ -0,0 +1,60 @@
"""
node-26#9 — second-SDR ADS-B telemetry ingestion.
Two things matter here: the endpoint requires node identity (a service/admin
token has no node_id to attribute the sighting to, so it must 400 rather than
silently write an orphan doc), and org_id gets stamped from the node's own
Firestore doc so firestore.rules' docInMyOrg() can gate the frontend's read —
the same defensive-stamp pattern upload.py already uses for `calls`.
"""
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from app.main import app
from app.internal.auth import require_node_service_or_firebase_token
from app.routers import telemetry
client = TestClient(app)
def _override(decoded: dict):
app.dependency_overrides[require_node_service_or_firebase_token] = lambda: decoded
def teardown_function():
app.dependency_overrides.pop(require_node_service_or_firebase_token, None)
def test_service_token_without_node_id_is_rejected():
_override({"service": True})
resp = client.post("/telemetry/adsb", json={"aircraft": []})
assert resp.status_code == 400
def test_node_upload_upserts_and_stamps_org_id():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value={"org_id": "org-A"})), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/adsb", json={
"aircraft": [{"icao": "A1B2C3", "callsign": "UAL123", "lat": 41.1, "lon": -73.8}],
})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 1}
mock_set.assert_awaited_once()
(collection, doc_id, doc), kwargs = mock_set.await_args
assert collection == "aircraft"
assert doc_id == "A1B2C3"
assert doc["node_id"] == "node-1"
assert doc["org_id"] == "org-A"
assert kwargs.get("merge") is True
def test_node_upload_skips_entries_missing_icao():
_override({"node": True, "node_id": "node-1"})
with patch.object(telemetry.fstore, "doc_get_cached", AsyncMock(return_value=None)), \
patch.object(telemetry.fstore, "doc_set", AsyncMock()) as mock_set:
resp = client.post("/telemetry/adsb", json={"aircraft": [{"icao": ""}]})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "count": 0}
mock_set.assert_not_awaited()