"""Tests for GDACS adapter."""
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from central.config_models import AdapterConfig
from central.models import Event
# Frozen RSS fixture mirroring real GDACS shape (namespaces + element layout).
SAMPLE_RSS = """
GDACS RSS information
https://www.gdacs.org/
Near real-time notification
Tue, 19 May 2026 06:35:01 GMT
-
Green wildfire in Greece 18/05/2026 11:00 UTC
Wildfire in Attica region of Greece.
https://www.gdacs.org/report.aspx?eventtype=WF&eventid=2002001
Mon, 18 May 2026 11:10:00 GMT
true
Mon, 18 May 2026 11:00:00 GMT
Tue, 19 May 2026 04:00:00 GMT
WF2002001
38.0
23.7
21.7 25.7 36.0 40.0
WF
Green
0
2002001
GRC
Greece
-
Orange drought in United States of America 15/04/2026
Multi-state drought.
https://www.gdacs.org/report.aspx?eventtype=DR&eventid=3003001
Wed, 15 Apr 2026 00:00:00 GMT
true
Wed, 15 Apr 2026 00:00:00 GMT
DR3003001
39.5
-98.5
-110.0 -90.0 32.0 45.0
DR
Orange
1.5
3003001
USA
United States of America
-
Green earthquake in Vanuatu
EQ Vanuatu
https://www.gdacs.org/report.aspx?eventtype=EQ&eventid=1541360
Tue, 19 May 2026 02:41:13 GMT
true
Tue, 19 May 2026 02:29:24 GMT
EQ1541360
-18.15
168.09
EQ
Green
1541360
VUT
Vanuatu
-
Synthetic unknown eventtype
XX synthetic test
https://www.gdacs.org/report.aspx?eventtype=XX&eventid=999999
Tue, 19 May 2026 00:00:00 GMT
true
Tue, 19 May 2026 00:00:00 GMT
XX999999
XX
Green
999999
Nowhere
"""
# Same items but WF turned to iscurrent=false (tombstone scenario)
SAMPLE_RSS_WF_RETIRED = SAMPLE_RSS.replace(
"true\n Mon, 18 May 2026 11:00:00 GMT",
"false\n Mon, 18 May 2026 11:00:00 GMT",
1,
)
# Just the DR + EQ + XX items, with WF removed entirely (missing-from-feed scenario)
SAMPLE_RSS_WF_MISSING = SAMPLE_RSS.replace(
"""-
Green wildfire in Greece 18/05/2026 11:00 UTC
Wildfire in Attica region of Greece.
https://www.gdacs.org/report.aspx?eventtype=WF&eventid=2002001
Mon, 18 May 2026 11:10:00 GMT
true
Mon, 18 May 2026 11:00:00 GMT
Tue, 19 May 2026 04:00:00 GMT
WF2002001
38.0
23.7
21.7 25.7 36.0 40.0
WF
Green
0
2002001
GRC
Greece
""",
"",
1,
)
def _config(settings: dict | None = None) -> AdapterConfig:
return AdapterConfig(
name="gdacs",
enabled=True,
cadence_s=600,
settings=settings or {"event_types": ["WF", "DR", "FL", "VO", "TC"]},
updated_at=datetime.now(timezone.utc),
)
class TestGDACSHelpers:
def test_severity_from_alertlevel_green_orange_red(self):
from central.adapters.gdacs import severity_from_alertlevel
assert severity_from_alertlevel("Green") == 1
assert severity_from_alertlevel("Orange") == 2
assert severity_from_alertlevel("Red") == 3
assert severity_from_alertlevel(None) == 0
assert severity_from_alertlevel("") == 0
assert severity_from_alertlevel("Unknown") == 0
# case-insensitive
assert severity_from_alertlevel("green") == 1
assert severity_from_alertlevel("RED") == 3
def test_subject_for_lowercase_country(self):
from central.adapters.gdacs import subject_for_country
assert subject_for_country("United States") == "united-states"
assert subject_for_country("Greece") == "greece"
assert subject_for_country("Solomon Islands") == "solomon-islands"
def test_subject_for_unknown_country(self):
from central.adapters.gdacs import subject_for_country
assert subject_for_country(None) == "unknown"
assert subject_for_country("") == "unknown"
assert subject_for_country(" ") == "unknown"
def test_subject_for_multi_country_takes_first(self):
from central.adapters.gdacs import subject_for_country
assert subject_for_country("Mozambique, Madagascar") == "mozambique"
def test_parse_gdacs_bbox(self):
from central.adapters.gdacs import parse_gdacs_bbox
# GDACS format: lonmin lonmax latmin latmax
# Geo.bbox: (minLon, minLat, maxLon, maxLat)
result = parse_gdacs_bbox("21.7 25.7 36.0 40.0")
assert result == (21.7, 36.0, 25.7, 40.0)
assert parse_gdacs_bbox(None) is None
assert parse_gdacs_bbox("") is None
assert parse_gdacs_bbox("not numbers") is None
class TestGDACSAdapter:
def test_class_attrs_complete(self):
from central.adapters.gdacs import GDACSAdapter, GDACSSettings
assert GDACSAdapter.name == "gdacs"
assert isinstance(GDACSAdapter.display_name, str) and GDACSAdapter.display_name
assert isinstance(GDACSAdapter.description, str) and GDACSAdapter.description
assert GDACSAdapter.settings_schema is GDACSSettings
assert GDACSAdapter.requires_api_key is None
assert GDACSAdapter.api_key_field is None
assert GDACSAdapter.wizard_order is None
assert GDACSAdapter.default_cadence_s == 600
@pytest.mark.asyncio
async def test_normalization_basic_wf(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
events: list[Event] = [e async for e in adapter.poll()]
await adapter.shutdown()
# WF + DR should yield; EQ + XX filtered.
assert len(events) == 2
wf = next(e for e in events if e.data["eventtype"] == "WF")
assert wf.adapter == "gdacs"
assert wf.category == "disaster.wf"
assert wf.id == "WF2002001"
assert wf.severity == 1 # Green
assert wf.data["country"] == "Greece"
assert wf.data["iso3"] == "GRC"
assert wf.geo.centroid == (23.7, 38.0)
assert wf.geo.bbox == (21.7, 36.0, 25.7, 40.0)
assert wf.geo.primary_region == "GRC"
assert wf.geo.regions == ["GRC"]
dr = next(e for e in events if e.data["eventtype"] == "DR")
assert dr.severity == 2 # Orange
assert dr.category == "disaster.dr"
assert dr.data["iso3"] == "USA"
@pytest.mark.asyncio
async def test_eq_filtered_by_default(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
events = [e async for e in adapter.poll()]
await adapter.shutdown()
# No EQ in default allowlist; EQ1541360 must not appear.
assert all(e.id != "EQ1541360" for e in events)
assert all(e.data["eventtype"] != "EQ" for e in events)
@pytest.mark.asyncio
async def test_unknown_eventtype_filtered(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
events = [e async for e in adapter.poll()]
await adapter.shutdown()
assert all(e.data["eventtype"] != "XX" for e in events)
@pytest.mark.asyncio
async def test_settings_event_types_override(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(
_config({"event_types": ["EQ"]}),
MagicMock(),
tmp_path / "cursors.db",
)
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
events = [e async for e in adapter.poll()]
await adapter.shutdown()
# Only EQ should yield now.
assert len(events) == 1
assert events[0].id == "EQ1541360"
assert events[0].category == "disaster.eq"
@pytest.mark.asyncio
async def test_dedup_by_guid(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
first_pass = [e async for e in adapter.poll()]
second_pass = [e async for e in adapter.poll()]
await adapter.shutdown()
assert len(first_pass) == 2
assert len(second_pass) == 0
@pytest.mark.asyncio
async def test_fall_off_iscurrent_false(self, tmp_path: Path):
"""Item seen iscurrent=true then iscurrent=false -> tombstone."""
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
first_pass = [e async for e in adapter.poll()]
assert any(e.id == "WF2002001" for e in first_pass)
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS_WF_RETIRED)
second_pass = [e async for e in adapter.poll()]
await adapter.shutdown()
tombstones = [e for e in second_pass if e.category.endswith(".removed")]
assert len(tombstones) == 1
ts = tombstones[0]
assert ts.id == "WF2002001:removed"
assert ts.category == "disaster.wf.removed"
assert ts.data["reason"] == "iscurrent_false"
# Subject form: central.disaster..removed.
assert adapter.subject_for(ts) == "central.disaster.wf.removed.greece"
@pytest.mark.asyncio
async def test_fall_off_missing_from_feed(self, tmp_path: Path):
"""Item seen, then completely missing from feed -> tombstone."""
from central.adapters.gdacs import GDACSAdapter
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS)
await adapter.startup()
_ = [e async for e in adapter.poll()]
adapter._fetch = AsyncMock(return_value=SAMPLE_RSS_WF_MISSING)
second_pass = [e async for e in adapter.poll()]
await adapter.shutdown()
tombstones = [e for e in second_pass if e.category.endswith(".removed")]
assert len(tombstones) == 1
assert tombstones[0].id == "WF2002001:removed"
assert tombstones[0].category == "disaster.wf.removed"
assert tombstones[0].data["reason"] == "missing_from_feed"
@pytest.mark.asyncio
async def test_subject_for_returns_country_path(self, tmp_path: Path):
from central.adapters.gdacs import GDACSAdapter
from central.models import Geo
adapter = GDACSAdapter(_config(), MagicMock(), tmp_path / "cursors.db")
event = Event(
id="WF2002001",
adapter="gdacs",
category="disaster.wf",
time=datetime(2026, 5, 18, 11, tzinfo=timezone.utc),
severity=1,
geo=Geo(),
data={"eventtype": "WF", "country": "Greece"},
)
assert adapter.subject_for(event) == "central.disaster.wf.greece"
event_unknown = Event(
id="DR1",
adapter="gdacs",
category="disaster.dr",
time=datetime(2026, 5, 18, tzinfo=timezone.utc),
severity=0,
geo=Geo(),
data={"eventtype": "DR", "country": None},
)
assert adapter.subject_for(event_unknown) == "central.disaster.dr.unknown"