diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 5686159..af7b192 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -11,6 +11,7 @@ func Definitions(svc Service) []endpoint.Definition { conditionsDefinition(svc), } defs = append(defs, weatherStoriesDefinitions(svc)...) + defs = append(defs, outlookDefinitions(svc)...) defs = append(defs, discussionDefinitions(svc)...) defs = append(defs, forecastDefinitions(svc)...) return defs diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index c4cc455..12b0bd0 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -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", diff --git a/internal/adapters/inbound/httpapi/outlooks_endpoint.go b/internal/adapters/inbound/httpapi/outlooks_endpoint.go new file mode 100644 index 0000000..d11001c --- /dev/null +++ b/internal/adapters/inbound/httpapi/outlooks_endpoint.go @@ -0,0 +1,63 @@ +// outlooks_endpoint.go defines convective outlook endpoint behavior. +// Layer: adapters/inbound/httpapi outlook routes. +package httpapi + +import ( + "context" + "net/http" + "time" + + "gitea.maximumdirect.net/ejr/feedapi/endpoint" + "gitea.maximumdirect.net/ejr/feedapi/render" + "gitea.maximumdirect.net/ejr/feedapi/response" + "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" +) + +type outlookFilterMode int + +const ( + outlookFilterUser outlookFilterMode = iota + outlookFilterActive + outlookFilterLocation +) + +var outlookNow = time.Now + +func outlookDefinitions(svc Service) []endpoint.Definition { + return []endpoint.Definition{ + outlookDefinition("/outlooks/convective", outlookFilterUser, bindOutlookQuery, svc), + outlookDefinition("/outlooks/convective/active", outlookFilterActive, bindOutlookQuery, svc), + outlookDefinition("/outlooks/convective/location", outlookFilterLocation, bindOutlookLocationQuery, svc), + } +} + +func outlookDefinition( + path string, + mode outlookFilterMode, + binder func(*http.Request) (outlookQueryRequest, error), + svc Service, +) endpoint.Definition { + return endpoint.GET( + path, + binder, + func(ctx context.Context, req outlookQueryRequest) (any, error) { + filter := req.Filter + if mode == outlookFilterActive || mode == outlookFilterLocation { + activeAt := outlookNow().UTC() + filter.ActiveAt = &activeAt + } + if mode == outlookFilterLocation { + containsLocation := true + filter.ContainsLocation = &containsLocation + } + + run, err := svc.LatestConvectiveOutlook(ctx, filter) + if err != nil { + return nil, err + } + return response.Envelope{Data: presenter.OutlookRunPayload(run, req.Units, req.Timezone)}, nil + }, + endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText), + endpoint.WithTemplate("outlooks_convective.txt.tmpl"), + ) +} diff --git a/internal/adapters/inbound/httpapi/presenter/outlook.go b/internal/adapters/inbound/httpapi/presenter/outlook.go new file mode 100644 index 0000000..c41e297 --- /dev/null +++ b/internal/adapters/inbound/httpapi/presenter/outlook.go @@ -0,0 +1,16 @@ +// outlook.go presents convective outlook payloads. +// Layer: adapters/inbound/httpapi/presenter outlook payload mapping. +package presenter + +import ( + "time" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func OutlookRunPayload(run *model.WeatherOutlookRun, _ Units, _ *time.Location) any { + if run == nil { + return nil + } + return run +} diff --git a/internal/adapters/inbound/httpapi/query_bind.go b/internal/adapters/inbound/httpapi/query_bind.go index bb52a45..a01e61d 100644 --- a/internal/adapters/inbound/httpapi/query_bind.go +++ b/internal/adapters/inbound/httpapi/query_bind.go @@ -8,7 +8,9 @@ import ( "time" "gitea.maximumdirect.net/ejr/feedapi/bind" + apierrors "gitea.maximumdirect.net/ejr/feedapi/errors" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" + "gitea.maximumdirect.net/ejr/weatherapi/internal/app" ) type queryRequest struct { @@ -26,6 +28,12 @@ type timezoneQueryRequest struct { Timezone *time.Location } +type outlookQueryRequest struct { + Units presenter.Units + Timezone *time.Location + Filter app.OutlookFilter +} + func bindQuery(r *http.Request) (queryRequest, error) { normalizeCommonQueryValue(r, "units") normalizeCommonQueryValue(r, "format") @@ -135,3 +143,91 @@ func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQ Timezone: tz, }, nil } + +func bindOutlookQuery(r *http.Request) (outlookQueryRequest, error) { + return bindOutlookQueryInternal(r, true) +} + +func bindOutlookLocationQuery(r *http.Request) (outlookQueryRequest, error) { + return bindOutlookQueryInternal(r, false) +} + +func bindOutlookQueryInternal(r *http.Request, allowContainsLocation bool) (outlookQueryRequest, error) { + normalizeCommonQueryValue(r, "units") + normalizeCommonQueryValue(r, "format") + normalizeCommonQueryValue(r, "outlookType") + + allowedExtra := []string{"tz", "TZ", "day", "outlookType"} + if allowContainsLocation { + allowedExtra = append(allowedExtra, "containsLocation") + } + + common, err := bind.CommonQueryParams(r, bind.QueryPolicy{ + AllowUnits: true, + AllowFormat: true, + DefaultUnits: string(presenter.UnitsMetric), + RejectUnknown: true, + }, allowedExtra...) + if err != nil { + return outlookQueryRequest{}, err + } + + units := presenter.Units(strings.ToLower(strings.TrimSpace(common.Units))) + if units == "" { + units = presenter.UnitsMetric + } + + tz, err := parseTimezoneQuery(r) + if err != nil { + return outlookQueryRequest{}, err + } + + filter, err := bindOutlookFilter(r, allowContainsLocation) + if err != nil { + return outlookQueryRequest{}, err + } + + return outlookQueryRequest{ + Units: units, + Timezone: tz, + Filter: filter, + }, nil +} + +func bindOutlookFilter(r *http.Request, allowContainsLocation bool) (app.OutlookFilter, error) { + var filter app.OutlookFilter + + if strings.TrimSpace(r.URL.Query().Get("day")) != "" { + day, err := bind.OptionalInt(r, "day", 0) + if err != nil { + return app.OutlookFilter{}, err + } + if day < 1 || day > 3 { + return app.OutlookFilter{}, apierrors.InvalidParameter("day must be one of [1, 2, 3]") + } + filter.Day = &day + } + + outlookType := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("outlookType"))) + if outlookType != "" { + switch outlookType { + case "categorical", "tornado", "hail", "wind": + filter.OutlookType = outlookType + default: + return app.OutlookFilter{}, apierrors.InvalidParameter("outlookType must be one of [categorical, tornado, hail, wind]") + } + } + + if strings.TrimSpace(r.URL.Query().Get("containsLocation")) != "" { + if !allowContainsLocation { + return app.OutlookFilter{}, apierrors.InvalidParameter("containsLocation is not allowed on this endpoint") + } + containsLocation, err := bind.OptionalBool(r, "containsLocation", false) + if err != nil { + return app.OutlookFilter{}, err + } + filter.ContainsLocation = &containsLocation + } + + return filter, nil +} diff --git a/internal/adapters/inbound/httpapi/service.go b/internal/adapters/inbound/httpapi/service.go index bd5b7a7..b9fc0ce 100644 --- a/internal/adapters/inbound/httpapi/service.go +++ b/internal/adapters/inbound/httpapi/service.go @@ -18,5 +18,6 @@ type Service interface { LatestWeatherStoryRun(ctx context.Context) (*model.WeatherStoryRun, error) LatestWeatherStory(ctx context.Context) (*model.WeatherStory, error) LatestAlertRun(ctx context.Context) (*model.WeatherAlertRun, error) + LatestConvectiveOutlook(ctx context.Context, filter app.OutlookFilter) (*model.WeatherOutlookRun, error) CurrentConditions(ctx context.Context) (*app.CurrentConditions, error) }