diff --git a/akd_ext/structures.py b/akd_ext/structures.py index 595ed9b..363f41e 100644 --- a/akd_ext/structures.py +++ b/akd_ext/structures.py @@ -22,3 +22,29 @@ class SDEIndexedDocumentType(StrEnum): DOCUMENTATION = "Documentation" SOFTWARE_TOOLS = "Software and Tools" MISSIONS_INSTRUMENTS = "Missions and Instruments" + + +class EONETCategory(StrEnum): + """EONET v3 event categories. Values match the IDs accepted by the EONET API.""" + + DROUGHT = "drought" + DUST_HAZE = "dustHaze" + EARTHQUAKES = "earthquakes" + FLOODS = "floods" + LANDSLIDES = "landslides" + MANMADE = "manmade" + SEA_LAKE_ICE = "seaLakeIce" + SEVERE_STORMS = "severeStorms" + SNOW = "snow" + TEMP_EXTREMES = "tempExtremes" + VOLCANOES = "volcanoes" + WATER_COLOR = "waterColor" + WILDFIRES = "wildfires" + + +class EONETStatus(StrEnum): + """EONET event lifecycle status filter.""" + + OPEN = "open" + CLOSED = "closed" + ALL = "all" diff --git a/akd_ext/tools/__init__.py b/akd_ext/tools/__init__.py index fb1328e..917de7e 100644 --- a/akd_ext/tools/__init__.py +++ b/akd_ext/tools/__init__.py @@ -1,6 +1,16 @@ """Tools module for akd_ext.""" from .dummy import DummyInputSchema, DummyOutputSchema, DummyTool +from .eonet import ( + EONETCategoryRef, + EONETEvent, + EONETGeometry, + EONETSearchInputSchema, + EONETSearchOutputSchema, + EONETSearchTool, + EONETSearchToolConfig, + EONETSource, +) from .sde_search import ( SDEDocument, SDESearchTool, @@ -25,6 +35,14 @@ "DummyTool", "DummyInputSchema", "DummyOutputSchema", + "EONETSearchTool", + "EONETSearchInputSchema", + "EONETSearchOutputSchema", + "EONETSearchToolConfig", + "EONETEvent", + "EONETGeometry", + "EONETCategoryRef", + "EONETSource", "SDESearchTool", "SDESearchToolInputSchema", "SDESearchToolOutputSchema", diff --git a/akd_ext/tools/eonet.py b/akd_ext/tools/eonet.py new file mode 100644 index 0000000..085c5ac --- /dev/null +++ b/akd_ext/tools/eonet.py @@ -0,0 +1,358 @@ +""" +NASA EONET (Earth Observatory Natural Event Tracker) v3 search tool. + +This tool wraps the EONET v3 /events endpoint to enable filtering natural events +(wildfires, severe storms, volcanoes, floods, earthquakes, sea/lake ice, +landslides, etc.) by category, lifecycle status, time range, geographic bounding +box, and magnitude. Each returned event carries its full geometry timeline, +upstream source provenance, and derived spatiotemporal envelopes (bbox, t_start, +t_end) that downstream tools (CMR queries, Worldview deep links) can consume +directly. + +EONET aggregates event metadata from authoritative providers (USGS, JTWC, +InciWeb, SI Volcano, etc.). The API is public and requires no authentication. +See https://eonet.gsfc.nasa.gov/docs/v3. +""" + +import os +from collections.abc import Iterable +from datetime import date, datetime +from typing import Any, Literal + +import httpx +from akd._base import InputSchema, OutputSchema +from akd.tools import BaseTool, BaseToolConfig +from loguru import logger +from pydantic import BaseModel, Field, model_validator + +from akd_ext.mcp import mcp_tool +from akd_ext.structures import EONETCategory, EONETStatus + + +class EONETSearchToolConfig(BaseToolConfig): + """Instance-level configuration for EONETSearchTool.""" + + base_url: str = Field( + default=os.getenv("EONET_BASE_URL", "https://eonet.gsfc.nasa.gov/api/v3"), + description="Base URL for the EONET v3 API.", + ) + timeout: float = Field( + default=30.0, + description="HTTP request timeout in seconds.", + ) + sources: list[str] | None = Field( + default=None, + description=( + "Optional allowlist of EONET source IDs (e.g., ['InciWeb', 'USGS_EHP']). " + "If set, all queries are scoped to these upstream sources." + ), + ) + + +class EONETSource(BaseModel): + """An upstream data provider entry attached to an EONET event.""" + + id: str = Field(..., description="Source ID (e.g., 'InciWeb', 'USGS_EHP', 'JTWC').") + url: str = Field(..., description="Upstream provider URL for this event (provenance).") + + +class EONETCategoryRef(BaseModel): + """A category assignment on an EONET event.""" + + id: str = Field(..., description="Category ID (e.g., 'wildfires').") + title: str = Field(..., description="Human-readable category title (e.g., 'Wildfires').") + + +class EONETGeometry(BaseModel): + """A single time-stamped geometry observation for an EONET event.""" + + date: datetime = Field(..., description="Timestamp of this geometry observation (UTC).") + type: Literal["Point", "Polygon"] = Field(..., description="GeoJSON geometry type.") + coordinates: list[Any] = Field( + ..., + description="GeoJSON coordinates. Point: [lon, lat]. Polygon: list of linear rings.", + ) + magnitude_value: float | None = Field( + default=None, + description="Magnitude scalar at this timestamp, if reported by the source.", + ) + magnitude_unit: str | None = Field( + default=None, + description="Magnitude unit (e.g., 'kts', 'acres'), if reported.", + ) + + +class EONETEvent(BaseModel): + """A single natural event record from EONET v3 with derived spatiotemporal helpers.""" + + id: str = Field(..., description="EONET event ID (e.g., 'EONET_19986').") + title: str = Field(..., description="Event title.") + description: str | None = Field(default=None, description="Event description (often empty).") + link: str = Field(..., description="EONET event landing-page URL.") + closed: datetime | None = Field( + default=None, + description="Closure timestamp; None if the event is still open.", + ) + categories: list[EONETCategoryRef] = Field( + default_factory=list, + description="Event categories.", + ) + sources: list[EONETSource] = Field( + default_factory=list, + description="Upstream sources for provenance.", + ) + geometry: list[EONETGeometry] = Field( + default_factory=list, + description="Time-stamped geometries for this event.", + ) + + bbox: list[float] | None = Field( + default=None, + min_length=4, + max_length=4, + description="Derived envelope across all geometries in standard (min_lon, min_lat, max_lon, max_lat) order.", + ) + t_start: datetime | None = Field( + default=None, + description="Earliest geometry timestamp (UTC).", + ) + t_end: datetime | None = Field( + default=None, + description="Latest geometry timestamp (UTC).", + ) + + +class EONETSearchInputSchema(InputSchema): + """Filters for an EONET v3 event search.""" + + category: EONETCategory | None = Field( + default=None, + description="Event category to filter on. Omit for all categories.", + ) + status: EONETStatus = Field( + default=EONETStatus.OPEN, + description="Event lifecycle: 'open' (active), 'closed' (ended), or 'all'.", + ) + days: int | None = Field( + default=None, + ge=1, + le=365, + description="Restrict to events updated in the last N days. Mutually exclusive with start/end.", + ) + start: date | None = Field( + default=None, + description="Start date (YYYY-MM-DD). Use with 'end'. Mutually exclusive with 'days'.", + ) + end: date | None = Field( + default=None, + description="End date (YYYY-MM-DD). Use with 'start'. Mutually exclusive with 'days'.", + ) + bbox: list[float] | None = Field( + default=None, + min_length=4, + max_length=4, + description=( + "Bounding box in standard (min_lon, min_lat, max_lon, max_lat) order. " + "Translated to EONET's (minLon, maxLat, maxLon, minLat) request format internally." + ), + ) + limit: int = Field( + default=10, + ge=1, + le=100, + description="Maximum number of events to return.", + ) + magnitude_id: str | None = Field( + default=None, + description="Magnitude type ID (e.g., 'kts' wind speed, 'ac' acres). See EONET /magnitudes.", + ) + magnitude_min: float | None = Field( + default=None, + description="Minimum magnitude value. Requires magnitude_id.", + ) + magnitude_max: float | None = Field( + default=None, + description="Maximum magnitude value. Requires magnitude_id.", + ) + + @model_validator(mode="after") + def _validate_combinations(self) -> "EONETSearchInputSchema": + if self.days is not None and (self.start is not None or self.end is not None): + raise ValueError("Use either 'days' or ('start' and 'end'), not both.") + if (self.start is None) != (self.end is None): + raise ValueError("'start' and 'end' must be provided together.") + if self.start and self.end and self.start > self.end: + raise ValueError("'start' must be on or before 'end'.") + if self.bbox is not None: + min_lon, min_lat, max_lon, max_lat = self.bbox + if not (-180.0 <= min_lon <= 180.0 and -180.0 <= max_lon <= 180.0): + raise ValueError("Longitudes must be in [-180, 180].") + if not (-90.0 <= min_lat <= 90.0 and -90.0 <= max_lat <= 90.0): + raise ValueError("Latitudes must be in [-90, 90].") + if min_lon > max_lon or min_lat > max_lat: + raise ValueError("bbox must be (min_lon, min_lat, max_lon, max_lat) with min <= max.") + if (self.magnitude_min is not None or self.magnitude_max is not None) and self.magnitude_id is None: + raise ValueError("magnitude_id is required when magnitude_min or magnitude_max is set.") + return self + + +class EONETSearchOutputSchema(OutputSchema): + """Output schema for EONET event search results.""" + + results: list[EONETEvent] = Field( + default_factory=list, + description="Matching natural events.", + ) + extra: dict[str, Any] | None = Field( + default=None, + description="Auxiliary metadata: total_count, request_url, params_echo.", + ) + + +def _flatten_positions(coords: Any) -> Iterable[tuple[float, float]]: + """Yield (lon, lat) pairs from any GeoJSON coordinate structure (Point/Polygon/nested).""" + if not coords: + return + if isinstance(coords[0], (int, float)): + yield float(coords[0]), float(coords[1]) + return + for sub in coords: + yield from _flatten_positions(sub) + + +def _compute_bbox(geometries: list[EONETGeometry]) -> list[float] | None: + """Compute [min_lon, min_lat, max_lon, max_lat] across all geometries; None if empty.""" + lons: list[float] = [] + lats: list[float] = [] + for g in geometries: + for lon, lat in _flatten_positions(g.coordinates): + lons.append(lon) + lats.append(lat) + if not lons: + return None + return [min(lons), min(lats), max(lons), max(lats)] + + +@mcp_tool +class EONETSearchTool(BaseTool[EONETSearchInputSchema, EONETSearchOutputSchema]): + """ + Search NASA's EONET (Earth Observatory Natural Event Tracker) v3 for natural events. + + EONET tracks ongoing and past natural events worldwide — wildfires, severe storms, + volcanoes, floods, earthquakes, sea/lake ice, landslides, drought, dust/haze, + snow, temperature extremes, water color anomalies, and manmade events. Event + metadata is sourced from authoritative providers (USGS, JTWC, InciWeb, + SI Volcano, etc.). The API is public; no authentication required. + + This tool wraps the GET /events endpoint and returns parsed events with their + full geometry timeline plus derived helpers — bbox (in standard GeoJSON order), + t_start, and t_end — so downstream tools (CMR queries, Worldview deep links) + can consume the spatiotemporal envelope without parsing GeoJSON. + + Input parameters (LLM-controllable per call): + - category: EONETCategory enum (wildfires, severeStorms, volcanoes, etc.). Omit for all. + - status: 'open' (default), 'closed', or 'all'. + - days: Restrict to events updated in the last N days (1-365). Mutually exclusive with start/end. + - start, end: Explicit date range (YYYY-MM-DD). Both required together. + - bbox: (min_lon, min_lat, max_lon, max_lat) — standard GeoJSON order; tool converts internally. + - limit: Max number of events (1-100, default 10). + - magnitude_id, magnitude_min, magnitude_max: Magnitude filtering (see EONET /magnitudes). + + Configuration parameters (instance-level): + - base_url: EONET API base. Defaults to env EONET_BASE_URL or production. + - timeout: HTTP request timeout (default 30s). + - sources: Optional allowlist of upstream source IDs (e.g., ['InciWeb', 'USGS_EHP']). + + Returns events with: + - id, title, description, link, closed (timestamp or None) + - categories (id+title), sources (id+url for provenance) + - geometry: list of time-stamped Point/Polygon observations with optional magnitude + - Derived: bbox in (min_lon, min_lat, max_lon, max_lat) order, t_start, t_end + """ + + input_schema = EONETSearchInputSchema + output_schema = EONETSearchOutputSchema + config_schema = EONETSearchToolConfig + + def _build_params(self, params: EONETSearchInputSchema) -> dict[str, str]: + """Build EONET v3 query string parameters from validated input.""" + out: dict[str, str] = {"status": params.status.value, "limit": str(params.limit)} + if params.category is not None: + out["category"] = params.category.value + if params.days is not None: + out["days"] = str(params.days) + if params.start is not None and params.end is not None: + out["start"] = params.start.isoformat() + out["end"] = params.end.isoformat() + if params.bbox is not None: + min_lon, min_lat, max_lon, max_lat = params.bbox + # EONET expects (minLon, maxLat, maxLon, minLat) — top-left, bottom-right. + out["bbox"] = f"{min_lon},{max_lat},{max_lon},{min_lat}" + if params.magnitude_id is not None: + out["magID"] = params.magnitude_id + if params.magnitude_min is not None: + out["magMin"] = str(params.magnitude_min) + if params.magnitude_max is not None: + out["magMax"] = str(params.magnitude_max) + if self.config.sources: + out["source"] = ",".join(self.config.sources) + return out + + def _parse_geometry(self, raw: dict[str, Any]) -> EONETGeometry: + return EONETGeometry( + date=raw["date"], + type=raw["type"], + coordinates=raw.get("coordinates", []), + magnitude_value=raw.get("magnitudeValue"), + magnitude_unit=raw.get("magnitudeUnit"), + ) + + def _parse_event(self, raw: dict[str, Any]) -> EONETEvent: + geometries = [self._parse_geometry(g) for g in raw.get("geometry", []) or []] + timestamps = [g.date for g in geometries] + return EONETEvent( + id=raw["id"], + title=raw.get("title", ""), + description=raw.get("description"), + link=raw.get("link", ""), + closed=raw.get("closed"), + categories=[EONETCategoryRef(**c) for c in raw.get("categories", []) or []], + sources=[EONETSource(**s) for s in raw.get("sources", []) or []], + geometry=geometries, + bbox=_compute_bbox(geometries), + t_start=min(timestamps) if timestamps else None, + t_end=max(timestamps) if timestamps else None, + ) + + async def _arun(self, params: EONETSearchInputSchema) -> EONETSearchOutputSchema: + """Execute an EONET v3 /events query and return parsed results.""" + query_params = self._build_params(params) + url = f"{self.config.base_url.rstrip('/')}/events" + logger.debug(f"EONET request: {url} params={query_params}") + + async with httpx.AsyncClient(timeout=self.config.timeout) as client: + try: + response = await client.get(url, params=query_params) + response.raise_for_status() + data = response.json() + except httpx.TimeoutException as e: + msg = f"EONET API request timed out after {self.config.timeout}s" + raise TimeoutError(msg) from e + except httpx.HTTPStatusError as e: + msg = f"EONET API returned status {e.response.status_code}: {e.response.text}" + raise RuntimeError(msg) from e + except Exception as e: + msg = f"Failed to query EONET API: {e}" + raise RuntimeError(msg) from e + + raw_events = data.get("events", []) or [] + events = [self._parse_event(ev) for ev in raw_events] + + return EONETSearchOutputSchema( + results=events, + extra={ + "total_count": len(events), + "request_url": str(response.request.url), + "params_echo": query_params, + }, + ) diff --git a/tests/tools/test_eonet.py b/tests/tools/test_eonet.py new file mode 100644 index 0000000..ca3c94c --- /dev/null +++ b/tests/tools/test_eonet.py @@ -0,0 +1,270 @@ +"""Tests for the EONET Search Tool.""" + +from datetime import date, datetime, timedelta, timezone + +import pytest +from akd_ext.structures import EONETCategory, EONETStatus +from akd_ext.tools import ( + EONETEvent, + EONETSearchInputSchema, + EONETSearchTool, + EONETSearchToolConfig, +) + + +# ── Unit tests: input schema validation (no network) ──────────────────────── + + +@pytest.mark.unit +def test_input_rejects_days_with_start_end(): + """days and explicit start/end are mutually exclusive.""" + with pytest.raises(ValueError, match="Use either 'days' or"): + EONETSearchInputSchema(days=7, start=date(2026, 1, 1), end=date(2026, 1, 31)) + + +@pytest.mark.unit +def test_input_requires_paired_start_end(): + """start without end (and vice-versa) is invalid.""" + with pytest.raises(ValueError, match="must be provided together"): + EONETSearchInputSchema(start=date(2026, 1, 1)) + with pytest.raises(ValueError, match="must be provided together"): + EONETSearchInputSchema(end=date(2026, 1, 31)) + + +@pytest.mark.unit +def test_input_rejects_start_after_end(): + """start must be on or before end.""" + with pytest.raises(ValueError, match="must be on or before"): + EONETSearchInputSchema(start=date(2026, 2, 1), end=date(2026, 1, 1)) + + +@pytest.mark.unit +def test_input_rejects_bbox_out_of_range(): + """bbox lon/lat must be valid Earth coordinates.""" + with pytest.raises(ValueError, match="Longitudes must be in"): + EONETSearchInputSchema(bbox=(200.0, 0.0, 210.0, 10.0)) + with pytest.raises(ValueError, match="Latitudes must be in"): + EONETSearchInputSchema(bbox=(0.0, -100.0, 10.0, 10.0)) + + +@pytest.mark.unit +def test_input_rejects_inverted_bbox(): + """min must be <= max for both axes.""" + with pytest.raises(ValueError, match="min_lon, min_lat, max_lon, max_lat"): + EONETSearchInputSchema(bbox=(10.0, 0.0, -10.0, 5.0)) + + +@pytest.mark.unit +def test_input_magnitude_requires_id(): + """magnitude_min/max are unusable without a magnitude_id.""" + with pytest.raises(ValueError, match="magnitude_id is required"): + EONETSearchInputSchema(magnitude_min=10.0) + with pytest.raises(ValueError, match="magnitude_id is required"): + EONETSearchInputSchema(magnitude_max=100.0) + + +@pytest.mark.unit +def test_input_accepts_valid_combinations(): + """Sanity-check that legal combinations parse cleanly.""" + # Just days + EONETSearchInputSchema(category=EONETCategory.WILDFIRES, days=30, limit=5) + # Date range + EONETSearchInputSchema(start=date(2026, 1, 1), end=date(2026, 2, 1)) + # bbox + magnitude + EONETSearchInputSchema( + bbox=(-130.0, 20.0, -60.0, 50.0), + magnitude_id="kts", + magnitude_min=34.0, + ) + + +@pytest.mark.unit +def test_tool_metadata(): + """Tool name auto-derives from class name; description comes from docstring.""" + tool = EONETSearchTool() + assert tool.name == "eonet_search_tool" + assert tool.description + assert "EONET" in tool.description + + +# ── Integration tests: live EONET API ──────────────────────────────────────── + + +@pytest.mark.integration +async def test_basic_open_events(): + """Default open-events query returns parsed events with required fields.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(days=30, limit=5)) + + assert result.results is not None + assert isinstance(result.results, list) + assert len(result.results) <= 5 + + for ev in result.results: + assert isinstance(ev, EONETEvent) + assert ev.id.startswith("EONET_") + assert ev.title + assert ev.link.startswith("https://eonet.gsfc.nasa.gov/") + assert ev.categories, f"Event {ev.id} has no categories" + assert ev.sources, f"Event {ev.id} has no sources (provenance missing)" + assert ev.geometry, f"Event {ev.id} has no geometry" + + assert result.extra is not None + assert "request_url" in result.extra + assert "params_echo" in result.extra + + +@pytest.mark.integration +async def test_category_filter_wildfires(): + """category=wildfires returns only wildfire events.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(category=EONETCategory.WILDFIRES, days=60, limit=10)) + + assert result.results is not None + for ev in result.results: + category_ids = [c.id for c in ev.categories] + assert "wildfires" in category_ids, f"Event {ev.id} has categories {category_ids}" + + +@pytest.mark.integration +async def test_status_closed_events(): + """status=closed returns only events that have a closure timestamp.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(status=EONETStatus.CLOSED, days=90, limit=5)) + + assert result.results is not None + for ev in result.results: + assert ev.closed is not None, f"Event {ev.id} marked closed but has no closed timestamp" + assert isinstance(ev.closed, datetime) + + +@pytest.mark.integration +async def test_status_all_events(): + """status=all is accepted and returns a mix (or at least events).""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(status=EONETStatus.ALL, days=30, limit=5)) + assert result.results is not None + + +@pytest.mark.integration +async def test_date_range_filter(): + """Explicit start/end is accepted and returns events with timestamps in range.""" + tool = EONETSearchTool() + end = date.today() + start = end - timedelta(days=60) + result = await tool.arun( + EONETSearchInputSchema( + status=EONETStatus.ALL, + start=start, + end=end, + limit=5, + ) + ) + + assert result.results is not None + # When events are returned, their geometry timestamps should overlap the requested window. + start_dt = datetime.combine(start, datetime.min.time(), tzinfo=timezone.utc) + end_dt = datetime.combine(end + timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc) + for ev in result.results: + assert ev.t_start is not None and ev.t_end is not None + # Geometry window should overlap requested window (>= start and <= end). + assert ev.t_end >= start_dt + assert ev.t_start <= end_dt + + +@pytest.mark.integration +async def test_bbox_filter_pacific(): + """A Pacific bbox should constrain returned event geometries to within (or overlapping) it.""" + tool = EONETSearchTool() + # Wide Pacific window — high chance of severe storms / volcanoes. + bbox = (100.0, -30.0, 180.0, 30.0) + result = await tool.arun(EONETSearchInputSchema(status=EONETStatus.OPEN, days=60, bbox=bbox, limit=5)) + + assert result.results is not None + # The API filters server-side. We verify each returned event's bbox overlaps our request. + req_min_lon, req_min_lat, req_max_lon, req_max_lat = bbox + for ev in result.results: + assert ev.bbox is not None, f"Event {ev.id} has no derived bbox" + ev_min_lon, ev_min_lat, ev_max_lon, ev_max_lat = ev.bbox + # Standard bbox overlap test + overlap = ( + ev_min_lon <= req_max_lon + and ev_max_lon >= req_min_lon + and ev_min_lat <= req_max_lat + and ev_max_lat >= req_min_lat + ) + assert overlap, f"Event {ev.id} bbox {ev.bbox} does not overlap request bbox {bbox}" + + +@pytest.mark.integration +async def test_limit_parameter_respected(): + """The 'limit' field caps the result count.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(status=EONETStatus.ALL, days=30, limit=3)) + assert result.results is not None + assert len(result.results) <= 3 + + +@pytest.mark.integration +async def test_derived_envelope_populated(): + """Derived bbox / t_start / t_end are populated and consistent with geometry.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(days=30, limit=5)) + + for ev in result.results: + assert ev.bbox is not None, f"Event {ev.id} should have a derived bbox" + assert ev.t_start is not None and ev.t_end is not None + assert ev.t_start <= ev.t_end + + # bbox should enclose every Point geometry's coordinates + min_lon, min_lat, max_lon, max_lat = ev.bbox + for g in ev.geometry: + if g.type == "Point": + lon, lat = g.coordinates[0], g.coordinates[1] + assert min_lon <= lon <= max_lon, f"Event {ev.id}: point lon {lon} outside derived bbox lon range" + assert min_lat <= lat <= max_lat, f"Event {ev.id}: point lat {lat} outside derived bbox lat range" + + +@pytest.mark.integration +async def test_no_results_for_narrow_window(): + """A tiny bbox over open ocean for a short window may return 0 results — must not error.""" + tool = EONETSearchTool() + # 0.1°-by-0.1° box in mid-Atlantic, 1-day window + result = await tool.arun( + EONETSearchInputSchema( + status=EONETStatus.OPEN, + days=1, + bbox=(-30.0, 0.0, -29.9, 0.1), + limit=5, + ) + ) + assert result.results is not None + assert isinstance(result.results, list) + + +@pytest.mark.integration +async def test_config_sources_filter(): + """Configuring sources= scopes the request to those upstream providers.""" + config = EONETSearchToolConfig(sources=["InciWeb"]) + tool = EONETSearchTool(config=config) + result = await tool.arun(EONETSearchInputSchema(category=EONETCategory.WILDFIRES, days=60, limit=5)) + + assert result.results is not None + # Every returned event should have at least one InciWeb source entry + for ev in result.results: + source_ids = [s.id for s in ev.sources] + assert "InciWeb" in source_ids, f"Event {ev.id} sources {source_ids} missing InciWeb" + + +@pytest.mark.integration +async def test_extra_metadata_shape(): + """`extra` carries total_count, request_url, and the echoed params.""" + tool = EONETSearchTool() + result = await tool.arun(EONETSearchInputSchema(days=14, limit=3)) + + assert result.extra is not None + assert result.extra.get("total_count") == len(result.results) + assert "events" in result.extra.get("request_url", "") + params = result.extra.get("params_echo", {}) + assert params.get("days") == "14" + assert params.get("limit") == "3"