Add planespotters.net latest-photo lookup for ADS-B aircraft. - app/live_layers.py: fetch_planespotters_photo() (hex preferred, reg fallback) + _normalize_planespotter_photo(); 24h TTL cache (their ToS cap). - app/main.py: GET /api/aircraft/photo?hex=...|reg=... (422/404/502). - app/static/index.html: .ps-photo block in ADS-B popup; lazy load on popupopen; thumbnail links to photo page + photographer credit. Server-side proxy, not browser fetch(): planespotters 403s any request carrying an Origin header (every browser fetch() sends one). The thumbnail binary is loaded by the browser straight from their CDN, never re-hosted. Tests: 4 unit + 4 API contract (38 pass in the two files).
109 lines
3.8 KiB
Python
109 lines
3.8 KiB
Python
"""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)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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() == []
|
|
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|