2026-08-28 09:33:19 -04:00
|
|
|
"""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)
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 01:24:36 -04:00
|
|
|
FENCE_ID = "11111111-1111-1111-1111-111111111111"
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-01 01:24:36 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
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)
|