diff --git a/internal/adapters/inbound/httpapi/alerts_endpoint.go b/internal/adapters/inbound/httpapi/alerts_endpoint.go index e71d367..de39b7d 100644 --- a/internal/adapters/inbound/httpapi/alerts_endpoint.go +++ b/internal/adapters/inbound/httpapi/alerts_endpoint.go @@ -4,6 +4,7 @@ package httpapi import ( "context" + "time" "gitea.maximumdirect.net/ejr/feedapi/endpoint" "gitea.maximumdirect.net/ejr/feedapi/render" @@ -11,12 +12,14 @@ import ( "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" ) +var alertNow = time.Now + func alertsDefinition(svc Service) endpoint.Definition { return endpoint.GET( "/alerts/active", bindQuery, func(ctx context.Context, req queryRequest) (any, error) { - run, err := svc.LatestAlertRun(ctx) + run, err := svc.LatestActiveAlertRun(ctx, alertNow().UTC()) if err != nil { return nil, err } diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index 251e1e3..4aca30f 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -30,6 +30,7 @@ type fakeService struct { weatherStoryRun *model.WeatherStoryRun weatherStory *model.WeatherStory alerts *model.WeatherAlertRun + activeAlertAt []time.Time outlookRun *model.WeatherOutlookRun outlookFilters []app.OutlookFilter conditions *app.CurrentConditions @@ -64,6 +65,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e return s.alerts, s.err } +func (s *fakeService) LatestActiveAlertRun(_ context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) { + s.activeAlertAt = append(s.activeAlertAt, activeAt) + return s.alerts, s.err +} + func (s *fakeService) LatestConvectiveOutlook(_ context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) { s.outlookFilters = append(s.outlookFilters, filter) return s.outlookRun, s.err @@ -315,6 +321,82 @@ func TestAlertsUSUnitsKeepSchema(t *testing.T) { } } +func TestAlertsRouteRegistered(t *testing.T) { + def := definitionForPath(t, Definitions(&fakeService{}), "/alerts/active") + if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet { + t.Fatalf("expected GET definition, got %+v", def.Methods) + } +} + +func TestAlertsHandlerPassesCurrentUTCInstant(t *testing.T) { + now := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("LOCAL", -5*60*60)) + setAlertNowForTest(t, now) + svc := &fakeService{ + alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}, + } + h := newHandler(t, svc, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if len(svc.activeAlertAt) != 1 { + t.Fatalf("expected one active-alert service call, got %d", len(svc.activeAlertAt)) + } + want := now.UTC() + if !svc.activeAlertAt[0].Equal(want) { + t.Fatalf("expected activeAt %v, got %v", want, svc.activeAlertAt[0]) + } + if svc.activeAlertAt[0].Location() != time.UTC { + t.Fatalf("expected UTC location, got %v", svc.activeAlertAt[0].Location()) + } +} + +func TestAlertsNoDataReturnsNullEnvelopeData(t *testing.T) { + h := newHandler(t, &fakeService{}, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var payload struct { + Data *json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode envelope: %v", err) + } + if payload.Data != nil { + t.Fatalf("expected data null, got %s", string(*payload.Data)) + } +} + +func TestAlertsTextRendersEmptyActiveSet(t *testing.T) { + h := newHandler(t, &fakeService{ + alerts: &model.WeatherAlertRun{ + AsOf: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), + Alerts: []model.WeatherAlert{}, + }, + }, "/alerts/active") + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=text", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Alerts: 0") { + t.Fatalf("expected empty alert count in text body, got %q", w.Body.String()) + } +} + func TestObservationUSUnitsWithXMLFormat(t *testing.T) { h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ @@ -1265,17 +1347,24 @@ func TestPrecisionValidationRange(t *testing.T) { } } -func TestAlertsRejectPrecisionQueryParameter(t *testing.T) { +func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) { h := newHandler(t, &fakeService{ alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}, }, "/alerts/active") - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil) - h.ServeHTTP(w, req) + for _, rawURL := range []string{ + "/alerts/active?precision=1", + "/alerts/active?tz=CDT", + "/alerts/active?TZ=CDT", + "/alerts/active?bogus=1", + } { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, rawURL, nil) + h.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400, got %d", rawURL, w.Code) + } } } @@ -2263,7 +2352,7 @@ func testRenderers(t *testing.T) *render.Registry { "outlooks_convective.txt.tmpl": "Convective Outlook\n{{if .Data}}Outlooks: {{len .Data.Outlooks}}\nDiscussions: {{len .Data.Discussions}}{{range .Data.Discussions}}\nDiscussion: {{.Discussion}}{{end}}{{else}}No convective outlook data available.{{end}}", "weatherstories.txt.tmpl": "Weather Stories", "weatherstories_latest.txt.tmpl": "Latest Weather Story", - "alerts_active.txt.tmpl": "Alerts text", + "alerts_active.txt.tmpl": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{else}}No active alerts data available.{{end}}", "conditions_current.txt.tmpl": "Conditions text", } { tmpl, err := template.New(name).Parse(body) @@ -2297,6 +2386,13 @@ func setOutlookNowForTest(t *testing.T, now time.Time) { t.Cleanup(func() { outlookNow = original }) } +func setAlertNowForTest(t *testing.T, now time.Time) { + t.Helper() + original := alertNow + alertNow = func() time.Time { return now } + t.Cleanup(func() { alertNow = original }) +} + func testOutlookRun() *model.WeatherOutlookRun { issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) discussionUpdatedAt := issuedAt.Add(30 * time.Minute) diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go index b9fc0ce..81af5a9 100644 --- a/internal/adapters/inbound/httpapi/service.go +++ b/internal/adapters/inbound/httpapi/service.go @@ -4,6 +4,7 @@ package httpapi import ( "context" + "time" "gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherfeeder/model" @@ -18,6 +19,7 @@ type Service interface { LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) + LatestActiveAlertRun(ctx context.Context, activeAt time.Time) (*model.WeatherAlertRun, error) LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) }