"""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