venthrive-api / tests /test_intel.py
GitHub Actions
Sync from GitHub Β· 2026-10-21 11:53 UTC
b7c46f4
Raw History Blame Contribute Delete
15 kB
"""
Unit tests for the intel store (Phase 0 + 1, docs/blueprint/intelligence-system.md):
geometry, gazetteer matching and text resolution, Layer 1 freshness, handoff
receipts, the calendar, resumable weather backfill, category mapping, and the
news β†’ events hook. Everything runs on in-memory SQLite with synthetic data β€”
no network, and no dependency on the gitignored raw downloads.
"""
from datetime import date, datetime, timedelta, timezone
import pytest
from intel import db, handoff
from intel.freshness import record_attempt, report, source_status
from intel.geo import PolygonIndex, centroid, contains, haversine_km
from intel.ingest import calendar as cal
from intel.ingest import weather
from intel.locations import match_boundaries, normalise, resolve
from intel.subsector_map import subsector_for
def square(x0, y0, x1, y1):
return {"type": "Polygon", "coordinates": [[[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]]}
@pytest.fixture
def con():
c = db.connect(":memory:")
db.init(c)
yield c
c.close()
def add_location(con, lid, level, name, parent=None, lat=0.0, lon=0.0, aliases=()):
con.execute("insert into locations (id, level, name, parent_id, lat, lon, source) values (?, ?, ?, ?, ?, ?, 'test')",
(lid, level, name, parent, lat, lon))
for a in (name, *aliases):
con.execute("insert into location_aliases (alias, location_id, origin) values (?, ?, 'test')", (normalise(a), lid))
@pytest.fixture
def gazetteer(con):
add_location(con, "ng", "country", "Nigeria")
add_location(con, "ng.lagos", "state", "Lagos", "ng")
add_location(con, "ng.oyo", "state", "Oyo", "ng")
add_location(con, "ng.bauchi", "state", "Bauchi", "ng")
add_location(con, "ng.lagos.surulere", "lga", "Surulere", "ng.lagos")
add_location(con, "ng.oyo.surulere", "lga", "Surulere", "ng.oyo")
add_location(con, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Ajah", "Lekki"))
add_location(con, "ng.bauchi.bauchi", "lga", "Bauchi", "ng.bauchi")
con.commit()
return con
# ─── geometry ───────────────────────────────────────────────────────────────
def test_centroid_of_square():
assert centroid(square(0, 0, 2, 2)) == pytest.approx((1.0, 1.0))
def test_centroid_respects_holes():
g = {"type": "Polygon", "coordinates": [
[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],
[[2, 0], [4, 0], [4, 4], [2, 4], [2, 0]], # right half removed
]}
assert centroid(g)[0] == pytest.approx(1.0)
def test_contains_and_index():
idx = PolygonIndex([("a", square(0, 0, 1, 1)), ("b", square(1, 0, 2, 1))])
assert idx.lookup(0.5, 0.5) == "a"
assert idx.lookup(1.5, 0.5) == "b"
assert idx.lookup(5, 5) is None
assert not contains(square(0, 0, 1, 1), 1.5, 0.5)
def test_haversine_one_degree_at_equator():
assert haversine_km(0, 0, 1, 0) == pytest.approx(111.2, abs=0.2)
# ─── gazetteer ──────────────────────────────────────────────────────────────
def _feature(name, geom):
return {"properties": {"shapeName": name}, "geometry": geom}
def test_match_boundaries_exact_fuzzy_and_state_placement():
states = [{"name": "Lagos", "lgas": ["Ifako-Ijaiye", "Surulere"]}, {"name": "Oyo", "lgas": ["Surulere"]}]
adm1 = [_feature("Lagos", square(0, 0, 2, 1)), _feature("Oyo", square(0, 1, 2, 2))]
adm2 = [
_feature("Ifako/Ijaye", square(0, 0, 1, 1)), # fuzzy
_feature("Surulere", square(1, 0, 2, 1)), # exact, in Lagos by position
_feature("Surulere", square(0, 1, 2, 2)), # same name, in Oyo by position
]
matched, problems = match_boundaries(states, adm1, adm2)
assert problems == []
assert matched[("Lagos", "Ifako-Ijaiye")]["properties"]["shapeName"] == "Ifako/Ijaye"
assert centroid(matched[("Oyo", "Surulere")]["geometry"])[1] == pytest.approx(1.5)
def test_match_boundaries_reports_rather_than_guesses():
states = [{"name": "Lagos", "lgas": ["Agege"]}]
adm1 = [_feature("Lagos", square(0, 0, 1, 1))]
adm2 = [_feature("Completely Different", square(0, 0, 1, 1))]
_, problems = match_boundaries(states, adm1, adm2)
assert any("Agege" in p for p in problems)
def test_resolve_alias_to_lga(gazetteer):
assert resolve(gazetteer, "Flooding in Ajah after heavy rain") == "ng.lagos.eti-osa"
def test_resolve_shared_lga_name_needs_state_context(gazetteer):
assert resolve(gazetteer, "Traders in Surulere protest") is None
assert resolve(gazetteer, "Surulere, Lagos market shut") == "ng.lagos.surulere"
def test_resolve_state_beats_same_named_lga(gazetteer):
assert resolve(gazetteer, "Bauchi governor signs levy") == "ng.bauchi"
def test_resolve_no_mention(gazetteer):
assert resolve(gazetteer, "Celebrity wedding photos") is None
assert resolve(gazetteer, None) is None
def test_resolve_word_boundaries(gazetteer):
# "Oyo" must not match inside another word.
assert resolve(gazetteer, "Coyotes spotted") is None
# ─── Layer 1: freshness ─────────────────────────────────────────────────────
def _source(con, sid):
return con.execute("select * from sources where id = ?", (sid,)).fetchone()
def test_never_fetched_source_is_never(con):
assert source_status(_source(con, "calendar"), datetime.now(timezone.utc))["status"] == "never"
def test_fresh_then_stale_on_fetch_clock(con):
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
now = datetime.now(timezone.utc)
assert source_status(_source(con, "open_meteo_forecast"), now)["status"] == "fresh"
assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=2))["status"] == "late"
assert source_status(_source(con, "open_meteo_forecast"), now + timedelta(days=4))["status"] == "stale"
def test_data_clock_catches_old_data_behind_a_fresh_fetch(con):
# Fetched just now, but the newest data is a month old on a daily source.
old = (date.today() - timedelta(days=30)).isoformat()
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=old)
s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
assert s["status"] == "stale"
assert "newest data" in s["reason"]
def test_archive_latency_is_allowed(con):
# The archive legitimately trails by ~a week; 6-day-old data is on time.
record_attempt(con, "open_meteo_archive", ok=True, latest_data_period=(date.today() - timedelta(days=6)).isoformat())
assert source_status(_source(con, "open_meteo_archive"), datetime.now(timezone.utc))["status"] == "fresh"
def test_failure_after_success_is_failing(con):
record_attempt(con, "open_meteo_forecast", ok=True, latest_data_period=date.today().isoformat())
record_attempt(con, "open_meteo_forecast", ok=False, error="timeout")
s = source_status(_source(con, "open_meteo_forecast"), datetime.now(timezone.utc))
assert s["status"] == "failing" and "timeout" in s["reason"]
# ─── handoffs ───────────────────────────────────────────────────────────────
def test_handoff_receipt_once(con):
hid = handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {"ok": True})
assert [h["id"] for h in handoff.pending(con, handoff.STALE_AWARENESS)] == [hid]
assert handoff.receive(con, hid, "test") is True
assert handoff.receive(con, hid, "test") is False
assert handoff.pending(con, handoff.STALE_AWARENESS) == []
def test_unreceived_handoff_to_active_layer_goes_overdue(con):
handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.STALE_AWARENESS, {})
later = datetime.now(timezone.utc) + timedelta(days=2)
assert len(report(con, later)["handoffs_overdue"]) == 1
def test_handoff_to_unbuilt_layer_is_queued_not_overdue(con):
handoff.produce(con, "r1", handoff.DATA_STREAM, handoff.INTERFACE, {})
r = report(con, datetime.now(timezone.utc) + timedelta(days=30))
assert r["handoffs_overdue"] == [] and r["handoffs_queued_for_unbuilt_layers"] == 1
# ─── calendar ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize("year,expected", [(2024, date(2024, 3, 31)), (2025, date(2025, 4, 20)), (2026, date(2026, 4, 5))])
def test_easter(year, expected):
assert cal.easter(year) == expected
def test_democracy_day_moved_in_2019():
dd = lambda y: next(e["starts_on"] for e in cal.events_for_year(y) if e["key"] == "democracy_day") # noqa: E731
assert dd(2018) == date(2018, 5, 29)
assert dd(2019) == date(2019, 6, 12)
def test_islamic_holidays_are_estimates():
events = {e["key"]: e for e in cal.events_for_year(2025)}
assert events["eid_al_fitr"]["starts_on"] == date(2025, 3, 30)
assert events["eid_al_fitr"]["is_estimate"] and not events["christmas"]["is_estimate"]
def test_calendar_ingest_is_idempotent(gazetteer):
first = cal.ingest(gazetteer, 2024, 2025)["events"]
cal.ingest(gazetteer, 2024, 2025)
assert gazetteer.execute("select count(*) from events where source_id = 'calendar'").fetchone()[0] <= first
# ─── weather backfill ───────────────────────────────────────────────────────
def fake_fetch(calls):
def fetch(url, params):
calls.append(params)
a, b = date.fromisoformat(params.get("start_date") or date.today().isoformat()), \
date.fromisoformat(params.get("end_date") or (date.today() + timedelta(days=params.get("forecast_days", 1) - 1)).isoformat())
days = [(a + timedelta(days=i)).isoformat() for i in range((b - a).days + 1)]
return {"daily": {"time": days, **{v: [1.0] * len(days) for v in weather.VARIABLES}}}
return fetch
def test_call_weight():
assert weather.call_weight(14) == 1
assert weather.call_weight(365) == pytest.approx(365 / 14)
def test_backfill_stops_at_budget_and_resumes(gazetteer):
start = date(date.today().year - 3, 1, 1)
calls = []
first = weather.backfill_history(gazetteer, start=start, budget=60, fetch=fake_fetch(calls), sleep=lambda s: None)
assert first["stopped"] == "run budget reached" and first["year_chunks_remaining"] > 0
weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
assert weather._missing_years(gazetteer, "ng.lagos", start, date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)) == []
# A third run has nothing to fetch.
n = len(calls)
weather.backfill_history(gazetteer, start=start, budget=10_000, fetch=fake_fetch(calls), sleep=lambda s: None)
assert len(calls) == n
def test_backfill_resumes_current_year_from_last_day(gazetteer):
end = date.today() - timedelta(days=weather.ARCHIVE_LAG_DAYS)
start = date(end.year, 1, 1)
weather.backfill_history(gazetteer, start=start, fetch=fake_fetch([]), sleep=lambda s: None)
gazetteer.execute("delete from observations where period_start > ?", ((end - timedelta(days=3)).isoformat(),))
spans = weather._missing_years(gazetteer, "ng.lagos", start, end)
assert spans == [(end - timedelta(days=2), end)]
def test_quota_429_is_partial_success_not_error(gazetteer):
def fetch(url, params):
raise weather.QuotaExhausted("daily limit")
out = weather.backfill_history(gazetteer, start=date(date.today().year, 1, 1), fetch=fetch, sleep=lambda s: None)
assert "quota" in out["stopped"]
def test_forecast_rows_are_marked_and_versioned(gazetteer):
weather.fetch_forecast(gazetteer, days=3, fetch=fake_fetch([]))
rows = gazetteer.execute("select distinct is_forecast, issued_at from observations").fetchall()
assert [tuple(r) for r in rows] == [(1, date.today().isoformat())]
# ─── places category mapping ────────────────────────────────────────────────
def test_subsector_prefers_detailed_taxonomy():
assert subsector_for("personal_or_beauty_service", "barber") == "hair_services"
assert subsector_for("personal_or_beauty_service", "makeup_artist") == "skincare_cosmetics"
assert subsector_for("restaurant", None) == "catering_food_service"
def test_context_places_have_no_subsector():
assert subsector_for("christian_place_of_worship", None) is None
assert subsector_for(None, None) is None
def test_mapped_subsectors_are_real_ids():
from news.business_types import VALID_IDS
from intel.subsector_map import BASIC_TO_SUBSECTOR, TAXONOMY_TO_SUBSECTOR
assert set(BASIC_TO_SUBSECTOR.values()) | set(TAXONOMY_TO_SUBSECTOR.values()) <= set(VALID_IDS)
# ─── news β†’ events ──────────────────────────────────────────────────────────
def test_record_news_item_is_noop_when_store_off(monkeypatch):
from intel.events import record_news_item
monkeypatch.delenv("INTEL_DB_PATH", raising=False)
assert record_news_item({"url_hash": "x", "title": "t"}) is False
def test_record_news_item_writes_located_event(monkeypatch, tmp_path):
from intel.events import record_news_item
path = tmp_path / "intel.db"
monkeypatch.setenv("INTEL_DB_PATH", str(path))
c = db.connect()
db.init(c)
add_location(c, "ng", "country", "Nigeria")
add_location(c, "ng.lagos", "state", "Lagos", "ng")
add_location(c, "ng.lagos.eti-osa", "lga", "Eti-Osa", "ng.lagos", aliases=("Lekki",))
c.commit()
item = {"url_hash": "abc", "title": "Lekki traders face new levy", "category": "policy", "location": "Lekki",
"published_at": "2026-10-01T09:00:00+00:00", "raw_text": "..."}
assert record_news_item(item) is True
assert record_news_item(item) is True # idempotent upsert
rows = c.execute("select family, location_id, starts_on from events where source_id = 'news_pipeline'").fetchall()
assert [tuple(r) for r in rows] == [("policy", "ng.lagos.eti-osa", "2026-10-01")]
c.close()
def test_record_news_item_never_raises(monkeypatch, tmp_path):
from intel.events import record_news_item
monkeypatch.setenv("INTEL_DB_PATH", str(tmp_path / "missing-schema.db"))
assert record_news_item({"url_hash": "x", "title": "t"}) is False