From dd2f24316ca8bc7f3c4c1c7bc0ff52ef5ef52344 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 12 Jun 2026 14:03:00 +0000 Subject: [PATCH] Filter active alerts in app service --- internal/app/service.go | 67 +++++++++++ internal/app/service_test.go | 209 +++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+) diff --git a/internal/app/service.go b/internal/app/service.go index ee11273..5f5bbcb 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -68,6 +68,26 @@ func (s *Service) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, e return s.repo.LatestAlertRun(ctx) } +func (s *Service) LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) { + run, err := s.repo.LatestAlertRun(ctx) + if err != nil { + return nil, err + } + if run == nil { + return nil, nil + } + + out := cloneAlertRun(run) + alerts := out.Alerts[:0] + for _, alert := range out.Alerts { + if isActiveAlert(alert, activeAt) { + alerts = append(alerts, alert) + } + } + out.Alerts = alerts + return out, nil +} + func (s *Service) LatestConvectiveOutlook(ctx context.Context, filter OutlookFilter) (*model.WeatherOutlookRun, error) { run, err := s.repo.LatestConvectiveOutlookRun(ctx) if err != nil { @@ -113,6 +133,53 @@ func normalizeOutlookType(value string) string { return strings.ToLower(strings.TrimSpace(value)) } +func cloneAlertRun(run *model.WeatherAlertRun) *model.WeatherAlertRun { + out := *run + out.Latitude = copyFloat64(run.Latitude) + out.Longitude = copyFloat64(run.Longitude) + if run.Alerts != nil { + out.Alerts = make([]model.WeatherAlert, len(run.Alerts)) + for i := range run.Alerts { + out.Alerts[i] = cloneAlert(run.Alerts[i]) + } + } + return &out +} + +func cloneAlert(alert model.WeatherAlert) model.WeatherAlert { + out := alert + out.Sent = copyTime(alert.Sent) + out.Effective = copyTime(alert.Effective) + out.Onset = copyTime(alert.Onset) + out.Expires = copyTime(alert.Expires) + if alert.References != nil { + out.References = make([]model.AlertReference, len(alert.References)) + for i := range alert.References { + out.References[i] = cloneAlertReference(alert.References[i]) + } + } + return out +} + +func cloneAlertReference(ref model.AlertReference) model.AlertReference { + out := ref + out.Sent = copyTime(ref.Sent) + return out +} + +func isActiveAlert(alert model.WeatherAlert, activeAt time.Time) bool { + if strings.EqualFold(strings.TrimSpace(alert.MessageType), "Cancel") { + return false + } + if alert.Effective != nil && activeAt.Before(*alert.Effective) { + return false + } + if alert.Expires != nil && !activeAt.Before(*alert.Expires) { + return false + } + return true +} + 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 f767eab..8844a6d 100644 --- a/internal/app/service_test.go +++ b/internal/app/service_test.go @@ -25,6 +25,7 @@ type fakeRepository struct { err error currentConditionsWindow int + alertRunCalls int outlookRunCalls int } @@ -53,6 +54,7 @@ func (r *fakeRepository) LatestWeatherStory(context.Context) (*model.WeatherStor } func (r *fakeRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { + r.alertRunCalls++ return r.alerts, r.err } @@ -118,6 +120,137 @@ func TestServiceDelegatesAlerts(t *testing.T) { } } +func TestServiceLatestActiveAlertRunDelegatesAndFilters(t *testing.T) { + activeAt := testTime(12) + repo := &fakeRepository{alerts: testAlertRun()} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), activeAt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.alertRunCalls != 1 { + t.Fatalf("expected one repository call, got %d", repo.alertRunCalls) + } + assertAlertIDs(t, run, []string{"current", "effective-at-boundary", "missing-effective", "missing-expires", "later-onset"}) +} + +func TestServiceLatestActiveAlertRunNoData(t *testing.T) { + repo := &fakeRepository{} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run != nil { + t.Fatalf("expected nil alert run, got %+v", run) + } +} + +func TestServiceLatestActiveAlertRunPropagatesErrors(t *testing.T) { + want := errors.New("alert read failed") + repo := &fakeRepository{err: want} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12)) + if !errors.Is(err, want) { + t.Fatalf("expected error %v, got %v", want, err) + } + if run != nil { + t.Fatalf("expected nil alert run on error, got %+v", run) + } +} + +func TestServiceLatestActiveAlertRunKeepsMetadataWithEmptyAlerts(t *testing.T) { + activeAt := testTime(12) + repo := &fakeRepository{alerts: testAlertRunWithAlerts([]model.WeatherAlert{ + testAlert("expired", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)), + testAlert("cancel", "Cancel", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)), + testAlert("future", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(14)), + })} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), activeAt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil { + t.Fatal("expected alert run") + } + if run.LocationID != "stl" || run.LocationName != "St. Louis" || !run.AsOf.Equal(testTime(10)) { + t.Fatalf("unexpected run metadata: %+v", run) + } + if run.Latitude == nil || *run.Latitude != 38.62 { + t.Fatalf("unexpected latitude: %v", run.Latitude) + } + if run.Longitude == nil || *run.Longitude != -90.2 { + t.Fatalf("unexpected longitude: %v", run.Longitude) + } + if run.Alerts == nil { + t.Fatal("expected empty alert slice, got nil") + } + if len(run.Alerts) != 0 { + t.Fatalf("expected no alerts, got %+v", run.Alerts) + } +} + +func TestServiceLatestActiveAlertRunDoesNotMutateRepositoryRun(t *testing.T) { + original := testAlertRun() + repo := &fakeRepository{alerts: original} + svc := NewService(repo) + + run, err := svc.LatestActiveAlertRun(context.Background(), testTime(12)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(run.Alerts) == 0 { + t.Fatal("expected active alerts") + } + + *run.Latitude = 99 + *run.Longitude = -99 + *run.Alerts[0].Sent = testTime(1) + *run.Alerts[0].Effective = testTime(2) + *run.Alerts[0].Onset = testTime(3) + *run.Alerts[0].Expires = testTime(4) + *run.Alerts[0].References[0].Sent = testTime(5) + run.Alerts[0].ID = "changed" + run.Alerts[0].References[0].ID = "changed" + run.Alerts = run.Alerts[:1] + + if *original.Latitude != 38.62 { + t.Fatalf("expected original latitude unchanged, got %v", *original.Latitude) + } + if *original.Longitude != -90.2 { + t.Fatalf("expected original longitude unchanged, got %v", *original.Longitude) + } + if original.Alerts[0].ID != "current" { + t.Fatalf("expected original alert ID unchanged, got %q", original.Alerts[0].ID) + } + if original.Alerts[0].Sent == nil || !original.Alerts[0].Sent.Equal(testTime(9)) { + t.Fatalf("expected original sent unchanged, got %v", original.Alerts[0].Sent) + } + if original.Alerts[0].Effective == nil || !original.Alerts[0].Effective.Equal(testTime(10)) { + t.Fatalf("expected original effective unchanged, got %v", original.Alerts[0].Effective) + } + if original.Alerts[0].Onset == nil || !original.Alerts[0].Onset.Equal(testTime(11)) { + t.Fatalf("expected original onset unchanged, got %v", original.Alerts[0].Onset) + } + if original.Alerts[0].Expires == nil || !original.Alerts[0].Expires.Equal(testTime(13)) { + t.Fatalf("expected original expires unchanged, got %v", original.Alerts[0].Expires) + } + if original.Alerts[0].References[0].ID != "ref-current" { + t.Fatalf("expected original reference ID unchanged, got %q", original.Alerts[0].References[0].ID) + } + if original.Alerts[0].References[0].Sent == nil || !original.Alerts[0].References[0].Sent.Equal(testTime(8)) { + t.Fatalf("expected original reference sent unchanged, got %v", original.Alerts[0].References[0].Sent) + } + if len(original.Alerts) != 8 { + t.Fatalf("expected original alert slice unchanged, got %d entries", len(original.Alerts)) + } +} + func TestServiceDelegatesLatestConvectiveOutlookRun(t *testing.T) { repo := &fakeRepository{outlookRun: testOutlookRun()} svc := NewService(repo) @@ -402,6 +535,67 @@ func TestServicePropagatesErrors(t *testing.T) { } } +func testAlertRun() *model.WeatherAlertRun { + return testAlertRunWithAlerts([]model.WeatherAlert{ + testAlert("current", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)), + testAlert("expired", "Update", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(12)), + testAlert("future-effective", "Alert", testTimePtr(9), testTimePtr(13), testTimePtr(13), testTimePtr(15)), + testAlert("canceled", " cancel ", testTimePtr(9), testTimePtr(10), testTimePtr(11), testTimePtr(13)), + testAlert("effective-at-boundary", "Alert", testTimePtr(9), testTimePtr(12), testTimePtr(12), testTimePtr(14)), + testAlert("missing-effective", "Alert", testTimePtr(9), nil, nil, testTimePtr(14)), + testAlert("missing-expires", "Alert", testTimePtr(9), testTimePtr(10), nil, nil), + testAlert("later-onset", "Alert", testTimePtr(9), testTimePtr(10), testTimePtr(13), testTimePtr(14)), + }) +} + +func testAlertRunWithAlerts(alerts []model.WeatherAlert) *model.WeatherAlertRun { + latitude := 38.62 + longitude := -90.2 + return &model.WeatherAlertRun{ + LocationID: "stl", + LocationName: "St. Louis", + AsOf: testTime(10), + Latitude: &latitude, + Longitude: &longitude, + Alerts: alerts, + } +} + +func testAlert(id string, messageType string, sent *time.Time, effective *time.Time, onset *time.Time, expires *time.Time) model.WeatherAlert { + refSent := testTime(8) + return model.WeatherAlert{ + ID: id, + Event: "Thunderstorm Warning", + Headline: "Storm headline", + Severity: "Severe", + Urgency: "Immediate", + Certainty: "Likely", + Status: "Actual", + MessageType: messageType, + Category: "Met", + Response: "Shelter", + Description: "Storm description", + Instruction: "Take shelter", + Sent: sent, + Effective: effective, + Onset: onset, + Expires: expires, + AreaDescription: "St. Louis City", + SenderName: "NWS St. Louis", + References: []model.AlertReference{{ + ID: "ref-" + id, + Identifier: "identifier-" + id, + Sender: "sender-" + id, + Sent: &refSent, + }}, + } +} + +func testTimePtr(hour int) *time.Time { + value := testTime(hour) + return &value +} + func testOutlookRun() *model.WeatherOutlookRun { latitude := 38.62 longitude := -90.2 @@ -493,3 +687,18 @@ func assertDiscussionDays(t *testing.T, run *model.WeatherOutlookRun, want []int } } } + +func assertAlertIDs(t *testing.T, run *model.WeatherAlertRun, want []string) { + t.Helper() + if run == nil { + t.Fatal("expected alert run") + } + if len(run.Alerts) != len(want) { + t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts) + } + for i := range want { + if run.Alerts[i].ID != want[i] { + t.Fatalf("expected alert IDs %v, got %+v", want, run.Alerts) + } + } +}