"""Tests for USGS earthquake adapter.""" import pytest from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch from pathlib import Path import tempfile from central.adapters.usgs_quake import ( USGSQuakeAdapter, magnitude_tier, magnitude_to_severity, ) from central.config_models import AdapterConfig, RegionConfig from central.models import Event, Geo # Sample USGS GeoJSON response SAMPLE_GEOJSON = { "type": "FeatureCollection", "metadata": { "generated": 1715878800000, "url": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_hour.geojson", "title": "USGS All Earthquakes, Past Hour", "status": 200, "api": "1.10.3", "count": 3 }, "features": [ { "type": "Feature", "properties": { "mag": 2.5, "place": "10km N of Boise, Idaho", "time": 1715878500000, "updated": 1715878600000, "tz": None, "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us1234", "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us1234.geojson", "felt": None, "cdi": None, "mmi": None, "alert": None, "status": "automatic", "tsunami": 0, "sig": 100, "net": "us", "code": "1234", "ids": ",us1234,", "sources": ",us,", "types": ",origin,", "nst": 10, "dmin": 0.5, "rms": 0.3, "gap": 100, "magType": "ml", "type": "earthquake", "title": "M 2.5 - 10km N of Boise, Idaho" }, "geometry": { "type": "Point", "coordinates": [-116.2, 43.7, 10.5] }, "id": "us1234" }, { "type": "Feature", "properties": { "mag": 4.5, "place": "20km S of Portland, Oregon", "time": 1715878400000, "updated": 1715878500000, "tz": None, "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us5678", "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us5678.geojson", "felt": 50, "cdi": 4.0, "mmi": 3.5, "alert": "green", "status": "reviewed", "tsunami": 0, "sig": 300, "net": "us", "code": "5678", "ids": ",us5678,", "sources": ",us,", "types": ",origin,shakemap,", "nst": 25, "dmin": 0.2, "rms": 0.2, "gap": 50, "magType": "mw", "type": "earthquake", "title": "M 4.5 - 20km S of Portland, Oregon" }, "geometry": { "type": "Point", "coordinates": [-122.6, 45.3, 15.0] }, "id": "us5678" }, { "type": "Feature", "properties": { "mag": 3.0, "place": "50km E of San Francisco, California", "time": 1715878300000, "updated": 1715878400000, "tz": None, "url": "https://earthquake.usgs.gov/earthquakes/eventpage/us9999", "detail": "https://earthquake.usgs.gov/earthquakes/feed/v1.0/detail/us9999.geojson", "felt": None, "cdi": None, "mmi": None, "alert": None, "status": "automatic", "tsunami": 0, "sig": 150, "net": "us", "code": "9999", "ids": ",us9999,", "sources": ",us,", "types": ",origin,", "nst": 15, "dmin": 0.3, "rms": 0.25, "gap": 80, "magType": "ml", "type": "earthquake", "title": "M 3.0 - 50km E of San Francisco, California" }, "geometry": { "type": "Point", "coordinates": [-121.5, 37.8, 8.0] }, "id": "us9999" } ] } # Sample with null magnitude SAMPLE_NULL_MAG = { "type": "FeatureCollection", "metadata": {"count": 1}, "features": [ { "type": "Feature", "properties": { "mag": None, "place": "Quarry blast", "time": 1715878500000, "type": "quarry blast" }, "geometry": { "type": "Point", "coordinates": [-116.0, 44.0, 0.0] }, "id": "usquarry1" } ] } def make_adapter_config( region: dict | None = None, feed: str = "all_hour", ) -> AdapterConfig: """Create an AdapterConfig for testing.""" settings = {"feed": feed} if region: settings["region"] = region else: settings["region"] = { "north": 49.5, "south": 40.0, "east": -110.0, "west": -125.0, } return AdapterConfig( name="usgs_quake", enabled=True, cadence_s=60, settings=settings, updated_at=datetime.now(timezone.utc), ) @pytest.fixture def temp_db_path(): """Create a temporary database path for testing.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: yield Path(f.name) @pytest.fixture def mock_config_store(): """Create a mock ConfigStore.""" return MagicMock() class TestMagnitudeTier: """Test magnitude tier classification.""" def test_minor(self): assert magnitude_tier(0.5) == "minor" assert magnitude_tier(2.9) == "minor" def test_light(self): assert magnitude_tier(3.0) == "light" assert magnitude_tier(3.9) == "light" def test_moderate(self): assert magnitude_tier(4.0) == "moderate" assert magnitude_tier(4.9) == "moderate" def test_strong(self): assert magnitude_tier(5.0) == "strong" assert magnitude_tier(5.9) == "strong" def test_major(self): assert magnitude_tier(6.0) == "major" assert magnitude_tier(6.9) == "major" def test_great(self): assert magnitude_tier(7.0) == "great" assert magnitude_tier(9.5) == "great" class TestMagnitudeToSeverity: """Test magnitude to severity mapping.""" def test_severity_levels(self): assert magnitude_to_severity(2.0) == 0 assert magnitude_to_severity(3.5) == 1 assert magnitude_to_severity(4.5) == 2 assert magnitude_to_severity(5.5) == 3 assert magnitude_to_severity(6.5) == 4 assert magnitude_to_severity(7.5) == 5 class TestRegionFiltering: """Test region/bbox filtering.""" @pytest.mark.asyncio async def test_filters_out_of_bbox(self, temp_db_path, mock_config_store): """Test that quakes outside bbox are filtered.""" # Region covers PNW only (north of 40, west of -110) config = make_adapter_config( region={"north": 49.5, "south": 40.0, "east": -110.0, "west": -125.0} ) adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_GEOJSON events = [] async for event in adapter.poll(): events.append(event) # us1234 (Boise) and us5678 (Portland) are in bbox # us9999 (SF, lat 37.8) is outside bbox (south < 40) assert len(events) == 2 event_ids = {e.id for e in events} assert "us1234" in event_ids assert "us5678" in event_ids assert "us9999" not in event_ids await adapter.shutdown() class TestDeduplication: """Test deduplication logic.""" @pytest.mark.asyncio async def test_dedup_marks_published(self, temp_db_path, mock_config_store): config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() event_id = "us1234" assert not adapter.is_published(event_id) adapter.mark_published(event_id) assert adapter.is_published(event_id) await adapter.shutdown() @pytest.mark.asyncio async def test_second_poll_no_duplicates(self, temp_db_path, mock_config_store): """Test that second poll with same events yields nothing.""" config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_GEOJSON # First poll events1 = [] async for event in adapter.poll(): events1.append(event) # Second poll - same data events2 = [] async for event in adapter.poll(): events2.append(event) # First poll should have events (2 in bbox) assert len(events1) == 2 # Second poll should have 0 (all deduped) assert len(events2) == 0 await adapter.shutdown() class TestNullMagnitude: """Test handling of null magnitude events.""" @pytest.mark.asyncio async def test_skips_null_magnitude(self, temp_db_path, mock_config_store): """Test that events with null magnitude are skipped.""" config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_NULL_MAG events = [] async for event in adapter.poll(): events.append(event) # Should skip the null-magnitude event assert len(events) == 0 await adapter.shutdown() class TestEventGeneration: """Test Event generation from features.""" @pytest.mark.asyncio async def test_event_category(self, temp_db_path, mock_config_store): config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_GEOJSON events = [] async for event in adapter.poll(): events.append(event) # Check categories categories = {e.category for e in events} # us1234 is M2.5 -> minor, us5678 is M4.5 -> moderate assert "quake.event.minor" in categories assert "quake.event.moderate" in categories await adapter.shutdown() @pytest.mark.asyncio async def test_event_severity(self, temp_db_path, mock_config_store): config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_GEOJSON events = [] async for event in adapter.poll(): events.append(event) # Find events by ID events_by_id = {e.id: e for e in events} # M2.5 -> severity 0 assert events_by_id["us1234"].severity == 0 # M4.5 -> severity 2 assert events_by_id["us5678"].severity == 2 await adapter.shutdown() @pytest.mark.asyncio async def test_event_geo(self, temp_db_path, mock_config_store): config = make_adapter_config() adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() with patch.object(adapter, "_fetch_geojson", new_callable=AsyncMock) as mock_fetch: mock_fetch.return_value = SAMPLE_GEOJSON events = [] async for event in adapter.poll(): events.append(event) events_by_id = {e.id: e for e in events} # Check Boise quake coordinates boise = events_by_id["us1234"] assert boise.geo.centroid == (-116.2, 43.7) await adapter.shutdown() class TestApplyConfig: """Test hot-reload configuration application.""" @pytest.mark.asyncio async def test_apply_config_updates_region(self, temp_db_path, mock_config_store): config = make_adapter_config( region={"north": 49.5, "south": 40.0, "east": -110.0, "west": -125.0} ) adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() assert adapter.region.north == 49.5 new_config = make_adapter_config( region={"north": 48.0, "south": 45.0, "east": -115.0, "west": -125.0} ) await adapter.apply_config(new_config) assert adapter.region.north == 48.0 assert adapter.region.south == 45.0 await adapter.shutdown() @pytest.mark.asyncio async def test_apply_config_updates_feed(self, temp_db_path, mock_config_store): config = make_adapter_config(feed="all_hour") adapter = USGSQuakeAdapter( config=config, config_store=mock_config_store, cursor_db_path=temp_db_path, ) await adapter.startup() assert adapter._feed == "all_hour" new_config = make_adapter_config(feed="all_day") await adapter.apply_config(new_config) assert adapter._feed == "all_day" await adapter.shutdown()