osint-dashboard/tests/test_api_fires.py
Sirius DevOps 627990efde Add NASA FIRMS active-fire ingest + /api/fires; API keys management page
Coherent merge of two coordinated features on the shared working tree:

FIRMS fire heatmap (backend, t_6e404c14):
- app/fire_sources.py: fetch FIRMS VIIRS area CSV (free MAP_KEY) -> NATS events.fire
- fires hypertable (TimescaleDB, 1-day chunks) with natural-key PK
  (latitude, longitude, acq_time, satellite); idempotent ON CONFLICT DO NOTHING
- alembic/versions/002_fires.py; GET /api/fires?bbox=&since= (JSON only)
- POST /api/ingest/fires; ~15 min poll loop (FIRMS_INTERVAL=900) in ingester
- env-driven config (FIRMS_MAP_KEY/DATASET/BBOX/INTERVAL); docs/firms.md covers
  the zero-cost GIBS VIIRS_SNPP_Thermal_Anomalies_375m_All tile alternative
- 18 tests (parser, mapping, idempotency, API contract) verified vs real
  TimescaleDB+PostGIS (localhost/osint-dashboard-pg image)

API keys page (frontend, t_4433cff2):
- app/keystore.py: api_keys table (self-creating), FIRMS/GEMINI/TELEGRAM
  registry with format validation, ****last4 masking, get_api_key()
- GET/POST/DELETE /api/keys (never returns full values); Keys tab in index.html

DB_NULL_POOL env switch in app/database.py enables a NullPool for tests /
short-lived processes that open a fresh event loop per unit.
2026-08-24 15:37:42 -04:00

91 lines
2.6 KiB
Python

"""Integration tests for GET /api/fires (bbox + since filters, JSON contract)."""
import asyncio
import httpx
from conftest import make_fire_msg, requires_db
from ingestor import ingest_fire_row
from main import app
BASE = "http://test"
def _seed(*msgs) -> None:
"""Insert fire rows through the real ingestor path (idempotent)."""
async def run():
for m in msgs:
await ingest_fire_row(m)
asyncio.run(run())
def _get(path: str) -> httpx.Response:
return asyncio.run(_get_async(path))
async def _get_async(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)
@requires_db
def test_api_fires_bbox_filter(clean_fires):
_seed(
make_fire_msg(latitude=39.45678, longitude=-121.12345, satellite="N"),
make_fire_msg(latitude=34.00000, longitude=-118.20000, satellite="N20"),
make_fire_msg(latitude=10.00000, longitude=20.00000, satellite="N"),
)
# bbox covering only the California-ish points
resp = _get("/api/fires?bbox=-125,30,-115,42")
assert resp.status_code == 200
body = resp.json()
assert isinstance(body, list)
assert len(body) == 2
lats = sorted(p["latitude"] for p in body)
assert lats == [34.0, 39.45678]
@requires_db
def test_api_fires_since_filter(clean_fires):
_seed(
make_fire_msg(acq_time="2026-08-24T18:10:00+00:00"),
make_fire_msg(acq_time="2026-08-24T19:10:00+00:00"),
)
resp = _get("/api/fires?since=2026-08-24T18:30:00Z")
assert resp.status_code == 200
body = resp.json()
assert len(body) == 1
assert body[0]["acq_time"].startswith("2026-08-24T19:10")
@requires_db
def test_api_fires_json_contract(clean_fires):
_seed(make_fire_msg())
body = _get("/api/fires").json()
assert len(body) == 1
point = body[0]
# The exact fields the frontend heatmap needs — nothing more, nothing less.
assert set(point.keys()) == {
"latitude", "longitude", "brightness", "confidence", "acq_time",
"satellite", "instrument", "bright_ti5", "frp", "daynight",
}
assert point["latitude"] == 39.45678
assert point["confidence"] == "h"
assert point["satellite"] == "N"
@requires_db
def test_api_fires_invalid_bbox_422(clean_fires):
assert _get("/api/fires?bbox=-125,30").status_code == 422
assert _get("/api/fires?bbox=-125,abc,-115,42").status_code == 422
@requires_db
def test_api_fires_empty(clean_fires):
resp = _get("/api/fires")
assert resp.status_code == 200
assert resp.json() == []