Add convective outlook HTTP routes

This commit is contained in:
2026-06-11 15:38:19 +00:00
parent 63c8f33a2a
commit d6734d5ffc
6 changed files with 419 additions and 0 deletions

View File

@@ -30,6 +30,8 @@ type fakeService struct {
weatherStoryRun *model.WeatherStoryRun
weatherStory *model.WeatherStory
alerts *model.WeatherAlertRun
outlookRun *model.WeatherOutlookRun
outlookFilters []app.OutlookFilter
conditions *app.CurrentConditions
err error
}
@@ -62,6 +64,11 @@ func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, e
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
}
func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions, error) {
return s.conditions, s.err
}
@@ -1272,6 +1279,208 @@ func TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
}
}
func TestOutlookRoutesRegistered(t *testing.T) {
defs := Definitions(&fakeService{})
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
def := definitionForPath(t, defs, path)
if len(def.Methods) != 1 || def.Methods[0] != http.MethodGet {
t.Fatalf("%s: expected GET definition, got %+v", path, def.Methods)
}
}
}
func TestOutlookRoutesJSONSuccess(t *testing.T) {
setOutlookNowForTest(t, time.Date(2026, 6, 11, 15, 0, 0, 0, time.UTC))
for _, path := range []string{
"/outlooks/convective",
"/outlooks/convective/active",
"/outlooks/convective/location",
} {
t.Run(path, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, path)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []struct {
ID string `json:"id"`
} `json:"outlooks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
if payload.Data.LocationID != "stl" {
t.Fatalf("expected locationId stl, got %q", payload.Data.LocationID)
}
if len(payload.Data.Outlooks) != 1 || payload.Data.Outlooks[0].ID != "cat-1" {
t.Fatalf("unexpected outlooks payload: %+v", payload.Data.Outlooks)
}
})
}
}
func TestOutlookNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective", 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 TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) {
run := testOutlookRun()
run.Outlooks = []model.WeatherOutlook{}
h := newHandler(t, &fakeService{outlookRun: run}, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=3", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var payload struct {
Data struct {
LocationID string `json:"locationId"`
Outlooks []model.WeatherOutlook `json:"outlooks"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode outlook payload: %v", err)
}
if payload.Data.LocationID != "stl" {
t.Fatalf("expected metadata to remain populated, got %+v", payload.Data)
}
if payload.Data.Outlooks == nil || len(payload.Data.Outlooks) != 0 {
t.Fatalf("expected empty outlooks slice, got %+v", payload.Data.Outlooks)
}
}
func TestOutlookQueryParamsConstructFilter(t *testing.T) {
svc := &fakeService{outlookRun: testOutlookRun()}
h := newHandler(t, svc, "/outlooks/convective")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?day=2&outlookType=Tornado&containsLocation=true&tz=CDT&units=US", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(svc.outlookFilters) != 1 {
t.Fatalf("expected one filter, got %d", len(svc.outlookFilters))
}
filter := svc.outlookFilters[0]
if filter.Day == nil || *filter.Day != 2 {
t.Fatalf("expected day filter 2, got %+v", filter.Day)
}
if filter.OutlookType != "tornado" {
t.Fatalf("expected outlookType tornado, got %q", filter.OutlookType)
}
if filter.ContainsLocation == nil || !*filter.ContainsLocation {
t.Fatalf("expected containsLocation true, got %+v", filter.ContainsLocation)
}
if filter.ActiveAt != nil {
t.Fatalf("expected no active filter, got %v", filter.ActiveAt)
}
}
func TestOutlookActiveAndLocationFiltersUseNow(t *testing.T) {
now := time.Date(2026, 6, 11, 15, 30, 0, 0, time.FixedZone("CDT", -5*3600))
setOutlookNowForTest(t, now)
activeSvc := &fakeService{outlookRun: testOutlookRun()}
activeHandler := newHandler(t, activeSvc, "/outlooks/convective/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/outlooks/convective/active?day=1", nil)
activeHandler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected active 200, got %d", w.Code)
}
activeFilter := activeSvc.outlookFilters[0]
if activeFilter.ActiveAt == nil || !activeFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected activeAt %s, got %v", now.UTC(), activeFilter.ActiveAt)
}
if activeFilter.ContainsLocation != nil {
t.Fatalf("expected active route not to force containsLocation, got %+v", activeFilter.ContainsLocation)
}
locationSvc := &fakeService{outlookRun: testOutlookRun()}
locationHandler := newHandler(t, locationSvc, "/outlooks/convective/location")
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/outlooks/convective/location?outlookType=hail", nil)
locationHandler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected location 200, got %d", w.Code)
}
locationFilter := locationSvc.outlookFilters[0]
if locationFilter.ActiveAt == nil || !locationFilter.ActiveAt.Equal(now.UTC()) {
t.Fatalf("expected location activeAt %s, got %v", now.UTC(), locationFilter.ActiveAt)
}
if locationFilter.ContainsLocation == nil || !*locationFilter.ContainsLocation {
t.Fatalf("expected location route to force containsLocation true, got %+v", locationFilter.ContainsLocation)
}
if locationFilter.OutlookType != "hail" {
t.Fatalf("expected outlookType hail, got %q", locationFilter.OutlookType)
}
}
func TestOutlookInvalidQueryParamsReturnBadRequest(t *testing.T) {
for _, rawURL := range []string{
"/outlooks/convective?precision=1",
"/outlooks/convective?bogus=1",
"/outlooks/convective?day=0",
"/outlooks/convective?day=4",
"/outlooks/convective?day=two",
"/outlooks/convective?outlookType=snow",
"/outlooks/convective?containsLocation=maybe",
"/outlooks/convective?tz=not-a-timezone",
"/outlooks/convective?tz=CDT&TZ=EST",
"/outlooks/convective/location?containsLocation=true",
} {
t.Run(rawURL, func(t *testing.T) {
h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, strings.Split(rawURL, "?")[0])
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)
}
})
}
}
func TestDiscussionNoDataReturnsNullEnvelopeData(t *testing.T) {
h := newHandler(t, &fakeService{}, "/discussion")
@@ -1965,6 +2174,7 @@ func testRenderers(t *testing.T) *render.Registry {
"discussion_long_term.txt.tmpl": "Forecast Discussion Long Term",
"forecast_hourly.txt.tmpl": "Forecast text",
"forecast_narrative.txt.tmpl": "Narrative Forecast",
"outlooks_convective.txt.tmpl": "Convective Outlook",
"weatherstories.txt.tmpl": "Weather Stories",
"weatherstories_latest.txt.tmpl": "Latest Weather Story",
"alerts_active.txt.tmpl": "Alerts text",
@@ -1994,6 +2204,38 @@ func wmoCodePtr(v model.WMOCode) *model.WMOCode {
return &out
}
func setOutlookNowForTest(t *testing.T, now time.Time) {
t.Helper()
original := outlookNow
outlookNow = func() time.Time { return now }
t.Cleanup(func() { outlookNow = original })
}
func testOutlookRun() *model.WeatherOutlookRun {
issuedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
return &model.WeatherOutlookRun{
LocationID: "stl",
LocationName: "St. Louis",
AsOf: issuedAt,
IssuedAt: &issuedAt,
Outlooks: []model.WeatherOutlook{{
ID: "cat-1",
Provider: "spc",
Product: "convective",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
ValidFrom: issuedAt,
ValidTo: issuedAt.Add(6 * time.Hour),
IssuedAt: issuedAt,
ExpiresAt: issuedAt.Add(6 * time.Hour),
ContainsLocation: true,
Geometry: []byte(`{"type":"Point","coordinates":[-90.2,38.6]}`),
}},
}
}
func sampleWeatherStoryRun() *model.WeatherStoryRun {
return &model.WeatherStoryRun{
OfficeID: "LSX",