From d8218a60a8d6edb4d0233c105907ecd7deb67008 Mon Sep 17 00:00:00 2001 From: Brian Stine Date: Wed, 2 Sep 2026 08:44:11 -0700 Subject: [PATCH 1/2] feat(alerts): add fetch_alerts_detailed with area_desc and region_filtered --- API.md | 33 +- CONTRACT.md | 43 ++ src/alerts.rs | 404 ++++++++++++++---- src/lib.rs | 4 +- .../snapshots/wire_contract__alert_entry.snap | 15 + .../wire_contract__alert_report.snap | 20 + tests/wire_contract.rs | 33 +- 7 files changed, 470 insertions(+), 82 deletions(-) create mode 100644 tests/snapshots/wire_contract__alert_entry.snap create mode 100644 tests/snapshots/wire_contract__alert_report.snap diff --git a/API.md b/API.md index 4d9ed70..819724d 100644 --- a/API.md +++ b/API.md @@ -118,13 +118,44 @@ JSON shapes for all public types are frozen in [CONTRACT.md](CONTRACT.md). ## Weather Alerts +### `fetch_alerts_detailed` + +```rust +pub async fn fetch_alerts_detailed(latitude: f64, longitude: f64) -> Result + +pub struct AlertReport { + pub alerts: Vec, + pub region_filtered: bool, +} + +pub struct AlertEntry { + pub alert: Alert, + pub area_desc: String, +} +``` + +The same dispatch as `fetch_alerts` below, plus two things a consumer cannot +otherwise know: + +- `area_desc` -- the provider's name for the area each entry covers. MeteoAlarm + `cap:areaDesc`, NWS `areaDesc` (a `;`-separated county list), the containing + ECCC polygon's `areaDesc`. BOM sends none, so it is `""`. +- `region_filtered` -- `false` only when a MeteoAlarm national feed was returned + without an EMMA_ID for the location, so the entries are national, not local. + NWS, ECCC and BOM filter by point, polygon and geohash, and an empty result is + trivially filtered, so all of those are `true`. + +When an EMMA_ID did resolve, MeteoAlarm entries carrying no geocode are dropped +rather than shown, since they cannot be placed in any region. + ### `fetch_alerts` ```rust pub async fn fetch_alerts(latitude: f64, longitude: f64) -> Result> ``` -Dispatches to the right provider based on location: +Thin wrapper over `fetch_alerts_detailed` that drops `area_desc` and the +filtering flag. Dispatches to the right provider based on location: | Region | Provider | Notes | |-----------|------------|-------------------------------------------------| diff --git a/CONTRACT.md b/CONTRACT.md index 21096c4..a951c60 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -132,6 +132,49 @@ EU standard: } ``` +### AlertEntry (array element of the detailed alerts payload) +```json +{ + "alert": { + "id": "NWS-IDP-PROD-123", + "event": "Severe Thunderstorm Warning", + "severity": "Severe", + "headline": "Severe thunderstorm until 10 PM EDT", + "description": "Wind gusts to 60 mph expected.", + "expires": "2026-06-11T22:00:00Z" + }, + "area_desc": "Warszawa" +} +``` + +`area_desc` is the provider's name for the area the entry covers; `""` where +the provider sends none (BOM), per the missing-strings rule. + +### AlertReport (the detailed alerts payload) +```json +{ + "alerts": [ + { + "alert": { + "id": "NWS-IDP-PROD-123", + "event": "Severe Thunderstorm Warning", + "severity": "Severe", + "headline": "Severe thunderstorm until 10 PM EDT", + "description": "Wind gusts to 60 mph expected.", + "expires": "2026-06-11T22:00:00Z" + }, + "area_desc": "Warszawa" + } + ], + "region_filtered": true +} +``` + +`region_filtered` is `false` only when a MeteoAlarm national feed was returned +without an EMMA_ID for the location, so the entries are national, not local. +NWS, ECCC and BOM filter by point, polygon and geohash, and an empty result is +trivially filtered, so all of those are `true`. + ### PollenData Inside CAMS European coverage: ```json diff --git a/src/alerts.rs b/src/alerts.rs index 3caacb7..4b55108 100644 --- a/src/alerts.rs +++ b/src/alerts.rs @@ -60,18 +60,52 @@ pub struct Alert { pub expires: DateTime, } -/// Fetches active weather alerts based on location. +/// One alert with the provider's name for the area it covers. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertEntry { + pub alert: Alert, + /// Provider's area name for this entry: MeteoAlarm `cap:areaDesc`, NWS + /// `areaDesc`, the containing ECCC polygon's `areaDesc`. Empty where the + /// provider sends none (BOM). + pub area_desc: String, +} + +/// What `fetch_alerts_detailed` returns: the alerts, and whether they were +/// narrowed to the caller's area. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertReport { + pub alerts: Vec, + /// `false` only when a MeteoAlarm national feed was returned without an + /// EMMA_ID for the location, so the entries are national, not local. + /// NWS, ECCC and BOM filter by point, polygon and geohash respectively, + /// and an empty result is trivially filtered, so all of those are `true`. + pub region_filtered: bool, +} + +/// Fetches active weather alerts based on location, with each entry's area +/// name and whether the list was narrowed to the caller's area. /// Dispatches to the appropriate regional API. -pub async fn fetch_alerts(latitude: f64, longitude: f64) -> Result> { +pub async fn fetch_alerts_detailed(latitude: f64, longitude: f64) -> Result { match detect_region(latitude, longitude) { Region::Us => fetch_nws_alerts(latitude, longitude).await, Region::Europe => fetch_meteoalarm_alerts(latitude, longitude).await, Region::Canada => fetch_eccc_alerts(latitude, longitude).await, Region::Australia => fetch_bom_alerts(latitude, longitude).await, - Region::Unknown => Ok(vec![]), + Region::Unknown => Ok(AlertReport { + alerts: vec![], + region_filtered: true, + }), } } +/// Fetches active weather alerts based on location. +/// Thin wrapper over `fetch_alerts_detailed` that drops the area names and +/// the filtering flag. +pub async fn fetch_alerts(latitude: f64, longitude: f64) -> Result> { + let report = fetch_alerts_detailed(latitude, longitude).await?; + Ok(report.alerts.into_iter().map(|entry| entry.alert).collect()) +} + // --------------------------------------------------------------------------- // NWS (United States) // --------------------------------------------------------------------------- @@ -97,10 +131,12 @@ struct NwsAlertProperties { description: Option, sent: String, expires: Option, + #[serde(rename = "areaDesc")] + area_desc: Option, } /// Fetches active weather alerts from the NWS API for US locations. -async fn fetch_nws_alerts(latitude: f64, longitude: f64) -> Result> { +async fn fetch_nws_alerts(latitude: f64, longitude: f64) -> Result { let url = format!( "https://api.weather.gov/alerts/active?point={},{}", latitude, longitude @@ -114,7 +150,10 @@ async fn fetch_nws_alerts(latitude: f64, longitude: f64) -> Result> { if !response.status().is_success() { tracing::warn!("NWS API returned status: {}", response.status()); - return Ok(vec![]); + return Ok(AlertReport { + alerts: vec![], + region_filtered: true, + }); } let data: NwsAlertsResponse = response.json().await?; @@ -122,11 +161,15 @@ async fn fetch_nws_alerts(latitude: f64, longitude: f64) -> Result> { let alerts = nws_alerts_from_response(data); tracing::debug!("Fetched {} alert(s) from NWS", alerts.len()); - Ok(alerts) + // The point query already narrowed the list to the caller's location. + Ok(AlertReport { + alerts, + region_filtered: true, + }) } /// Lives in its own function so it can be unit-tested against fixtures without a live network. -fn nws_alerts_from_response(data: NwsAlertsResponse) -> Vec { +fn nws_alerts_from_response(data: NwsAlertsResponse) -> Vec { data.features .into_iter() .filter_map(|feature| { @@ -147,17 +190,20 @@ fn nws_alerts_from_response(data: NwsAlertsResponse) -> Vec { return None; } - Some(Alert { - id: props.id, - event: props.event, - severity: props - .severity - .as_deref() - .map(AlertSeverity::from_cap_string) - .unwrap_or(AlertSeverity::Unknown), - headline: props.headline.unwrap_or_default(), - description: props.description.unwrap_or_default(), - expires, + Some(AlertEntry { + alert: Alert { + id: props.id, + event: props.event, + severity: props + .severity + .as_deref() + .map(AlertSeverity::from_cap_string) + .unwrap_or(AlertSeverity::Unknown), + headline: props.headline.unwrap_or_default(), + description: props.description.unwrap_or_default(), + expires, + }, + area_desc: props.area_desc.unwrap_or_default(), }) }) .collect() @@ -191,6 +237,8 @@ struct MeteoAlarmEntry { cap_expires: Option, #[serde(rename = "geocode")] cap_geocode: Option, + #[serde(rename = "areaDesc")] + cap_area_desc: Option, } /// Geocode element containing EMMA_ID area identifier. @@ -349,7 +397,7 @@ fn rank_emma_match(search_lower: &str, name_lower: &str) -> Option { } /// Fetches active weather alerts from MeteoAlarm for European locations. -async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result> { +async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result { // Failing to determine the country is an error, not an absence of alerts. // Returning Ok(vec![]) here would be indistinguishable from a quiet day, // which is the silent-failure pattern this whole path is being fixed for. @@ -382,7 +430,10 @@ async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result info, None => { tracing::debug!("{} ({}) is not covered by MeteoAlarm", country, iso_code); - return Ok(vec![]); + return Ok(AlertReport { + alerts: vec![], + region_filtered: true, + }); } }; @@ -396,7 +447,10 @@ async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result Result Option<&str> { + entry + .cap_geocode + .as_ref() + .and_then(|gc| gc.value.as_deref()) } /// Lives in its own function so it can be unit-tested against fixtures without a live network. -fn meteoalarm_alerts_from_feed(feed: MeteoAlarmFeed, user_emma_id: &Option) -> Vec { +fn meteoalarm_alerts_from_feed( + feed: MeteoAlarmFeed, + user_emma_id: &Option, +) -> Vec { + if let Some(user_id) = user_emma_id { + let untagged = feed + .entries + .iter() + .filter(|entry| entry_emma_id(entry).is_none()) + .count(); + if untagged > 0 { + tracing::debug!( + "Dropped {} untagged MeteoAlarm entr(y/ies) while filtering to {}", + untagged, + user_id + ); + } + } + feed.entries .into_iter() .filter_map(|entry| parse_meteoalarm_entry(entry, user_emma_id)) .collect() } -/// Parses a MeteoAlarm entry into an Alert. +/// Parses a MeteoAlarm entry into an AlertEntry. /// Returns None if it doesn't match the user's EMMA_ID or is expired. -fn parse_meteoalarm_entry(entry: MeteoAlarmEntry, user_emma_id: &Option) -> Option { +fn parse_meteoalarm_entry( + entry: MeteoAlarmEntry, + user_emma_id: &Option, +) -> Option { let now = Utc::now(); // Filter by EMMA_ID if we resolved one for the user if let Some(user_id) = user_emma_id { - let entry_emma_id = entry.cap_geocode.as_ref().and_then(|gc| gc.value.as_ref()); - - match entry_emma_id { + match entry_emma_id(&entry) { Some(entry_id) if entry_id != user_id => return None, + // An untagged entry cannot be shown to belong to the user's region; + // when a filter is active it is dropped, not leaked past it. + None => return None, _ => {} } } @@ -474,13 +561,16 @@ fn parse_meteoalarm_entry(entry: MeteoAlarmEntry, user_emma_id: &Option) .map(AlertSeverity::from_cap_string) .unwrap_or(AlertSeverity::Unknown); - Some(Alert { - id: entry.cap_identifier.unwrap_or(entry.id), - event, - severity, - headline, - description: String::new(), - expires, + Some(AlertEntry { + alert: Alert { + id: entry.cap_identifier.unwrap_or(entry.id), + event, + severity, + headline, + description: String::new(), + expires, + }, + area_desc: entry.cap_area_desc.unwrap_or_default(), }) } @@ -522,12 +612,12 @@ struct EcccCapArea { } /// Fetches active weather alerts from ECCC (Environment and Climate Change Canada). -async fn fetch_eccc_alerts(latitude: f64, longitude: f64) -> Result> { +async fn fetch_eccc_alerts(latitude: f64, longitude: f64) -> Result { let offices = get_eccc_office_codes(latitude, longitude); let today = chrono::Utc::now().format("%Y%m%d").to_string(); let client = http_client()?; - let mut all_alerts: Vec = Vec::new(); + let mut all_alerts: Vec = Vec::new(); let mut seen_ids: HashSet = HashSet::new(); for office in offices { @@ -614,12 +704,21 @@ async fn fetch_eccc_alerts(latitude: f64, longitude: f64) -> Result> } tracing::debug!("Fetched {} alert(s) from ECCC", all_alerts.len()); - Ok(all_alerts) + // Every entry passed the polygon check for the caller's point. + Ok(AlertReport { + alerts: all_alerts, + region_filtered: true, + }) } /// Parses an ECCC CAP XML document into an Alert. /// Filters by location using polygon containment and deduplicates by identifier. -fn parse_eccc_cap(xml: &str, lat: f64, lon: f64, seen_ids: &mut HashSet) -> Option { +fn parse_eccc_cap( + xml: &str, + lat: f64, + lon: f64, + seen_ids: &mut HashSet, +) -> Option { let cap: EcccCapAlert = quick_xml::de::from_str(xml).ok()?; if cap.status != "Actual" { @@ -686,17 +785,20 @@ fn parse_eccc_cap(xml: &str, lat: f64, lon: f64, seen_ids: &mut HashSet) let headline = info.headline.clone().unwrap_or_else(|| event.clone()); - Some(Alert { - id: cap.identifier, - event, - severity: info - .severity - .as_deref() - .map(AlertSeverity::from_cap_string) - .unwrap_or(AlertSeverity::Unknown), - headline, - description: info.description.clone().unwrap_or_default(), - expires, + Some(AlertEntry { + alert: Alert { + id: cap.identifier, + event, + severity: info + .severity + .as_deref() + .map(AlertSeverity::from_cap_string) + .unwrap_or(AlertSeverity::Unknown), + headline, + description: info.description.clone().unwrap_or_default(), + expires, + }, + area_desc, }) } @@ -723,7 +825,7 @@ struct BomWarning { } /// Fetches weather alerts from the Australian Bureau of Meteorology. -async fn fetch_bom_alerts(latitude: f64, longitude: f64) -> Result> { +async fn fetch_bom_alerts(latitude: f64, longitude: f64) -> Result { let geohash = encode_geohash(latitude, longitude, 6); let url = format!( "https://api.weather.bom.gov.au/v1/locations/{}/warnings", @@ -733,18 +835,25 @@ async fn fetch_bom_alerts(latitude: f64, longitude: f64) -> Result> { let response = http_client()?.get(&url).send().await?; if !response.status().is_success() { - return Ok(vec![]); + return Ok(AlertReport { + alerts: vec![], + region_filtered: true, + }); } let response_body: BomWarningsResponse = response.json().await?; let alerts = bom_alerts_from_response(response_body.data); - Ok(alerts) + // The geohash lookup already narrowed the list to the caller's location. + Ok(AlertReport { + alerts, + region_filtered: true, + }) } /// Lives in its own function so it can be unit-tested against fixtures without a live network. -fn bom_alerts_from_response(data: Vec) -> Vec { +fn bom_alerts_from_response(data: Vec) -> Vec { let now = Utc::now(); data.into_iter() @@ -779,13 +888,17 @@ fn bom_alerts_from_response(data: Vec) -> Vec { .map(|t| t.replace('_', " ")) .unwrap_or_else(|| headline.clone()); - Some(Alert { - id: w.id.clone(), - event, - severity, - headline, - description: String::new(), - expires, + Some(AlertEntry { + alert: Alert { + id: w.id.clone(), + event, + severity, + headline, + description: String::new(), + expires, + }, + // BOM's warnings endpoint carries no area name. + area_desc: String::new(), }) }) .collect() @@ -814,7 +927,7 @@ mod tests { let alerts = nws_alerts_from_response(data); assert_eq!(alerts.len(), 1); - let alert = &alerts[0]; + let alert = &alerts[0].alert; assert_eq!(alert.id, "NWS-IDP-PROD-123"); assert_eq!(alert.event, "Tornado Warning"); assert_eq!(alert.severity, AlertSeverity::Severe); @@ -853,7 +966,7 @@ mod tests { let alerts = nws_alerts_from_response(data); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].severity, AlertSeverity::Unknown); + assert_eq!(alerts[0].alert.severity, AlertSeverity::Unknown); } #[test] @@ -888,8 +1001,8 @@ mod tests { let alerts = nws_alerts_from_response(data); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].headline, ""); - assert_eq!(alerts[0].description, ""); + assert_eq!(alerts[0].alert.headline, ""); + assert_eq!(alerts[0].alert.description, ""); } // ----------------------------------------------------------------- @@ -942,7 +1055,7 @@ mod tests { let alerts = bom_alerts_from_response(resp.data); assert_eq!(alerts.len(), 1); - let alert = &alerts[0]; + let alert = &alerts[0].alert; assert_eq!(alert.severity, AlertSeverity::Severe); assert_eq!(alert.event, "severe thunderstorm"); assert_eq!(alert.headline, "Severe Thunderstorm Warning"); @@ -962,7 +1075,7 @@ mod tests { let alerts = bom_alerts_from_response(resp.data); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].severity, AlertSeverity::Severe); + assert_eq!(alerts[0].alert.severity, AlertSeverity::Severe); } #[test] @@ -1011,8 +1124,8 @@ mod tests { let alerts = bom_alerts_from_response(resp.data); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].headline, "Weather Warning"); - assert_eq!(alerts[0].event, "flood"); + assert_eq!(alerts[0].alert.headline, "Weather Warning"); + assert_eq!(alerts[0].alert.event, "flood"); } #[test] @@ -1029,8 +1142,8 @@ mod tests { let alerts = bom_alerts_from_response(resp.data); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].event, "Severe Weather Alert"); - assert_eq!(alerts[0].headline, "Severe Weather Alert"); + assert_eq!(alerts[0].alert.event, "Severe Weather Alert"); + assert_eq!(alerts[0].alert.headline, "Severe Weather Alert"); } // ----------------------------------------------------------------- @@ -1053,6 +1166,7 @@ mod tests { https://feeds.meteoalarm.org/feed/example-entry-1 Wind Warning for Test Region 2-717000-DE723 + Test Region Wind Severe 2026-06-01T08:00:00Z @@ -1062,7 +1176,9 @@ mod tests { "#; let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap(); - let alert = parse_meteoalarm_entry(entry, &None).expect("entry should decode to an alert"); + let entry = parse_meteoalarm_entry(entry, &None).expect("entry should decode to an alert"); + assert_eq!(entry.area_desc, "Test Region"); + let alert = entry.alert; assert_eq!(alert.id, "2-717000-DE723"); assert_eq!(alert.event, "Wind"); @@ -1088,7 +1204,7 @@ mod tests { let alert = parse_meteoalarm_entry(entry, &Some("DE723".to_string())); assert!(alert.is_some()); - let alert = alert.unwrap(); + let alert = alert.unwrap().alert; assert_eq!(alert.event, "Wind"); assert_eq!(alert.severity, AlertSeverity::Severe); } @@ -1165,7 +1281,7 @@ mod tests { let alerts = meteoalarm_alerts_from_feed(feed, &None); assert_eq!(alerts.len(), 1); - assert_eq!(alerts[0].id, "2-717000-DE723-future"); + assert_eq!(alerts[0].alert.id, "2-717000-DE723-future"); } // ----------------------------------------------------------------- @@ -1210,7 +1326,7 @@ mod tests { let alert = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids); assert!(alert.is_some()); - let alert = alert.unwrap(); + let alert = alert.unwrap().alert; assert_eq!(alert.event, "Thunderstorm Warning"); assert_eq!(alert.severity, AlertSeverity::Severe); assert_eq!(alert.id, "CA-ON-2026-001"); @@ -1575,4 +1691,142 @@ mod tests { Some("PL1465".to_string()) ); } + + // ----------------------------------------------------------------- + // area_desc and AlertReport + // ----------------------------------------------------------------- + + #[test] + fn meteoalarm_entry_without_area_desc_is_empty_string() { + let xml = r#" + https://feeds.meteoalarm.org/feed/example-entry-5 + Wind Warning + Wind + 2026-06-01T08:00:00Z + 2099-01-01T00:00:00Z + "#; + let entry: MeteoAlarmEntry = quick_xml::de::from_str(xml).unwrap(); + let entry = parse_meteoalarm_entry(entry, &None).unwrap(); + + assert_eq!(entry.area_desc, ""); + } + + /// Two entries: one tagged with a region that is not the user's, one with + /// no geocode at all. With a filter active both must be dropped; before + /// the `None` arm existed, the untagged one leaked through. + fn untagged_feed() -> MeteoAlarmFeed { + let xml = r#" + + https://feeds.meteoalarm.org/feed/tagged-elsewhere + Wind Warning for elsewhere + Wind + Moderate + 2026-06-01T08:00:00Z + 2099-01-01T00:00:00Z + + PL999 + + + + https://feeds.meteoalarm.org/feed/untagged + Wind Warning with no geocode + Wind + Moderate + 2026-06-01T08:00:00Z + 2099-01-01T00:00:00Z + + "#; + quick_xml::de::from_str(xml).unwrap() + } + + #[test] + fn meteoalarm_untagged_entry_dropped_when_filter_active() { + let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &Some("PL1465".to_string())); + + assert!(alerts.is_empty(), "untagged entry leaked past the filter"); + } + + #[test] + fn meteoalarm_untagged_entry_kept_without_filter() { + let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &None); + + assert_eq!(alerts.len(), 2); + } + + #[test] + fn nws_decodes_area_desc() { + let json = r#"{"features":[{"properties":{ + "id":"NWS-IDP-PROD-128", + "event":"Heat Advisory", + "severity":"Moderate", + "headline":"Heat Advisory until 8 PM", + "description":"Hot.", + "sent":"2026-06-01T12:00:00Z", + "expires":"2099-01-01T00:00:00Z", + "areaDesc":"Coastal Los Angeles County; Los Angeles County Beaches" + }}]}"#; + let data: NwsAlertsResponse = serde_json::from_str(json).unwrap(); + let alerts = nws_alerts_from_response(data); + + assert_eq!( + alerts[0].area_desc, + "Coastal Los Angeles County; Los Angeles County Beaches" + ); + } + + #[test] + fn nws_missing_area_desc_is_empty_string() { + let json = r#"{"features":[{"properties":{ + "id":"NWS-IDP-PROD-129", + "event":"Heat Advisory", + "severity":"Moderate", + "headline":"Heat Advisory", + "description":"Hot.", + "sent":"2026-06-01T12:00:00Z", + "expires":"2099-01-01T00:00:00Z" + }}]}"#; + let data: NwsAlertsResponse = serde_json::from_str(json).unwrap(); + let alerts = nws_alerts_from_response(data); + + assert_eq!(alerts[0].area_desc, ""); + } + + #[test] + fn eccc_area_desc_is_the_containing_polygon() { + let xml = eccc_fixture( + "Actual", + "Alert", + "2026-06-01T08:00:00-04:00", + "CA-ON-2026-008", + ); + let mut seen_ids = HashSet::new(); + let entry = parse_eccc_cap(&xml, 5.0, 5.0, &mut seen_ids).unwrap(); + + assert_eq!(entry.area_desc, "Test Region"); + } + + #[test] + fn bom_area_desc_is_empty_string() { + let json = r#"{"data":[{ + "id":"bom-4", + "type":"flood", + "short_title":"Flood Warning", + "warning_group_type":"moderate", + "phase":"active", + "expiry_time":"2099-01-01T00:00:00Z" + }]}"#; + let resp: BomWarningsResponse = serde_json::from_str(json).unwrap(); + let alerts = bom_alerts_from_response(resp.data); + + assert_eq!(alerts[0].area_desc, ""); + } + + #[tokio::test] + async fn dispatch_unknown_region_detailed_is_empty_and_filtered() { + // Same Tokyo coordinate as dispatch_unknown_region_returns_empty. + let report = fetch_alerts_detailed(35.68, 139.65).await.unwrap(); + + assert!(report.alerts.is_empty()); + assert!(report.region_filtered); + } } diff --git a/src/lib.rs b/src/lib.rs index 7b32eab..e9b79d2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,7 +30,7 @@ pub mod wire; pub use air_quality::{ AirQualityData, AqiCategory, AqiSource, AqiStandard, EuAqiCategory, UsAqiCategory, }; -pub use alerts::{Alert, AlertSeverity}; +pub use alerts::{Alert, AlertEntry, AlertReport, AlertSeverity}; pub use codes::{CompassDirection, WeatherCondition}; pub use error::{Error, NetworkKind, ParseKind, Result}; pub use geo::{detect_region, Region}; @@ -45,7 +45,7 @@ pub use wire::{Envelope, EnvelopeError, WireError}; // Re-export the async functions at crate root. pub use air_quality::fetch_air_quality; -pub use alerts::fetch_alerts; +pub use alerts::{fetch_alerts, fetch_alerts_detailed}; pub use client::reset_http_client; pub use location::{detect_location, search_city, uses_imperial_units}; pub use network::network_stream; diff --git a/tests/snapshots/wire_contract__alert_entry.snap b/tests/snapshots/wire_contract__alert_entry.snap new file mode 100644 index 0000000..56b0533 --- /dev/null +++ b/tests/snapshots/wire_contract__alert_entry.snap @@ -0,0 +1,15 @@ +--- +source: tests/wire_contract.rs +expression: round_trip(&alert_entry()) +--- +{ + "alert": { + "id": "NWS-IDP-PROD-123", + "event": "Severe Thunderstorm Warning", + "severity": "Severe", + "headline": "Severe thunderstorm until 10 PM EDT", + "description": "Wind gusts to 60 mph expected.", + "expires": "2026-06-11T22:00:00Z" + }, + "area_desc": "Warszawa" +} diff --git a/tests/snapshots/wire_contract__alert_report.snap b/tests/snapshots/wire_contract__alert_report.snap new file mode 100644 index 0000000..0fdce32 --- /dev/null +++ b/tests/snapshots/wire_contract__alert_report.snap @@ -0,0 +1,20 @@ +--- +source: tests/wire_contract.rs +expression: round_trip(&alert_report()) +--- +{ + "alerts": [ + { + "alert": { + "id": "NWS-IDP-PROD-123", + "event": "Severe Thunderstorm Warning", + "severity": "Severe", + "headline": "Severe thunderstorm until 10 PM EDT", + "description": "Wind gusts to 60 mph expected.", + "expires": "2026-06-11T22:00:00Z" + }, + "area_desc": "Warszawa" + } + ], + "region_filtered": true +} diff --git a/tests/wire_contract.rs b/tests/wire_contract.rs index 6d8d94c..873b008 100644 --- a/tests/wire_contract.rs +++ b/tests/wire_contract.rs @@ -10,10 +10,11 @@ use chrono::{TimeZone, Utc}; use serde::{de::DeserializeOwned, Serialize}; use weathervane::{ - AirQualityData, Alert, AlertSeverity, AqiCategory, AqiSource, CompassDirection, CurrentWeather, - DailyForecast, DetectedLocation, Envelope, EnvelopeError, Error, EuAqiCategory, HourlyForecast, - LocationResult, MeasurementSystem, NetworkKind, ParseKind, PollenData, PressureUnit, - SavedLocation, TemperatureUnit, UsAqiCategory, WeatherCondition, WeatherData, WireError, + AirQualityData, Alert, AlertEntry, AlertReport, AlertSeverity, AqiCategory, AqiSource, + CompassDirection, CurrentWeather, DailyForecast, DetectedLocation, Envelope, EnvelopeError, + Error, EuAqiCategory, HourlyForecast, LocationResult, MeasurementSystem, NetworkKind, + ParseKind, PollenData, PressureUnit, SavedLocation, TemperatureUnit, UsAqiCategory, + WeatherCondition, WeatherData, WireError, }; // --------------------------------------------------------------------------- @@ -296,6 +297,30 @@ fn alert_shape() { insta::assert_snapshot!("canary_alert_severity", json(&AlertSeverity::Severe)); } +fn alert_entry() -> AlertEntry { + AlertEntry { + alert: alert(), + area_desc: "Warszawa".to_string(), + } +} + +fn alert_report() -> AlertReport { + AlertReport { + alerts: vec![alert_entry()], + region_filtered: true, + } +} + +#[test] +fn alert_entry_shape() { + insta::assert_snapshot!("alert_entry", round_trip(&alert_entry())); +} + +#[test] +fn alert_report_shape() { + insta::assert_snapshot!("alert_report", round_trip(&alert_report())); +} + // --------------------------------------------------------------------------- // Pollen and location shapes // --------------------------------------------------------------------------- From a46f1c7b131b3789efb533a3401f7b34ed64f155 Mon Sep 17 00:00:00 2001 From: Brian Stine Date: Wed, 2 Sep 2026 09:38:44 -0700 Subject: [PATCH 2/2] fix(alerts): treat a feed with EMMA_ED geocodes as unfiltered --- API.md | 7 +- CONTRACT.md | 7 +- src/alerts.rs | 216 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 188 insertions(+), 42 deletions(-) diff --git a/API.md b/API.md index 819724d..30baf78 100644 --- a/API.md +++ b/API.md @@ -141,9 +141,10 @@ otherwise know: `cap:areaDesc`, NWS `areaDesc` (a `;`-separated county list), the containing ECCC polygon's `areaDesc`. BOM sends none, so it is `""`. - `region_filtered` -- `false` only when a MeteoAlarm national feed was returned - without an EMMA_ID for the location, so the entries are national, not local. - NWS, ECCC and BOM filter by point, polygon and geohash, and an empty result is - trivially filtered, so all of those are `true`. + unfiltered: no EMMA_ID resolved for the location, or the feed carries no + EMMA_ID geocodes to filter on (France tags entries with NUTS3), so the entries + are national, not local. NWS, ECCC and BOM filter by point, polygon and + geohash, and an empty result is trivially filtered, so all of those are `true`. When an EMMA_ID did resolve, MeteoAlarm entries carrying no geocode are dropped rather than shown, since they cannot be placed in any region. diff --git a/CONTRACT.md b/CONTRACT.md index a951c60..26fef89 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -171,9 +171,10 @@ the provider sends none (BOM), per the missing-strings rule. ``` `region_filtered` is `false` only when a MeteoAlarm national feed was returned -without an EMMA_ID for the location, so the entries are national, not local. -NWS, ECCC and BOM filter by point, polygon and geohash, and an empty result is -trivially filtered, so all of those are `true`. +unfiltered: no EMMA_ID resolved for the location, or the feed carries no +EMMA_ID geocodes to filter on (France tags entries with NUTS3), so the entries +are national, not local. NWS, ECCC and BOM filter by point, polygon and +geohash, and an empty result is trivially filtered, so all of those are `true`. ### PollenData Inside CAMS European coverage: diff --git a/src/alerts.rs b/src/alerts.rs index 4b55108..ba57d19 100644 --- a/src/alerts.rs +++ b/src/alerts.rs @@ -75,10 +75,12 @@ pub struct AlertEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AlertReport { pub alerts: Vec, - /// `false` only when a MeteoAlarm national feed was returned without an - /// EMMA_ID for the location, so the entries are national, not local. - /// NWS, ECCC and BOM filter by point, polygon and geohash respectively, - /// and an empty result is trivially filtered, so all of those are `true`. + /// `false` only when a MeteoAlarm national feed was returned unfiltered: + /// no EMMA_ID resolved for the location, or the feed carries no EMMA_ID + /// geocodes to filter on (France tags entries with NUTS3), so the entries + /// are national, not local. NWS, ECCC and BOM filter by point, polygon and + /// geohash respectively, and an empty result is trivially filtered, so all + /// of those are `true`. pub region_filtered: bool, } @@ -244,6 +246,10 @@ struct MeteoAlarmEntry { /// Geocode element containing EMMA_ID area identifier. #[derive(Debug, Deserialize)] struct MeteoAlarmGeocode { + /// Which scheme `value` belongs to. Most feeds say `EMMA_ID`; France says + /// `NUTS3`, whose codes are not EMMA_IDs and must not be filtered as such. + #[serde(rename = "valueName")] + value_name: Option, value: Option, } @@ -456,60 +462,100 @@ async fn fetch_meteoalarm_alerts(latitude: f64, longitude: f64) -> Result tracing::debug!( + match (&user_emma_id, report.region_filtered) { + (Some(emma_id), true) => tracing::debug!( "Fetched {} alert(s) from MeteoAlarm ({}), filtered to {}", - alerts.len(), + report.alerts.len(), country, emma_id ), - None => tracing::warn!( + (Some(emma_id), false) => tracing::warn!( + "Fetched {} alert(s) from MeteoAlarm ({}), UNFILTERED - the feed carries no \ + EMMA_ID geocodes, so the filter to {} could not apply", + report.alerts.len(), + country, + emma_id + ), + (None, _) => tracing::warn!( "Fetched {} alert(s) from MeteoAlarm ({}), UNFILTERED - no EMMA_ID \ for this location, so these are national alerts, not local ones", - alerts.len(), + report.alerts.len(), country ), } - Ok(AlertReport { - alerts, - region_filtered: user_emma_id.is_some(), - }) + Ok(report) } -/// The entry's EMMA_ID, if the feed tagged it with one. +/// The entry's EMMA_ID, if the feed tagged it with one. A geocode under any +/// other scheme (`NUTS3` in France) is not an EMMA_ID and yields `None`. fn entry_emma_id(entry: &MeteoAlarmEntry) -> Option<&str> { entry .cap_geocode .as_ref() + .filter(|gc| gc.value_name.as_deref() == Some("EMMA_ID")) .and_then(|gc| gc.value.as_deref()) } /// Lives in its own function so it can be unit-tested against fixtures without a live network. -fn meteoalarm_alerts_from_feed( - feed: MeteoAlarmFeed, - user_emma_id: &Option, -) -> Vec { - if let Some(user_id) = user_emma_id { - let untagged = feed - .entries - .iter() - .filter(|entry| entry_emma_id(entry).is_none()) - .count(); - if untagged > 0 { - tracing::debug!( - "Dropped {} untagged MeteoAlarm entr(y/ies) while filtering to {}", - untagged, +/// +/// A resolved EMMA_ID can only filter a feed that tags its entries with +/// EMMA_IDs. If none of the entries carries one, the filter cannot apply and +/// the whole feed renders unfiltered, reported as such, rather than being +/// emptied by a comparison that can never match. +fn meteoalarm_alerts_from_feed(feed: MeteoAlarmFeed, user_emma_id: &Option) -> AlertReport { + let feed_has_emma_ids = feed + .entries + .iter() + .any(|entry| entry_emma_id(entry).is_some()); + + let filter = match user_emma_id { + Some(user_id) if !feed.entries.is_empty() && !feed_has_emma_ids => { + let mut schemes: Vec<&str> = feed + .entries + .iter() + .filter_map(|entry| entry.cap_geocode.as_ref()) + .filter_map(|gc| gc.value_name.as_deref()) + .collect(); + schemes.sort_unstable(); + schemes.dedup(); + tracing::warn!( + "MeteoAlarm feed carries no EMMA_ID geocodes (found {:?}); the filter to {} \ + cannot apply, rendering the feed unfiltered", + schemes, user_id ); + None } - } + Some(user_id) => { + let untagged = feed + .entries + .iter() + .filter(|entry| entry_emma_id(entry).is_none()) + .count(); + if untagged > 0 { + tracing::debug!( + "Dropped {} untagged MeteoAlarm entr(y/ies) while filtering to {}", + untagged, + user_id + ); + } + Some(user_id.clone()) + } + None => None, + }; - feed.entries + let alerts = feed + .entries .into_iter() - .filter_map(|entry| parse_meteoalarm_entry(entry, user_emma_id)) - .collect() + .filter_map(|entry| parse_meteoalarm_entry(entry, &filter)) + .collect(); + + AlertReport { + alerts, + region_filtered: filter.is_some(), + } } /// Parses a MeteoAlarm entry into an AlertEntry. @@ -1172,6 +1218,7 @@ mod tests { 2026-06-01T08:00:00Z 2099-01-01T00:00:00Z + EMMA_ID DE723 "#; @@ -1197,6 +1244,7 @@ mod tests { 2026-06-01T08:00:00Z 2099-01-01T00:00:00Z + EMMA_ID DE723 "#; @@ -1220,6 +1268,7 @@ mod tests { 2026-06-01T08:00:00Z 2099-01-01T00:00:00Z + EMMA_ID DE723 "#; @@ -1240,6 +1289,7 @@ mod tests { 2020-01-01T00:00:00Z 2020-01-01T00:00:00Z + EMMA_ID DE723 "#; @@ -1261,6 +1311,7 @@ mod tests { 2026-06-01T08:00:00Z 2099-01-01T00:00:00Z + EMMA_ID DE723 @@ -1273,12 +1324,13 @@ mod tests { 2020-01-01T00:00:00Z 2020-01-01T00:00:00Z + EMMA_ID DE723 "#; let feed: MeteoAlarmFeed = quick_xml::de::from_str(xml).unwrap(); - let alerts = meteoalarm_alerts_from_feed(feed, &None); + let alerts = meteoalarm_alerts_from_feed(feed, &None).alerts; assert_eq!(alerts.len(), 1); assert_eq!(alerts[0].alert.id, "2-717000-DE723-future"); @@ -1724,6 +1776,7 @@ mod tests { 2026-06-01T08:00:00Z 2099-01-01T00:00:00Z + EMMA_ID PL999 @@ -1741,14 +1794,15 @@ mod tests { #[test] fn meteoalarm_untagged_entry_dropped_when_filter_active() { - let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &Some("PL1465".to_string())); + let alerts = + meteoalarm_alerts_from_feed(untagged_feed(), &Some("PL1465".to_string())).alerts; assert!(alerts.is_empty(), "untagged entry leaked past the filter"); } #[test] fn meteoalarm_untagged_entry_kept_without_filter() { - let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &None); + let alerts = meteoalarm_alerts_from_feed(untagged_feed(), &None).alerts; assert_eq!(alerts.len(), 2); } @@ -1829,4 +1883,94 @@ mod tests { assert!(report.alerts.is_empty()); assert!(report.region_filtered); } + + // ----------------------------------------------------------------- + // Feeds that are not EMMA_ID-tagged (France uses NUTS3) + // ----------------------------------------------------------------- + + /// The shape of every entry in the live French feed on 2026-09-02: a NUTS3 + /// geocode, with the EMMA_ID only in a link href that is not parsed. + fn nuts3_entry(id: &str, nuts3: &str, area: &str) -> String { + format!( + r#" + https://feeds.meteoalarm.org/feed/{id} + + NUTS3 + {nuts3} + + + {area} + Yellow Wind Warning + Moderate + 2026-06-01T08:00:00Z + 2099-01-01T00:00:00Z + "# + ) + } + + #[test] + fn meteoalarm_nuts3_geocode_is_not_an_emma_id() { + let entry: MeteoAlarmEntry = + quick_xml::de::from_str(&nuts3_entry("fr-1", "FR713", "Drôme")).unwrap(); + + assert_eq!(entry_emma_id(&entry), None); + assert_eq!(entry.cap_geocode.unwrap().value.as_deref(), Some("FR713")); + } + + #[test] + fn meteoalarm_feed_without_emma_ids_renders_unfiltered() { + let xml = format!( + "{}{}", + nuts3_entry("fr-1", "FR713", "Drôme"), + nuts3_entry("fr-2", "FR813", "Hérault") + ); + let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap(); + let report = meteoalarm_alerts_from_feed(feed, &Some("FR101".to_string())); + + // The filter cannot apply to a feed that never carries an EMMA_ID, so + // nothing is dropped and the report says the list is national. + assert_eq!(report.alerts.len(), 2); + assert!(!report.region_filtered); + assert_eq!(report.alerts[0].area_desc, "Drôme"); + } + + #[test] + fn meteoalarm_mixed_feed_drops_untagged_and_stays_filtered() { + let xml = format!( + r#" + + https://feeds.meteoalarm.org/feed/tagged-here + Wind + 2026-06-01T08:00:00Z + 2099-01-01T00:00:00Z + + EMMA_ID + FR101 + + + {} + "#, + nuts3_entry("fr-3", "FR713", "Drôme") + ); + let feed: MeteoAlarmFeed = quick_xml::de::from_str(&xml).unwrap(); + let report = meteoalarm_alerts_from_feed(feed, &Some("FR101".to_string())); + + // One entry carries an EMMA_ID, so the feed is filterable: the NUTS3 + // entry is untagged for this purpose and is dropped. + assert_eq!(report.alerts.len(), 1); + assert_eq!( + report.alerts[0].alert.id, + "https://feeds.meteoalarm.org/feed/tagged-here" + ); + assert!(report.region_filtered); + } + + #[test] + fn meteoalarm_empty_feed_with_filter_is_filtered() { + let feed: MeteoAlarmFeed = quick_xml::de::from_str("").unwrap(); + let report = meteoalarm_alerts_from_feed(feed, &Some("PL1465".to_string())); + + assert!(report.alerts.is_empty()); + assert!(report.region_filtered); + } }