diff --git a/docs/api.md b/docs/api.md index 3f4666d..490ffdc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -194,7 +194,8 @@ GET /conditions/current?format=json&precision=0 GET /alerts/active ``` -Returns the latest stored alert run filtered to alerts active at request time. +Returns the latest stored alert run filtered to alerts active at request time, +omitting older alerts superseded by newer alert references in the same run. Query parameters: `format`, `units`. @@ -216,6 +217,9 @@ at or before request time, and the alert end boundary is absent or after request time. The end boundary prefers `ends`; if `ends` is absent, `expires` is used as a fallback for older rows or providers that do not supply an alert-period end. `onset` is presented when available but is not used as the active boundary. +After active-time filtering, alerts referenced by another alert in the same run +are omitted as superseded. References from update and cancel messages are both +honored, even when the referencing alert is not itself returned. Alert fields include `id`, `event`, `headline`, `severity`, `urgency`, `certainty`, `status`, `messageType`, `category`, `response`, `description`, diff --git a/docs/internal/http-adapter.md b/docs/internal/http-adapter.md index 75f72bd..1a20db4 100644 --- a/docs/internal/http-adapter.md +++ b/docs/internal/http-adapter.md @@ -136,7 +136,10 @@ behavior deterministic. the application service with `alertNow().UTC()` so active alert filtering uses the request-time instant while remaining deterministic in endpoint tests. The application service prefers alert `ends` over `expires` when deciding whether an -alert has ended. +alert has ended. The application service also suppresses alerts referenced by +another alert in the same latest run. This supersession rule uses alert +references from update and cancel messages, even when the referencing message is +not returned by `/alerts/active`. ## Outlook Filters diff --git a/internal/app/service.go b/internal/app/service.go index 5b5555f..f8083d8 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -10,6 +10,8 @@ import ( "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) +const nwsAlertURLPrefix = "https://api.weather.gov/alerts/" + // Repository defines outbound data access used by weatherapi use cases. type Repository interface { LatestObservation(ctx context.Context) (*model.WeatherObservation, error) @@ -77,9 +79,10 @@ func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) } out := cloneAlertRun(run) + supersededIDs := collectSupersededAlertIDs(out.Alerts) alerts := out.Alerts[:0] for _, alert := range out.Alerts { - if isActiveAlert(alert, activeAt) { + if isActiveAlert(alert, activeAt) && !isSupersededAlert(alert, supersededIDs) { alerts = append(alerts, alert) } } @@ -181,6 +184,41 @@ func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool { return true } +func collectSupersededAlertIDs(alerts []model.WeatherAlert) map[string]struct{} { + supersededIDs := make(map[string]struct{}) + for _, alert := range alerts { + for _, ref := range alert.References { + id := normalizeAlertID(referenceAlertID(ref)) + if id != "" { + supersededIDs[id] = struct{}{} + } + } + } + return supersededIDs +} + +func isSupersededAlert(alert model.WeatherAlert, supersededIDs map[string]struct{}) bool { + id := normalizeAlertID(alert.ID) + if id == "" { + return false + } + _, ok := supersededIDs[id] + return ok +} + +func referenceAlertID(ref model.AlertReference) string { + if strings.TrimSpace(ref.Identifier) != "" { + return ref.Identifier + } + return ref.ID +} + +func normalizeAlertID(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, nwsAlertURLPrefix) + return value +} + func cloneOutlookRun(run *model.WeatherOutlookRun) *model.WeatherOutlookRun { out := *run out.Latitude = copyFloat64(run.Latitude) diff --git a/internal/app/service_test.go b/internal/app/service_test.go index 2dc9256..1f6e9f2 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -272,6 +272,60 @@ func TestServiceLatestActiveAlertRunUsesEndsBeforeExpires(t *testing.T) { assertAlertIDs(t, run, []string{"ends-after-active-expires-before", "expires-fallback"}) } +func TestServiceLatestActiveAlertRunSuppressesReferencedOriginal(t *testing.T) { + activeAt := testTime(12) + original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + update := testAlert("https://api.weather.gov/alerts/urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13)) + update.References = []model.AlertReference{{Identifier: "urn:oid:original"}} + unrelated := testAlert("https://api.weather.gov/alerts/urn:oid:unrelated", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, update, unrelated})} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), activeAt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertAlertIDs(t, run, []string{ + "https://api.weather.gov/alerts/urn:oid:update", + "https://api.weather.gov/alerts/urn:oid:unrelated", + }) +} + +func TestServiceLatestActiveAlertRunCancelSuppressesReferencedOriginal(t *testing.T) { + activeAt := testTime(12) + original := testAlert("https://api.weather.gov/alerts/urn:oid:original", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + cancel := testAlert("https://api.weather.gov/alerts/urn:oid:cancel", "Cancel", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13)) + cancel.References = []model.AlertReference{{Identifier: "urn:oid:original"}} + repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{original, cancel})} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), activeAt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertAlertIDs(t, run, []string{}) +} + +func TestServiceLatestActiveAlertRunReferenceIdentifierPrecedenceAndIDFallback(t *testing.T) { + activeAt := testTime(12) + fromID := testAlert("urn:oid:from-id", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + fromIdentifier := testAlert("urn:oid:from-identifier", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + idFallback := testAlert("urn:oid:id-fallback", "Alert", testTimePtr(9), testTimePtr(10), nil, testTimePtr(13), testTimePtr(13)) + update := testAlert("urn:oid:update", "Update", testTimePtr(11), testTimePtr(11), nil, testTimePtr(13), testTimePtr(13)) + update.References = []model.AlertReference{ + {ID: "urn:oid:from-id", Identifier: "urn:oid:from-identifier"}, + {ID: "urn:oid:id-fallback"}, + } + repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{fromID, fromIdentifier, idFallback, update})} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), activeAt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertAlertIDs(t, run, []string{"urn:oid:from-id", "urn:oid:update"}) +} + func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) { repo := &fakeRepository{outlookRun: testOutlookRun()} svc := NewService(repo)