Filter active alerts in app service

This commit is contained in:
2026-06-12 14:03:00 +00:00
parent 90abc536dd
commit dd2f24316c
2 changed files with 276 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)
}
}
}