osint-dashboard/tests/test_fire_ingest.py
Sirius DevOps 5ea9a4e879 feat: lean-Pi WS fan-out, geofences, DVR playback, fire/aircraft correlation
Phase 1: in-memory ConnectionManager viewport fan-out, 500ms map debounce,
cachetools TTLCache, background masscan/ffmpeg, compose memory caps.

Phase 2: PostGIS geofences + ST_Intersects alerts, Timescale 1-min CAGGs
and timestamp playback, FIRMS/WFIGS x firefighting ADS-B within 20 miles.

No Redis/Kafka/Celery.
2026-08-28 09:35:18 -04:00

171 lines
5.6 KiB
Python

"""Unit + integration tests for fire message routing and idempotent storage.
The row-mapping logic (_fire_row_from_msg) is pure and tested everywhere; the
DB-backed idempotency tests are `integration` and auto-skip without a live DB.
"""
import asyncio
from datetime import datetime, timezone
from conftest import make_fire_msg, requires_db
from ingestor import _fire_row_from_msg, ingest_fire_row, ingest_event
# ── Row mapping (pure, no DB) ────────────────────────────────────────────
def test_fire_row_from_msg_maps_fields():
row = _fire_row_from_msg(make_fire_msg())
assert row is not None
assert row["latitude"] == 39.45678
assert row["longitude"] == -121.12345
assert row["brightness"] == 341.40
assert row["confidence"] == "h"
assert row["satellite"] == "N"
assert row["acq_time"] == datetime(2026, 8, 24, 18, 10, tzinfo=timezone.utc)
assert row["frp"] == 12.4
assert row["daynight"] == "D"
def test_fire_row_from_msg_rejects_missing_natural_key():
for key in ("latitude", "longitude", "acq_time", "satellite"):
msg = make_fire_msg(**{key: None})
assert _fire_row_from_msg(msg) is None, f"{key}=None should be dropped"
def test_fire_row_from_msg_rejects_bad_timestamp():
msg = make_fire_msg(acq_time="not-a-timestamp")
assert _fire_row_from_msg(msg) is None
def test_ingest_event_routes_fire_away_from_events(monkeypatch):
"""source_type='fire' must hit the fire path, never the generic events insert."""
calls = {"fire": 0, "events": 0}
async def fake_fire_row(msg):
calls["fire"] += 1
return True
async def boom(*a, **k): # the generic events insert must not be reached
calls["events"] += 1
raise AssertionError("fire message leaked into the events insert path")
import ingestor
monkeypatch.setattr(ingestor, "ingest_fire_row", fake_fire_row)
monkeypatch.setattr(ingestor.events_table, "insert", boom)
asyncio.run(ingest_event(make_fire_msg()))
assert calls["fire"] == 1
assert calls["events"] == 0
# ── Idempotent storage against a live database ───────────────────────────
@requires_db
def test_ingest_fire_row_idempotent(clean_fires):
async def run():
# First insert persists.
assert await ingest_fire_row(make_fire_msg()) is True
# Same natural key on a later 15-min poll -> silently ignored.
assert await ingest_fire_row(make_fire_msg(brightness=999.0)) is False
# Same point/time but a different satellite is a distinct detection.
assert await ingest_fire_row(make_fire_msg(satellite="N20")) is True
# Same point but a different acquisition time is a distinct detection.
assert await ingest_fire_row(
make_fire_msg(acq_time="2026-08-24T19:10:00+00:00")
) is True
asyncio.run(run())
@requires_db
def test_ingest_fire_row_drops_malformed(clean_fires):
async def run():
assert await ingest_fire_row(make_fire_msg(latitude=None)) is False
assert await ingest_fire_row(make_fire_msg(acq_time="garbage")) is False
asyncio.run(run())
class _InsertSession:
"""async_session stand-in: fire insert succeeds (rowcount=1)."""
def __init__(self):
self.rowcount = 1
async def execute(self, *a, **k):
return self
async def commit(self):
return None
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def test_ingest_fire_row_geofence_when_cache_empty(monkeypatch):
"""Ingester process has empty geofence cache; still notify on FIRMS insert."""
import geofence
import ingestor
import live_layers
geofence._cache.clear()
live_layers.aircraft_last_known.clear()
notified = []
async def fake_record(**kw):
notified.append(kw)
return 1
async def no_markers(*a, **k):
return []
monkeypatch.setattr(ingestor, "async_session", _InsertSession)
monkeypatch.setattr(geofence, "record_and_notify", fake_record)
monkeypatch.setattr("tracks.recent_markers", no_markers)
assert asyncio.run(ingest_fire_row(make_fire_msg())) is True
assert len(notified) == 1
assert notified[0]["source_kind"] == "firms"
assert notified[0]["lat"] == 39.45678
assert notified[0]["lon"] == -121.12345
def test_ingest_fire_row_correlates_from_hypertable_when_last_known_empty(monkeypatch):
"""FIRMS ingester has no ADS-B last-known; still correlate from aircraft_positions."""
import geofence
import ingestor
import live_layers
geofence._cache.clear()
live_layers.aircraft_last_known.clear()
correlated = []
async def fake_record(**kw):
return 0
async def fake_recent(kind, limit=2000):
assert kind == "aircraft"
return [{
"id": "acf001",
"lat": 39.45,
"lon": -121.12,
"extra": {"type": "AT802"},
}]
async def fake_corr(fires, aircraft):
correlated.append((fires, aircraft))
return aircraft
monkeypatch.setattr(ingestor, "async_session", _InsertSession)
monkeypatch.setattr(geofence, "record_and_notify", fake_record)
monkeypatch.setattr("tracks.recent_markers", fake_recent)
monkeypatch.setattr("fire_aircraft.correlate_and_notify", fake_corr)
assert asyncio.run(ingest_fire_row(make_fire_msg())) is True
assert len(correlated) == 1
assert correlated[0][1][0]["id"] == "acf001"
assert correlated[0][0][0]["lat"] == 39.45678