"""Phase 2 API contracts (no Redis; geofences ≠ /api/alerts).""" from __future__ import annotations import asyncio import httpx from main import app BASE = "http://test" async def _req(method: str, path: str, **kw) -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url=BASE) as client: return await client.request(method, path, **kw) FENCE_ID = "11111111-1111-1111-1111-111111111111" def test_geofence_post_rejects_point(): resp = asyncio.run(_req( "POST", "/api/geofences", json={"name": "nope", "geojson": {"type": "Point", "coordinates": [-79, 35]}}, )) assert resp.status_code == 422 def test_delete_geofence_404_when_missing(monkeypatch): async def missing(_gid: str) -> bool: return False monkeypatch.setattr("geofence.delete_geofence", missing) resp = asyncio.run(_req("DELETE", f"/api/geofences/{FENCE_ID}")) assert resp.status_code == 404 def test_geofence_alerts_passes_filters(monkeypatch): seen = {} async def fake_list(**kwargs): seen.update(kwargs) return [{"id": "a", "geofence_id": FENCE_ID, "source_kind": "ais"}] monkeypatch.setattr("geofence.list_alerts", fake_list) resp = asyncio.run(_req( "GET", "/api/geofence-alerts", params={ "geofence_id": FENCE_ID, "since": "2026-08-28T00:00:00Z", "until": "2026-08-29T00:00:00Z", "source_kind": "ais", "limit": 10, }, )) assert resp.status_code == 200 assert resp.json()[0]["source_kind"] == "ais" assert seen["geofence_id"] == FENCE_ID assert seen["source_kind"] == "ais" assert seen["limit"] == 10 assert seen["since"] is not None assert seen["until"] is not None def test_geofence_alerts_rejects_bad_source_kind(): resp = asyncio.run(_req( "GET", "/api/geofence-alerts", params={"source_kind": "camera"}, )) assert resp.status_code == 422 def test_geofence_at_404_when_missing(monkeypatch): async def no_snap(gid: str, ts): return None monkeypatch.setattr("geofence.snapshot_at", no_snap) resp = asyncio.run(_req( "GET", f"/api/geofences/{FENCE_ID}/at", params={"timestamp": "2026-08-28T12:04:00Z"}, )) assert resp.status_code == 404 def test_geofence_at_empty_lists_when_db_down(monkeypatch): async def empty_snap(gid: str, ts): return { "geofence_id": gid, "timestamp": ts.isoformat(), "aircraft": [], "vessels": [], "fires": [], } monkeypatch.setattr("geofence.snapshot_at", empty_snap) resp = asyncio.run(_req( "GET", f"/api/geofences/{FENCE_ID}/at", params={"timestamp": "2026-08-28T12:04:00Z"}, )) assert resp.status_code == 200 body = resp.json() assert body["geofence_id"] == FENCE_ID assert body["aircraft"] == [] assert body["vessels"] == [] assert body["fires"] == [] assert "timestamp" in body def test_geofence_at_does_not_notify(monkeypatch): called = {"notify": 0} async def empty_snap(gid: str, ts): return { "geofence_id": gid, "timestamp": ts.isoformat(), "aircraft": [], "vessels": [], "fires": [], } async def boom(**_kw): called["notify"] += 1 raise AssertionError("GET /at must not record_and_notify") monkeypatch.setattr("geofence.snapshot_at", empty_snap) monkeypatch.setattr("geofence.record_and_notify", boom) resp = asyncio.run(_req( "GET", f"/api/geofences/{FENCE_ID}/at", params={"timestamp": "2026-08-28T12:04:00Z"}, )) assert resp.status_code == 200 assert called["notify"] == 0 def test_geofences_list_does_not_collide_with_alerts(): resp = asyncio.run(_req("GET", "/api/geofences")) assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_aircraft_timestamp_returns_list_not_live_error(): resp = asyncio.run(_req( "GET", "/api/aircraft", params={"bbox": "-80,35,-78,36", "timestamp": "2026-08-28T12:04:00Z"}, )) assert resp.status_code == 200 assert isinstance(resp.json(), list) def test_tracks_range_has_bounds(): body = asyncio.run(_req("GET", "/api/tracks/range")).json() assert "min" in body and "max" in body def test_fire_aircraft_reload_endpoint(): resp = asyncio.run(_req("GET", "/api/fire-aircraft")) assert resp.status_code == 200 assert isinstance(resp.json(), list)