node-26#9, same shape as the ADS-B commit. Adds POST /telemetry/ais (same node-key auth, same org_id-stamped upsert-by-key pattern, this time by mmsi into a new `vessels` collection) and its docInMyOrg() firestore rule. Frontend gets useVessels() (mirrors useAircraft(), longer staleness window since AIS position reports are minutes apart, not seconds) and an opt-in "Vessels" map overlay. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
95 lines
3.8 KiB
Python
95 lines
3.8 KiB
Python
"""
|
|
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()
|
|
|
|
|
|
def test_ais_service_token_without_node_id_is_rejected():
|
|
_override({"service": True})
|
|
resp = client.post("/telemetry/ais", json={"vessels": []})
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_ais_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/ais", json={
|
|
"vessels": [{"mmsi": "123456789", "name": "MV TEST", "lat": 41.0, "lon": -73.9}],
|
|
})
|
|
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 == "vessels"
|
|
assert doc_id == "123456789"
|
|
assert doc["node_id"] == "node-1"
|
|
assert doc["org_id"] == "org-A"
|
|
assert kwargs.get("merge") is True
|
|
|
|
|
|
def test_ais_node_upload_skips_entries_missing_mmsi():
|
|
_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/ais", json={"vessels": [{"mmsi": ""}]})
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {"ok": True, "count": 0}
|
|
mock_set.assert_not_awaited()
|