feat: IODA fallback, UCDP timeout fix, PDF export, 18 tests
- infrastructure.py: Add IODA (Georgia Tech) as fallback when Cloudflare Radar returns 403 (no API token). Internet outage data now available without CLOUDFLARE_API_TOKEN. - conflict.py: Increase UCDP GED API timeout from 15s to 30s to handle slow responses from ucdpapi.pcr.uu.se. - dashboard/app.py: Add /api/report/pdf endpoint for PDF daily brief export via weasyprint (optional dependency). - pyproject.toml: Add dashboard + pdf optional deps, intel-dashboard script entry point, update description for 55 tools. - tests: Add 11 new tests covering health, sanctions, elections, shipping, social, nuclear, infrastructure (IODA fallback + cable health), and UCDP conflict. Fix cross-loop asyncio lock issue in conftest. - Total: 50 tests passing, 55 tools verified. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6fee3feae5
commit
2d33ea136a
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "world-intel-mcp"
|
name = "world-intel-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "World Intelligence MCP Server - real-time global intelligence across 17 domains"
|
description = "World Intelligence MCP Server - real-time global intelligence across 23 domains with 55 MCP tools"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
@@ -21,6 +21,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
dashboard = ["starlette>=0.37.0", "uvicorn>=0.29.0"]
|
||||||
pdf = ["weasyprint>=62.0"]
|
pdf = ["weasyprint>=62.0"]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
@@ -32,6 +33,7 @@ dev = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
world-intel-mcp = "world_intel_mcp.server:run"
|
world-intel-mcp = "world_intel_mcp.server:run"
|
||||||
intel = "world_intel_mcp.cli:main"
|
intel = "world_intel_mcp.cli:main"
|
||||||
|
intel-dashboard = "world_intel_mcp.dashboard.app:run"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.responses import HTMLResponse, JSONResponse, StreamingResponse
|
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||||
from starlette.routing import Route
|
from starlette.routing import Route
|
||||||
|
|
||||||
from world_intel_mcp.cache import Cache
|
from world_intel_mcp.cache import Cache
|
||||||
@@ -205,6 +205,55 @@ async def api_health(request):
|
|||||||
return JSONResponse({"status": "ok"})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
async def api_report_pdf(request):
|
||||||
|
"""Generate a PDF daily brief report.
|
||||||
|
|
||||||
|
Renders the daily_brief.html template with live data, then converts
|
||||||
|
to PDF via weasyprint. Requires ``pip install world-intel-mcp[pdf]``.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML as WeasyHTML
|
||||||
|
except ImportError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "weasyprint not installed — run: pip install world-intel-mcp[pdf]"},
|
||||||
|
status_code=501,
|
||||||
|
)
|
||||||
|
|
||||||
|
from world_intel_mcp.reports.html_report import render_template
|
||||||
|
|
||||||
|
data = await _fetch_overview()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "Daily Intelligence Brief",
|
||||||
|
"generated_at": data.get("timestamp", ""),
|
||||||
|
"market_quotes": data.get("market_quotes", {}),
|
||||||
|
"crypto_quotes": data.get("crypto_quotes", {}),
|
||||||
|
"macro_signals": data.get("macro_signals", {}),
|
||||||
|
"earthquakes": data.get("earthquakes", {}),
|
||||||
|
"cyber_threats": data.get("cyber_threats", {}),
|
||||||
|
"news_feed": data.get("news_feed", {}),
|
||||||
|
"military_flights": data.get("military_flights", {}),
|
||||||
|
"internet_outages": data.get("internet_outages", {}),
|
||||||
|
"climate_anomalies": data.get("climate_anomalies", {}),
|
||||||
|
"displacement": data.get("displacement", {}),
|
||||||
|
"risk_scores": data.get("risk_scores", {}),
|
||||||
|
"alert_digest": data.get("alert_digest", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
html_str = render_template("daily_brief.html", context)
|
||||||
|
pdf_bytes = WeasyHTML(string=html_str).write_pdf()
|
||||||
|
|
||||||
|
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
return Response(
|
||||||
|
content=pdf_bytes,
|
||||||
|
media_type="application/pdf",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="intel-brief-{now_str}.pdf"',
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App
|
# App
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -215,6 +264,7 @@ app = Starlette(
|
|||||||
Route("/api/overview", api_overview),
|
Route("/api/overview", api_overview),
|
||||||
Route("/api/stream", api_stream),
|
Route("/api/stream", api_stream),
|
||||||
Route("/api/health", api_health),
|
Route("/api/health", api_health),
|
||||||
|
Route("/api/report/pdf", api_report_pdf),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -155,13 +155,14 @@ async def fetch_ucdp_events(
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
cutoff = now - timedelta(days=days)
|
cutoff = now - timedelta(days=days)
|
||||||
|
|
||||||
# Fetch page 1 to discover total pages
|
# Fetch page 1 to discover total pages (UCDP is slow — 30s timeout)
|
||||||
page1_data = await fetcher.get_json(
|
page1_data = await fetcher.get_json(
|
||||||
_UCDP_GED_URL,
|
_UCDP_GED_URL,
|
||||||
source="ucdp",
|
source="ucdp",
|
||||||
cache_key=f"conflict:ucdp:{days}:page1",
|
cache_key=f"conflict:ucdp:{days}:page1",
|
||||||
cache_ttl=21600,
|
cache_ttl=21600,
|
||||||
params={"pagesize": min(limit, 1000), "page": 0},
|
params={"pagesize": min(limit, 1000), "page": 0},
|
||||||
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if page1_data is None:
|
if page1_data is None:
|
||||||
@@ -187,6 +188,7 @@ async def fetch_ucdp_events(
|
|||||||
cache_key=f"conflict:ucdp:{days}:page{p}",
|
cache_key=f"conflict:ucdp:{days}:page{p}",
|
||||||
cache_ttl=21600,
|
cache_ttl=21600,
|
||||||
params={"pagesize": min(limit, 1000), "page": p},
|
params={"pagesize": min(limit, 1000), "page": p},
|
||||||
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
for p in range(1, total_pages)
|
for p in range(1, total_pages)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -148,6 +148,85 @@ def _point_in_corridor(
|
|||||||
return lon_lo <= lon <= lon_hi
|
return lon_lo <= lon <= lon_hi
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IODA fallback (Georgia Tech Internet Intelligence — public, no auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_IODA_OUTAGES_URL = "https://api.ioda.inetintel.cc.gatech.edu/v2/outages/overall"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_ioda_outages(fetcher: Fetcher) -> dict | None:
|
||||||
|
"""Fallback: fetch recent internet outages from IODA public API.
|
||||||
|
|
||||||
|
Returns a dict matching the fetch_internet_outages output shape,
|
||||||
|
or None if IODA is also unavailable.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since = now - timedelta(days=7)
|
||||||
|
|
||||||
|
data = await fetcher.get_json(
|
||||||
|
url=_IODA_OUTAGES_URL,
|
||||||
|
source="ioda",
|
||||||
|
cache_key="infra:outages:ioda",
|
||||||
|
cache_ttl=300,
|
||||||
|
params={
|
||||||
|
"from": int(since.timestamp()),
|
||||||
|
"until": int(now.timestamp()),
|
||||||
|
"limit": 20,
|
||||||
|
},
|
||||||
|
timeout=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# IODA returns {"data": [{"entity": {...}, "events": [...]}]}
|
||||||
|
raw_items = data if isinstance(data, list) else data.get("data", [])
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
outages: list[dict] = []
|
||||||
|
ongoing_count = 0
|
||||||
|
|
||||||
|
for item in raw_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
entity = item.get("entity", {}) if isinstance(item.get("entity"), dict) else {}
|
||||||
|
events = item.get("events", []) if isinstance(item.get("events"), list) else [item]
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
if not isinstance(ev, dict):
|
||||||
|
continue
|
||||||
|
start = ev.get("from") or ev.get("start")
|
||||||
|
end = ev.get("until") or ev.get("end")
|
||||||
|
is_ongoing = end is None
|
||||||
|
|
||||||
|
if is_ongoing:
|
||||||
|
ongoing_count += 1
|
||||||
|
|
||||||
|
outages.append({
|
||||||
|
"id": ev.get("id") or entity.get("code"),
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
"description": ev.get("summary", entity.get("name", "")),
|
||||||
|
"scope": ev.get("level", "unknown"),
|
||||||
|
"countries": [entity.get("code", "")] if entity.get("code") else [],
|
||||||
|
"asns": [],
|
||||||
|
"is_ongoing": is_ongoing,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"outages": outages,
|
||||||
|
"ongoing_count": ongoing_count,
|
||||||
|
"total_7d": len(outages),
|
||||||
|
"source": "ioda-gatech",
|
||||||
|
"timestamp": _utc_now_iso(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Internet Outages (Cloudflare Radar)
|
# Internet Outages (Cloudflare Radar)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,44 +234,49 @@ def _point_in_corridor(
|
|||||||
async def fetch_internet_outages(fetcher: Fetcher) -> dict:
|
async def fetch_internet_outages(fetcher: Fetcher) -> dict:
|
||||||
"""Fetch recent internet outage annotations from Cloudflare Radar.
|
"""Fetch recent internet outage annotations from Cloudflare Radar.
|
||||||
|
|
||||||
Uses the public Radar annotations API for the last 7 days. If the
|
Tries authenticated Cloudflare Radar API first (requires
|
||||||
``CLOUDFLARE_API_TOKEN`` environment variable is set, authenticated
|
``CLOUDFLARE_API_TOKEN``). Falls back to IODA (Georgia Tech
|
||||||
requests are made which may yield higher rate limits.
|
Internet Intelligence) public API for internet outage signals.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with outages list, ongoing/total counts, source, and timestamp.
|
Dict with outages list, ongoing/total counts, source, and timestamp.
|
||||||
"""
|
"""
|
||||||
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
token = os.environ.get("CLOUDFLARE_API_TOKEN")
|
||||||
|
|
||||||
headers: dict[str, str] | None = None
|
|
||||||
if token:
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
params = {
|
|
||||||
"limit": 20,
|
|
||||||
"dateRange": "7d",
|
|
||||||
}
|
|
||||||
|
|
||||||
data = await fetcher.get_json(
|
|
||||||
url=_CF_RADAR_OUTAGES_URL,
|
|
||||||
source="cloudflare-radar",
|
|
||||||
cache_key="infra:outages",
|
|
||||||
cache_ttl=300,
|
|
||||||
headers=headers,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
now_iso = _utc_now_iso()
|
now_iso = _utc_now_iso()
|
||||||
|
|
||||||
|
data = None
|
||||||
|
|
||||||
|
# --- Attempt 1: Cloudflare Radar (requires token) ---
|
||||||
|
if token:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
data = await fetcher.get_json(
|
||||||
|
url=_CF_RADAR_OUTAGES_URL,
|
||||||
|
source="cloudflare-radar",
|
||||||
|
cache_key="infra:outages:cf",
|
||||||
|
cache_ttl=300,
|
||||||
|
headers=headers,
|
||||||
|
params={"limit": 20, "dateRange": "7d"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Attempt 2: IODA public API (no auth needed) ---
|
||||||
if data is None:
|
if data is None:
|
||||||
logger.warning("Cloudflare Radar outages API returned no data")
|
data = await _fetch_ioda_outages(fetcher)
|
||||||
return {
|
if data is not None:
|
||||||
|
return data # IODA already returns our output shape
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
note = "Set CLOUDFLARE_API_TOKEN for detailed outage data" if not token else None
|
||||||
|
logger.warning("Internet outages: no data from Cloudflare or IODA")
|
||||||
|
result: dict = {
|
||||||
"outages": [],
|
"outages": [],
|
||||||
"ongoing_count": 0,
|
"ongoing_count": 0,
|
||||||
"total_7d": 0,
|
"total_7d": 0,
|
||||||
"source": "cloudflare-radar",
|
"source": "cloudflare-radar",
|
||||||
"timestamp": now_iso,
|
"timestamp": now_iso,
|
||||||
}
|
}
|
||||||
|
if note:
|
||||||
|
result["note"] = note
|
||||||
|
return result
|
||||||
|
|
||||||
outages: list[dict] = []
|
outages: list[dict] = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Test configuration — strips proxy env vars so httpx doesn't try SOCKS."""
|
"""Test configuration — strips proxy env vars so httpx doesn't try SOCKS."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -16,3 +17,14 @@ def _strip_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
"""Remove system proxy env vars so httpx creates clean connections."""
|
"""Remove system proxy env vars so httpx creates clean connections."""
|
||||||
for var in _PROXY_VARS:
|
for var in _PROXY_VARS:
|
||||||
monkeypatch.delenv(var, raising=False)
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fetcher_locks() -> None:
|
||||||
|
"""Reset global asyncio locks between tests to avoid cross-loop binding."""
|
||||||
|
import world_intel_mcp.fetcher as fetcher_mod
|
||||||
|
|
||||||
|
fetcher_mod._yahoo_lock = asyncio.Lock()
|
||||||
|
fetcher_mod._yahoo_last_call = 0.0
|
||||||
|
fetcher_mod._source_locks.clear()
|
||||||
|
fetcher_mod._source_last_call.clear()
|
||||||
|
|||||||
@@ -216,3 +216,374 @@ async def test_fetch_world_bank_indicators(fetcher: Fetcher) -> None:
|
|||||||
assert len(result["indicators"]) == 1
|
assert len(result["indicators"]) == 1
|
||||||
assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD"
|
assert result["indicators"][0]["id"] == "NY.GDP.MKTP.CD"
|
||||||
assert result["source"] == "world-bank"
|
assert result["source"] == "world-bank"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Health (disease outbreaks)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_disease_outbreaks(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.health import fetch_disease_outbreaks
|
||||||
|
|
||||||
|
rss_xml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel><title>WHO</title>
|
||||||
|
<item>
|
||||||
|
<title>Ebola outbreak in DRC - Update 5</title>
|
||||||
|
<link>https://who.int/ebola-update</link>
|
||||||
|
<pubDate>Mon, 10 Feb 2026 12:00:00 GMT</pubDate>
|
||||||
|
<description>Ebola virus disease outbreak continues in North Kivu.</description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Seasonal influenza update</title>
|
||||||
|
<link>https://who.int/flu</link>
|
||||||
|
<pubDate>Sun, 09 Feb 2026 08:00:00 GMT</pubDate>
|
||||||
|
<description>Northern hemisphere flu season report.</description>
|
||||||
|
</item>
|
||||||
|
</channel></rss>"""
|
||||||
|
|
||||||
|
# Mock all 3 health feeds returning same XML
|
||||||
|
respx.get(url__regex=r".*who\.int.*").mock(
|
||||||
|
return_value=httpx.Response(200, text=rss_xml)
|
||||||
|
)
|
||||||
|
respx.get(url__regex=r".*cdc\.gov.*").mock(
|
||||||
|
return_value=httpx.Response(200, text=rss_xml)
|
||||||
|
)
|
||||||
|
respx.get(url__regex=r".*outbreaknewstoday.*").mock(
|
||||||
|
return_value=httpx.Response(200, text=rss_xml)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_disease_outbreaks(fetcher)
|
||||||
|
assert result["source"] == "health-outbreak-monitor"
|
||||||
|
assert result["count"] > 0
|
||||||
|
assert result["high_concern_count"] > 0 # "ebola" in title
|
||||||
|
assert any(item["is_high_concern"] for item in result["items"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sanctions (OFAC SDN)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_sanctions_search(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.sanctions import fetch_sanctions_search
|
||||||
|
|
||||||
|
csv_data = (
|
||||||
|
'100,"DOE, John",individual,SDGT,"","","","","","","","nationality Iran; DOB 01 Jan 1970"\n'
|
||||||
|
'101,"ACME CORP",entity,CUBA,"","","","","","","",""\n'
|
||||||
|
'102,"SMITH, Jane",individual,SYRIA,"","","","","","","","nationality Syria"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
respx.get(url__regex=r".*treasury\.gov.*sdn\.csv.*").mock(
|
||||||
|
return_value=httpx.Response(200, text=csv_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_sanctions_search(fetcher, query="DOE")
|
||||||
|
assert result["source"] == "ofac-sdn"
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert result["matches"][0]["name"] == "DOE, John"
|
||||||
|
assert result["total_entities"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_sanctions_search_country_filter(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.sanctions import fetch_sanctions_search
|
||||||
|
|
||||||
|
csv_data = (
|
||||||
|
'100,"DOE, John",individual,SDGT,"","","","","","","","nationality Iran; DOB 01 Jan 1970"\n'
|
||||||
|
'101,"ACME CORP",entity,CUBA,"","","","","","","",""\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
respx.get(url__regex=r".*treasury\.gov.*sdn\.csv.*").mock(
|
||||||
|
return_value=httpx.Response(200, text=csv_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_sanctions_search(fetcher, country="iran")
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert result["matches"][0]["name"] == "DOE, John"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Elections (pure data — no HTTP mocking needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_election_calendar(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.elections import fetch_election_calendar
|
||||||
|
|
||||||
|
result = await fetch_election_calendar(fetcher)
|
||||||
|
assert result["source"] == "election-calendar"
|
||||||
|
assert result["count"] > 0
|
||||||
|
assert "elections" in result
|
||||||
|
|
||||||
|
# Each election should have risk_score
|
||||||
|
for election in result["elections"]:
|
||||||
|
assert "risk_score" in election
|
||||||
|
assert "days_until" in election
|
||||||
|
assert election["status"] in ("past", "upcoming")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_election_calendar_country_filter(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.elections import fetch_election_calendar
|
||||||
|
|
||||||
|
result = await fetch_election_calendar(fetcher, country="USA")
|
||||||
|
# May or may not match depending on config data
|
||||||
|
assert result["source"] == "election-calendar"
|
||||||
|
assert isinstance(result["elections"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shipping (Yahoo Finance quotes)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_shipping_index(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.shipping import fetch_shipping_index
|
||||||
|
|
||||||
|
chart_response = {
|
||||||
|
"chart": {
|
||||||
|
"result": [{
|
||||||
|
"meta": {
|
||||||
|
"symbol": "BDRY",
|
||||||
|
"regularMarketPrice": 15.50,
|
||||||
|
"regularMarketChangePercent": 4.2,
|
||||||
|
"currency": "USD",
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock all 4 shipping symbols
|
||||||
|
respx.get(url__regex=r".*finance\.yahoo\.com.*").mock(
|
||||||
|
return_value=httpx.Response(200, json=chart_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_shipping_index(fetcher)
|
||||||
|
assert result["source"] == "yahoo-finance"
|
||||||
|
assert len(result["quotes"]) > 0
|
||||||
|
assert isinstance(result["stress_score"], (int, float))
|
||||||
|
assert result["assessment"] in ("low", "moderate", "elevated", "high", "extreme")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Social (Reddit)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_social_signals(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.social import fetch_social_signals
|
||||||
|
|
||||||
|
reddit_response = {
|
||||||
|
"data": {
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"title": "Ukraine conflict escalation analysis",
|
||||||
|
"score": 5000,
|
||||||
|
"num_comments": 300,
|
||||||
|
"upvote_ratio": 0.95,
|
||||||
|
"created_utc": 1708700000,
|
||||||
|
"permalink": "/r/worldnews/comments/abc123/",
|
||||||
|
"is_self": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"title": "US-China trade tensions rise",
|
||||||
|
"score": 2000,
|
||||||
|
"num_comments": 150,
|
||||||
|
"upvote_ratio": 0.88,
|
||||||
|
"created_utc": 1708690000,
|
||||||
|
"permalink": "/r/worldnews/comments/def456/",
|
||||||
|
"is_self": True,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.get(url__regex=r".*reddit\.com.*hot\.json.*").mock(
|
||||||
|
return_value=httpx.Response(200, json=reddit_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_social_signals(fetcher)
|
||||||
|
assert result["source"] == "reddit-public"
|
||||||
|
assert result["velocity_metrics"]["total_posts"] > 0
|
||||||
|
assert result["velocity_metrics"]["high_engagement_count"] > 0
|
||||||
|
assert result["subreddits_queried"] == ["worldnews", "geopolitics"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nuclear (USGS near test sites)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_nuclear_monitor(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.nuclear import fetch_nuclear_monitor
|
||||||
|
|
||||||
|
geojson = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "nn00900001",
|
||||||
|
"properties": {
|
||||||
|
"mag": 2.8,
|
||||||
|
"place": "50km N of Test Site",
|
||||||
|
"time": 1708700000000,
|
||||||
|
"tsunami": 0,
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": [129.08, 41.30, 3.0], # Near Punggye-ri
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock USGS for all nuclear sites
|
||||||
|
respx.get("https://earthquake.usgs.gov/fdsnws/event/1/query").mock(
|
||||||
|
return_value=httpx.Response(200, json=geojson)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_nuclear_monitor(fetcher, hours=72)
|
||||||
|
assert result["source"] == "usgs-nuclear-monitor"
|
||||||
|
assert len(result["sites"]) == 5 # 5 nuclear test sites
|
||||||
|
assert isinstance(result["total_flagged_events"], int)
|
||||||
|
assert isinstance(result["critical_flags"], int)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Infrastructure (Cloudflare + IODA fallback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_internet_outages_ioda_fallback(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.infrastructure import fetch_internet_outages
|
||||||
|
|
||||||
|
# IODA response shape
|
||||||
|
ioda_response = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"entity": {"code": "US", "name": "United States"},
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"id": "out-123",
|
||||||
|
"from": "2026-02-20T00:00:00Z",
|
||||||
|
"until": None,
|
||||||
|
"summary": "BGP outage detected",
|
||||||
|
"level": "country",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cloudflare returns 403 (no token)
|
||||||
|
respx.get(url__regex=r".*cloudflare\.com.*").mock(
|
||||||
|
return_value=httpx.Response(403, json={"error": "unauthorized"})
|
||||||
|
)
|
||||||
|
# IODA responds
|
||||||
|
respx.get(url__regex=r".*ioda\.inetintel.*").mock(
|
||||||
|
return_value=httpx.Response(200, json=ioda_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.environ.pop("CLOUDFLARE_API_TOKEN", None)
|
||||||
|
|
||||||
|
result = await fetch_internet_outages(fetcher)
|
||||||
|
assert result["source"] == "ioda-gatech"
|
||||||
|
assert result["total_7d"] == 1
|
||||||
|
assert result["ongoing_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_cable_health(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.infrastructure import fetch_cable_health
|
||||||
|
|
||||||
|
warnings = [
|
||||||
|
{
|
||||||
|
"msgYear": 2026,
|
||||||
|
"msgNumber": 42,
|
||||||
|
"navArea": "XII",
|
||||||
|
"subregion": "31",
|
||||||
|
"status": "in force",
|
||||||
|
"issueDate": "2026-02-20",
|
||||||
|
"text": "SUBMARINE CABLE OPERATIONS 40-30.5N/030-15.2E VESSELS ADVISED",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
respx.get(url__regex=r".*nga\.mil.*broadcast-warn.*").mock(
|
||||||
|
return_value=httpx.Response(200, json=warnings)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_cable_health(fetcher)
|
||||||
|
assert result["source"] == "nga-msi"
|
||||||
|
assert "corridors" in result
|
||||||
|
assert len(result["corridors"]) == 6
|
||||||
|
assert result["cable_related_warnings"] >= 1 # "cable" keyword in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Conflict (UCDP + ACLED)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_ucdp_events(fetcher: Fetcher) -> None:
|
||||||
|
from world_intel_mcp.sources.conflict import fetch_ucdp_events
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
ucdp_response = {
|
||||||
|
"TotalPages": 1,
|
||||||
|
"Result": [
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"relid": "11-1",
|
||||||
|
"year": 2026,
|
||||||
|
"date_start": today,
|
||||||
|
"date_end": today,
|
||||||
|
"country": "Ukraine",
|
||||||
|
"region": "Europe",
|
||||||
|
"type_of_violence": 1,
|
||||||
|
"side_a": "Government of Ukraine",
|
||||||
|
"side_b": "DPR",
|
||||||
|
"best": 5,
|
||||||
|
"high": 10,
|
||||||
|
"low": 2,
|
||||||
|
"latitude": 48.0,
|
||||||
|
"longitude": 37.5,
|
||||||
|
"source_article": "Reuters",
|
||||||
|
"source_headline": "Fighting continues",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
respx.get(url__regex=r".*ucdpapi\.pcr\.uu\.se.*").mock(
|
||||||
|
return_value=httpx.Response(200, json=ucdp_response)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_ucdp_events(fetcher, days=30)
|
||||||
|
assert result["source"] == "ucdp"
|
||||||
|
assert result["count"] == 1
|
||||||
|
assert result["events"][0]["country"] == "Ukraine"
|
||||||
|
assert result["total_fatalities_best"] == 5
|
||||||
|
|||||||
Reference in New Issue
Block a user