""" 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) telemetry._last_position.clear() def _writes_to(mock_set, collection_prefix: str): return [c for c in mock_set.await_args_list if c.args[0].startswith(collection_prefix)] 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} snapshot = [c for c in mock_set.await_args_list if c.args[0] == "aircraft"] assert len(snapshot) == 1 (collection, doc_id, doc), kwargs = snapshot[0] 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() def _post_adsb(aircraft): 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": aircraft}) assert resp.status_code == 200 return mock_set def test_position_writes_trail_point_with_ttl(): _override({"node": True, "node_id": "node-1"}) mock_set = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8, "altitude_ft": 3000}]) trail = _writes_to(mock_set, "aircraft/A1B2C3/positions") assert len(trail) == 1 (_, doc_id, point), _ = trail[0] assert doc_id.isdigit() assert (point["lat"], point["lon"], point["altitude_ft"]) == (41.1, -73.8, 3000) assert point["expire_at"] > telemetry.datetime.now(telemetry.timezone.utc) def test_unchanged_position_is_not_rewritten_to_trail(): _override({"node": True, "node_id": "node-1"}) _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}]) again = _post_adsb([{"icao": "A1B2C3", "lat": 41.1, "lon": -73.8}]) moved = _post_adsb([{"icao": "A1B2C3", "lat": 41.2, "lon": -73.8}]) assert _writes_to(again, "aircraft/A1B2C3/positions") == [] assert len(_writes_to(moved, "aircraft/A1B2C3/positions")) == 1 def test_aircraft_without_position_gets_no_trail_point(): _override({"node": True, "node_id": "node-1"}) mock_set = _post_adsb([{"icao": "A1B2C3", "callsign": "UAL123"}]) assert _writes_to(mock_set, "aircraft/A1B2C3/positions") == [] assert len(_writes_to(mock_set, "aircraft")) == 1