feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
"""API contract tests for live overlay endpoints (no DB required)."""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
from live_layers import overlay_catalog
|
|
|
|
|
from main import app
|
|
|
|
|
|
|
|
|
|
BASE = "http://test"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _get(path: str) -> httpx.Response:
|
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
|
|
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
|
|
|
|
return await client.get(path)
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:46:37 -04:00
|
|
|
async def _post(path: str, payload: dict | None) -> httpx.Response:
|
|
|
|
|
transport = httpx.ASGITransport(app=app)
|
|
|
|
|
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
|
|
|
|
|
return await client.post(path, json=payload)
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
def test_map_layers_includes_overlays():
|
|
|
|
|
body = asyncio.run(_get("/api/map/layers")).json()
|
|
|
|
|
assert "layers" in body
|
|
|
|
|
overlays = body["overlays"]
|
|
|
|
|
for key in ("radar_iem", "radar_rainviewer", "gibs_thermal",
|
|
|
|
|
"aircraft", "vessels", "trains", "nws_alerts",
|
|
|
|
|
"wfigs_incidents", "wfigs_perimeters"):
|
|
|
|
|
assert key in overlays
|
|
|
|
|
assert overlay_catalog()["radar_iem"]["tileUrl"].startswith("https://mesonet")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aircraft_requires_bbox():
|
|
|
|
|
resp = asyncio.run(_get("/api/aircraft"))
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vessels_empty_without_ais_key():
|
|
|
|
|
resp = asyncio.run(_get("/api/vessels"))
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert resp.json() == []
|
2026-08-27 21:21:19 -04:00
|
|
|
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
|
2026-08-27 21:46:37 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _desired_boxes():
|
|
|
|
|
import asyncio as _a
|
|
|
|
|
from ais_stream import _take_desired_boxes
|
|
|
|
|
return _a.run(_take_desired_boxes())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vessels_subscribe_sets_viewport_box():
|
|
|
|
|
assert _desired_boxes() is None
|
|
|
|
|
resp = asyncio.run(_post("/api/vessels/subscribe", {"bbox": "-70,40,-60,45"}))
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
body = resp.json()
|
|
|
|
|
assert body["ok"] is True and body["bbox"] == "-70,40,-60,45"
|
|
|
|
|
# AISStream corner order: [[lat, lon], [lat, lon]] (southwest, northeast).
|
|
|
|
|
assert _desired_boxes() == [[[40.0, -70.0], [45.0, -60.0]]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vessels_subscribe_empty_resets():
|
|
|
|
|
assert asyncio.run(_post("/api/vessels/subscribe", {"bbox": "-70,40,-60,45"})).status_code == 200
|
|
|
|
|
resp = asyncio.run(_post("/api/vessels/subscribe", {"bbox": ""}))
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert resp.json()["bbox"] is None
|
|
|
|
|
assert _desired_boxes() is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_vessels_subscribe_rejects_bad_bbox():
|
|
|
|
|
for bad in ("1,2,3", "a,b,c,d", "20,30,10,40", "0,0,0,200"):
|
|
|
|
|
resp = asyncio.run(_post("/api/vessels/subscribe", {"bbox": bad}))
|
|
|
|
|
assert resp.status_code == 422, bad
|
|
|
|
|
# Explicit null bbox is the "reset to env default" path (still 200).
|
|
|
|
|
assert asyncio.run(_post("/api/vessels/subscribe", {"bbox": None})).status_code == 200
|
2026-08-28 22:58:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aircraft_photo_requires_hex_or_reg():
|
|
|
|
|
assert asyncio.run(_get("/api/aircraft/photo")).status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aircraft_photo_rejects_bad_hex():
|
|
|
|
|
# hex must be exactly 6 hex chars
|
|
|
|
|
resp = asyncio.run(_get("/api/aircraft/photo?hex=xyz1234"))
|
|
|
|
|
assert resp.status_code == 422
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aircraft_photo_returns_photo(monkeypatch):
|
|
|
|
|
async def fake(hex_code=None, reg=None):
|
|
|
|
|
return {"id": "1", "src": "https://t.plnspttrs.net/a_280.jpg",
|
|
|
|
|
"link": "https://www.planespotters.net/photo/1/x", "photographer": "A"}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("main.fetch_planespotters_photo", fake)
|
|
|
|
|
resp = asyncio.run(_get("/api/aircraft/photo?hex=e8027e"))
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
body = resp.json()
|
|
|
|
|
assert body["src"].startswith("https://t.plnspttrs.net/")
|
|
|
|
|
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_aircraft_photo_404_when_no_photo(monkeypatch):
|
|
|
|
|
async def fake(hex_code=None, reg=None):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr("main.fetch_planespotters_photo", fake)
|
|
|
|
|
resp = asyncio.run(_get("/api/aircraft/photo?reg=D-ABCD"))
|
|
|
|
|
assert resp.status_code == 404
|