// endpoints_test.go validates HTTP endpoint behavior and format negotiation. // Layer: adapters/inbound/httpapi endpoint regression tests. package httpapi import ( "context" "encoding/json" "math" "net/http" "net/http/httptest" "strings" "testing" "text/template" "time" "gitea.maximumdirect.net/ejr/feedapi/endpoint" apierrors "gitea.maximumdirect.net/ejr/feedapi/errors" "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/templates" "gitea.maximumdirect.net/ejr/feedapi/transport/httpx" "gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) type fakeService struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun narrativeForecast *model.WeatherForecastRun discussion *model.WeatherForecastDiscussion weatherStoryRun *model.WeatherStoryRun weatherStory *model.WeatherStory alerts *model.WeatherAlertRun activeAlertAt []time.Time outlookRun *model.WeatherOutlookRun outlookFilters []app.OutlookFilter conditions *app.CurrentConditions err error } func (s *fakeService) LatestObservation(context.Context) (*model.WeatherObservation, error) { return s.observation, s.err } func (s *fakeService) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) { return s.forecast, s.err } func (s *fakeService) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) { return s.narrativeForecast, s.err } func (s *fakeService) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) { return s.discussion, s.err } func (s *fakeService) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) { return s.weatherStoryRun, s.err } func (s *fakeService) LatestWeatherStory(context.Context) (*model.WeatherStory, error) { return s.weatherStory, s.err } func (s *fakeService) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { 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 } func (s *fakeService) CurrentConditions(context.Context) (*app.CurrentConditions, error) { return s.conditions, s.err } type alertRepository struct { alerts *model.WeatherAlertRun } func (r *alertRepository) LatestObservation(context.Context) (*model.WeatherObservation, error) { return nil, nil } func (r *alertRepository) LatestHourlyForecast(context.Context) (*model.WeatherForecastRun, error) { return nil, nil } func (r *alertRepository) LatestNarrativeForecast(context.Context) (*model.WeatherForecastRun, error) { return nil, nil } func (r *alertRepository) LatestForecastDiscussion(context.Context) (*model.WeatherForecastDiscussion, error) { return nil, nil } func (r *alertRepository) LatestWeatherStoryRun(context.Context) (*model.WeatherStoryRun, error) { return nil, nil } func (r *alertRepository) LatestWeatherStory(context.Context) (*model.WeatherStory, error) { return nil, nil } func (r *alertRepository) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) { return r.alerts, nil } func (r *alertRepository) LatestConvectiveOutlookRun(context.Context) (*model.WeatherOutlookRun, error) { return nil, nil } func (r *alertRepository) CurrentConditions(context.Context, int) (*app.CurrentConditions, error) { return nil, nil } func TestObservationsRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations?bogus=1", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var env apierrors.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatalf("decode error envelope: %v", err) } if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter { t.Fatalf("expected invalid_parameter code, got %+v", env.Error) } } func TestObservationsNoDataReturnsNullEnvelopeData(t *testing.T) { h := newHandler(t, &fakeService{}, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations", 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 TestObservationsPopulatedJSONEnvelope(t *testing.T) { now := time.Date(2026, 3, 19, 18, 0, 0, 0, time.UTC) h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ StationID: "KSTL", StationName: "St. Louis", Timestamp: now, }, }, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { StationID string `json:"stationId"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.StationID != "KSTL" { t.Fatalf("expected stationId KSTL, got %q", payload.Data.StationID) } } func TestObservationsDefaultPrecisionRoundsToInteger(t *testing.T) { h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ Timestamp: time.Now().UTC(), ConditionCode: model.WMOUnknown, TemperatureC: float64Ptr(20.6), }, }, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } got, ok := payload.Data["temperatureC"].(float64) if !ok { t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"]) } if got != 21 { t.Fatalf("expected rounded temperatureC 21, got %v", got) } } func TestObservationsPrecisionTwo(t *testing.T) { h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ Timestamp: time.Now().UTC(), ConditionCode: model.WMOUnknown, TemperatureC: float64Ptr(20.678), }, }, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations?precision=2", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } got, ok := payload.Data["temperatureC"].(float64) if !ok { t.Fatalf("expected temperatureC float, got %#v", payload.Data["temperatureC"]) } if math.Abs(got-20.68) > 0.00001 { t.Fatalf("expected rounded temperatureC 20.68, got %v", got) } } func TestFormatNegotiationCaseInsensitive(t *testing.T) { hXML := newHandler(t, &fakeService{alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}}, "/alerts/active") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/alerts/active?format=XML", nil) hXML.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for xml request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } hText := newHandler(t, &fakeService{forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly}}, "/forecast/hourly") w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=TEXT", nil) hText.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Forecast text") { t.Fatalf("expected rendered text template body, got %q", w.Body.String()) } } func TestObservationUSUnitsChangesFieldNames(t *testing.T) { h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ StationID: "KSTL", Timestamp: time.Now().UTC(), ConditionCode: 1, TemperatureC: float64Ptr(20), }, }, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations?units=US", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if _, ok := payload.Data["temperatureC"]; ok { t.Fatalf("expected temperatureC to be omitted in US payload") } v, ok := payload.Data["temperatureF"].(float64) if !ok { t.Fatalf("expected temperatureF in US payload, got %#v", payload.Data["temperatureF"]) } if math.Abs(v-68.0) > 0.0001 { t.Fatalf("expected temperatureF ~= 68, got %f", v) } } func TestAlertsUSUnitsKeepSchema(t *testing.T) { ends := time.Date(2026, 6, 11, 14, 0, 0, 0, time.UTC) h := newHandler(t, &fakeService{ alerts: &model.WeatherAlertRun{ AsOf: time.Now().UTC(), Alerts: []model.WeatherAlert{{ ID: "abc", Headline: "A headline", Ends: &ends, }}, }, }, "/alerts/active") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/alerts/active?units=us", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } alerts, ok := payload.Data["alerts"].([]any) if !ok || len(alerts) != 1 { t.Fatalf("expected one alert in response, got %#v", payload.Data["alerts"]) } first, ok := alerts[0].(map[string]any) if !ok { t.Fatalf("expected first alert object, got %#v", alerts[0]) } if first["id"] != "abc" { t.Fatalf("expected alert id abc, got %#v", first["id"]) } if _, ok := first["ends"].(string); !ok { t.Fatalf("expected alert ends string, got %#v", first["ends"]) } } 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 TestAlertsTextRendersNoData(t *testing.T) { h := newHandler(t, &fakeService{}, "/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(), "No active alerts data available.") { t.Fatalf("expected no-data alert text, got %q", w.Body.String()) } } 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 TestAlertsTextOmitsInactiveAlertsAfterServiceFiltering(t *testing.T) { activeAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) setAlertNowForTest(t, activeAt) effective := activeAt.Add(-1 * time.Hour) activeEnds := activeAt.Add(1 * time.Hour) expiredEndsAtBoundary := activeAt expiredProviderMetadata := activeAt.Add(-30 * time.Minute) activeProviderMetadata := activeAt.Add(30 * time.Minute) repo := &alertRepository{ alerts: &model.WeatherAlertRun{ AsOf: activeAt, Alerts: []model.WeatherAlert{ { ID: "active-alert", Headline: "Active warning", MessageType: "Alert", Effective: &effective, Ends: &activeEnds, Expires: &expiredProviderMetadata, }, { ID: "expired-alert", Headline: "Expired warning", MessageType: "Alert", Effective: &effective, Ends: &expiredEndsAtBoundary, Expires: &activeProviderMetadata, }, { ID: "canceled-alert", Headline: "Canceled warning", MessageType: " cancel ", Effective: &effective, Ends: &activeEnds, }, }, }, } h := newHandler(t, app.NewService(repo), "/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) } body := w.Body.String() for _, want := range []string{"Alerts: 1", "active-alert", "Active warning", "Ends:"} { if !strings.Contains(body, want) { t.Fatalf("expected %q in text body, got %q", want, body) } } for _, notWant := range []string{"expired-alert", "Expired warning", "canceled-alert", "Canceled warning"} { if strings.Contains(body, notWant) { t.Fatalf("did not expect %q in text body, got %q", notWant, body) } } } func TestObservationUSUnitsWithXMLFormat(t *testing.T) { h := newHandler(t, &fakeService{ observation: &model.WeatherObservation{ StationID: "KSTL", Timestamp: time.Now().UTC(), ConditionCode: 1, TemperatureC: float64Ptr(20), }, }, "/observations") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/observations?format=xml&units=us", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } if strings.Contains(w.Body.String(), "temperatureC") { t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", w.Body.String()) } if !strings.Contains(w.Body.String(), "temperatureF") { t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) } } func TestForecastUSUnitsWithXMLFormatUppercaseQuery(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC(), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10), }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?format=XML&units=US", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } body := w.Body.String() if strings.Contains(body, "temperatureC") { t.Fatalf("expected metric field temperatureC to be omitted in XML payload: %s", body) } if !strings.Contains(body, "temperatureF") { t.Fatalf("expected US field temperatureF in XML payload: %s", body) } } func TestForecastPrecisionTwo(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC(), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), TemperatureC: float64Ptr(12.345), }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?precision=2", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } periods, ok := payload.Data["periods"].([]any) if !ok || len(periods) == 0 { t.Fatalf("expected non-empty periods, got %#v", payload.Data["periods"]) } first, ok := periods[0].(map[string]any) if !ok { t.Fatalf("expected first period object, got %#v", periods[0]) } got, ok := first["temperatureC"].(float64) if !ok { t.Fatalf("expected temperatureC float, got %#v", first["temperatureC"]) } if math.Abs(got-12.35) > 0.00001 { t.Fatalf("expected rounded temperatureC 12.35, got %v", got) } } func TestForecastJSONOmitsLegacyDescriptionFields(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC(), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Cloudy", }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { Periods []map[string]any `json:"periods"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode forecast payload: %v", err) } if len(payload.Data.Periods) == 0 { t.Fatalf("expected at least one period") } period := payload.Data.Periods[0] for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} { if _, ok := period[key]; ok { t.Fatalf("unexpected legacy field %q in forecast response period: %#v", key, period) } } if period["textDescription"] != "Cloudy" { t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"]) } } func TestForecastTimezoneOffsetUppercaseTZConvertsAllTimes(t *testing.T) { issuedAt := time.Date(2026, 7, 10, 15, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) periodStart := issuedAt.Add(time.Hour) periodEnd := periodStart.Add(time.Hour) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: issuedAt, UpdatedAt: &updatedAt, Periods: []model.WeatherForecastPeriod{{ StartTime: periodStart, EndTime: periodEnd, ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?TZ=-5", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) if payload.Data.UpdatedAt == nil { t.Fatalf("expected updatedAt in payload") } assertOffsetSeconds(t, *payload.Data.UpdatedAt, -5*60*60) assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) assertOffsetSeconds(t, payload.Data.Periods[0].EndTime, -5*60*60) if !payload.Data.IssuedAt.UTC().Equal(issuedAt) { t.Fatalf("expected issuedAt instant to be preserved") } } func TestForecastTimezoneAbbreviationCDT(t *testing.T) { issuedAt := time.Date(2026, 1, 10, 12, 0, 0, 0, time.UTC) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: issuedAt, Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) } func TestForecastTimezoneCityAliasChicago(t *testing.T) { issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: issuedAt, Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=Chicago", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) assertOffsetSeconds(t, payload.Data.IssuedAt, -5*60*60) assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) } func TestForecastTimezoneInvalidValueRejected(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=not-a-timezone", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var env apierrors.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatalf("decode error envelope: %v", err) } if env.Error == nil || env.Error.Code != apierrors.CodeInvalidParameter { t.Fatalf("expected invalid_parameter code, got %+v", env.Error) } } func TestForecastTimezoneConflictingKeyValuesRejected(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, }, "/forecast/hourly") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly?tz=CDT&TZ=EST", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastHourlyTodayFiltersByStartDateUTCDefault(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 10, 0, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 1, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 2, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 3, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 2 { t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods)) } for _, p := range payload.Data.Periods { y, m, d := p.StartTime.UTC().Date() if y != 2026 || m != time.July || d != 10 { t.Fatalf("expected start date 2026-07-10 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339)) } } } func TestForecastHourlyTomorrowFiltersByStartDateUTCDefault(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 15, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 16, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 2 { t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods)) } for _, p := range payload.Data.Periods { y, m, d := p.StartTime.UTC().Date() if y != 2026 || m != time.July || d != 11 { t.Fatalf("expected start date 2026-07-11 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339)) } } } func TestForecastHourlyTodayTimezoneAffectsDaySlice(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 1 { t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods)) } if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC)) { t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339)) } assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) } func TestForecastHourlyTomorrowTimezoneAffectsDaySlice(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 4, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 6, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/hourly/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 1 { t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods)) } if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC)) { t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339)) } assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) } func TestForecastHourlyTodaySupportsSameFlags(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 14, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10.123), }}, }, }, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?format=TEXT&units=US&precision=2&tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Forecast text") { t.Fatalf("expected rendered text template body, got %q", w.Body.String()) } } func TestForecastHourlyTomorrowSupportsSameFlags(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(10.123), }}, }, }, "/forecast/hourly/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?format=XML&units=US&precision=2&tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } } func TestForecastHourlyTodayRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?bogus=1", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastHourlyTomorrowRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/forecast/hourly/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?bogus=1", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastHourlyTodayTimezoneValidation(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, }, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today?tz=not-a-timezone", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastHourlyTomorrowTimezoneValidation(t *testing.T) { h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC()}, }, "/forecast/hourly/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/tomorrow?tz=CDT&TZ=EST", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastHourlyTodayNoMatchingPeriodsReturnsDataWithEmptyPeriods(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ forecast: &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 11, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 14, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown), }}, }, }, "/forecast/hourly/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/hourly/today", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayloadAllowEmpty(t, w) if len(payload.Data.Periods) != 0 { t.Fatalf("expected empty periods, got %d", len(payload.Data.Periods)) } if payload.Data.IssuedAt.IsZero() { t.Fatalf("expected metadata fields to remain populated") } } func TestForecastNarrativeNoDataReturnsNullEnvelopeData(t *testing.T) { h := newHandler(t, &fakeService{}, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", 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 TestForecastNarrativePopulatedJSONEnvelope(t *testing.T) { h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{ Product: model.ForecastProductNarrative, IssuedAt: time.Now().UTC(), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Now().UTC(), EndTime: time.Now().UTC().Add(12 * time.Hour), Name: "Tonight", ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Mostly clear overnight.", }}, }, }, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { Product string `json:"product"` Periods []struct { Name string `json:"name"` } `json:"periods"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.Product != "narrative" { t.Fatalf("expected product narrative, got %q", payload.Data.Product) } if len(payload.Data.Periods) != 1 || payload.Data.Periods[0].Name != "Tonight" { t.Fatalf("unexpected narrative periods payload: %+v", payload.Data.Periods) } } func TestForecastNarrativeTextFormatUsesNarrativeTemplate(t *testing.T) { h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{ Product: model.ForecastProductNarrative, IssuedAt: time.Now().UTC(), }, }, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=TEXT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Narrative Forecast") { t.Fatalf("expected narrative text template body, got %q", w.Body.String()) } } func TestForecastNarrativeSupportsSameFlags(t *testing.T) { h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{ Product: model.ForecastProductNarrative, IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 7, 10, 13, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 1, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(1), TemperatureC: float64Ptr(20.123), }}, }, }, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?format=XML&units=US&precision=2&tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } } func TestForecastNarrativeRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?bogus=1", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastNarrativeTimezoneValidation(t *testing.T) { h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{Product: model.ForecastProductNarrative, IssuedAt: time.Now().UTC()}, }, "/forecast/narrative") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=not-a-timezone", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/forecast/narrative?tz=CDT&TZ=EST", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestForecastNarrativeTodayTimezoneAffectsDaySlice(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{ Product: model.ForecastProductNarrative, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 10, 4, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 10, 5, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 15, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 5, 30, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 17, 30, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/narrative/today") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/today?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 1 { t.Fatalf("expected 1 period, got %d", len(payload.Data.Periods)) } if !payload.Data.Periods[0].StartTime.UTC().Equal(time.Date(2026, 7, 11, 3, 30, 0, 0, time.UTC)) { t.Fatalf("unexpected filtered period start: %s", payload.Data.Periods[0].StartTime.UTC().Format(time.RFC3339)) } assertOffsetSeconds(t, payload.Data.Periods[0].StartTime, -5*60*60) } func TestForecastNarrativeTomorrowFiltersByStartDateUTCDefault(t *testing.T) { setForecastNowForTest(t, time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)) h := newHandler(t, &fakeService{ narrativeForecast: &model.WeatherForecastRun{ Product: model.ForecastProductNarrative, IssuedAt: time.Date(2026, 7, 10, 11, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{ {StartTime: time.Date(2026, 7, 10, 23, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 11, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, {StartTime: time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 7, 12, 0, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown)}, }, }, }, "/forecast/narrative/tomorrow") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/forecast/narrative/tomorrow", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } payload := decodeForecastTimePayload(t, w) if len(payload.Data.Periods) != 2 { t.Fatalf("expected 2 periods, got %d", len(payload.Data.Periods)) } for _, p := range payload.Data.Periods { y, m, d := p.StartTime.UTC().Date() if y != 2026 || m != time.July || d != 11 { t.Fatalf("expected start date 2026-07-11 UTC, got %s", p.StartTime.UTC().Format(time.RFC3339)) } } } func TestCurrentConditionsRejectTimezoneQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current", 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 TestCurrentConditionsMetricDefaultJSON(t *testing.T) { h := newHandler(t, &fakeService{ conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), ApparentTemperatureC: float64Ptr(9), DewpointC: float64Ptr(5), RelativeHumidityPercent: float64Ptr(75), WindSpeedKmh: float64Ptr(18), WindDirectionDegrees: float64Ptr(135), ConditionCode: 63, }, }, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if _, ok := payload.Data["temperatureC"]; !ok { t.Fatalf("expected temperatureC in metric payload") } if _, ok := payload.Data["temperatureF"]; ok { t.Fatalf("expected temperatureF omitted in metric payload") } if payload.Data["conditionText"] != "Rain" { t.Fatalf("expected conditionText Rain, got %#v", payload.Data["conditionText"]) } } func TestCurrentConditionsUSJSON(t *testing.T) { h := newHandler(t, &fakeService{ conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), ApparentTemperatureC: float64Ptr(9), DewpointC: float64Ptr(5), RelativeHumidityPercent: float64Ptr(75), WindSpeedKmh: float64Ptr(18), WindDirectionDegrees: float64Ptr(135), ConditionCode: 63, }, }, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current?units=us", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if _, ok := payload.Data["temperatureF"]; !ok { t.Fatalf("expected temperatureF in us payload") } if _, ok := payload.Data["temperatureC"]; ok { t.Fatalf("expected temperatureC omitted in us payload") } if _, ok := payload.Data["windSpeedMph"]; !ok { t.Fatalf("expected windSpeedMph in us payload") } if _, ok := payload.Data["windSpeedKmh"]; ok { t.Fatalf("expected windSpeedKmh omitted in us payload") } } func TestCurrentConditionsXMLAndTextFormats(t *testing.T) { h := newHandler(t, &fakeService{ conditions: &app.CurrentConditions{ TemperatureC: float64Ptr(10), WindSpeedKmh: float64Ptr(18), ConditionCode: 2, }, }, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current?format=xml&units=us", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "temperatureF") { t.Fatalf("expected US field temperatureF in XML payload: %s", w.Body.String()) } w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/conditions/current?format=TEXT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Conditions text") { t.Fatalf("expected rendered text template body, got %q", w.Body.String()) } } func TestCurrentConditionsRejectUnknownQueryParameter(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current?bogus=1", nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } } func TestPrecisionValidationRange(t *testing.T) { h := newHandler(t, &fakeService{}, "/conditions/current") for _, raw := range []string{"-1", "3", "abc"} { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/conditions/current?precision="+raw, nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for precision=%s, got %d", raw, w.Code) } } } func TestAlertsRejectUnsupportedQueryParameters(t *testing.T) { h := newHandler(t, &fakeService{ alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()}, }, "/alerts/active") 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("%s: expected 400, got %d", rawURL, w.Code) } } } func TestOutlookRoutesRegistered(t *testing.T) { defs := Definitions(&fakeService{}) for _, path := range []string{ "/outlooks/convective", "/outlooks/convective/active", } { 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) } } if endpointExists(defs, "/outlooks/convective/location") { t.Fatal("expected /outlooks/convective/location to be removed") } } 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", } { 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 []map[string]any `json:"outlooks"` Discussions []outlookDiscussionCheck `json:"discussions"` } `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) } for _, field := range []string{"headline", "summary", "discussion"} { if _, ok := payload.Data.Outlooks[0][field]; ok { t.Fatalf("expected outlook polygon to omit %s, got %+v", field, payload.Data.Outlooks[0]) } } if len(payload.Data.Discussions) != 1 { t.Fatalf("expected one discussion, got %+v", payload.Data.Discussions) } if payload.Data.Discussions[0].Day != 1 || payload.Data.Discussions[0].Headline != "Day 1 headline" { t.Fatalf("unexpected discussions payload: %+v", payload.Data.Discussions) } }) } } 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 TestOutlookTextResponseUsesTemplate(t *testing.T) { h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=text", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } body := w.Body.String() if !strings.Contains(body, "Convective Outlook") || !strings.Contains(body, "Outlooks: 1") || !strings.Contains(body, "Discussions: 1") || !strings.Contains(body, "Day 1 discussion") { t.Fatalf("expected outlook text template body, got %q", body) } } func TestOutlookXMLResponseRenders(t *testing.T) { h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?format=xml", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "stl") { t.Fatalf("expected outlook XML payload, got %q", w.Body.String()) } if !strings.Contains(w.Body.String(), "") || !strings.Contains(w.Body.String(), "Day 1 headline") { t.Fatalf("expected outlook XML discussions, got %q", w.Body.String()) } } func TestOutlookFilteredNoMatchReturnsEmptyOutlooks(t *testing.T) { run := testOutlookRun() run.Outlooks = []model.WeatherOutlook{} run.Discussions = []model.WeatherOutlookDiscussion{} 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"` Discussions []model.WeatherOutlookDiscussion `json:"discussions"` } `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) } if payload.Data.Discussions == nil || len(payload.Data.Discussions) != 0 { t.Fatalf("expected empty discussions slice, got %+v", payload.Data.Discussions) } } func TestOutlookTimezoneQuery(t *testing.T) { h := newHandler(t, &fakeService{outlookRun: testOutlookRun()}, "/outlooks/convective") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/outlooks/convective?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload outlookTimePayload if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode outlook payload: %v", err) } assertOffsetSeconds(t, payload.Data.AsOf, -5*60*60) assertOffsetSeconds(t, *payload.Data.IssuedAt, -5*60*60) if len(payload.Data.Outlooks) != 1 { t.Fatalf("expected one outlook, got %+v", payload.Data.Outlooks) } assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidFrom, -5*60*60) assertOffsetSeconds(t, payload.Data.Outlooks[0].ValidTo, -5*60*60) assertOffsetSeconds(t, payload.Data.Outlooks[0].IssuedAt, -5*60*60) assertOffsetSeconds(t, payload.Data.Outlooks[0].ExpiresAt, -5*60*60) if len(payload.Data.Discussions) != 1 || payload.Data.Discussions[0].UpdatedAt == nil { t.Fatalf("expected one discussion with updatedAt, got %+v", payload.Data.Discussions) } assertOffsetSeconds(t, *payload.Data.Discussions[0].UpdatedAt, -5*60*60) } 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&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.ActiveAt != nil { t.Fatalf("expected no active filter, got %v", filter.ActiveAt) } } func TestOutlookActiveFilterUsesNow(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) } } 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=true", "/outlooks/convective/active?containsLocation=true", "/outlooks/convective?tz=not-a-timezone", "/outlooks/convective?tz=CDT&TZ=EST", } { 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") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion", 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 TestDiscussionJSONEnvelope(t *testing.T) { issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC) shortIssuedAt := issuedAt.Add(-5 * time.Minute) h := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{ OfficeID: "LSX", OfficeName: "National Weather Service Saint Louis MO", Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, KeyMessages: []string{"msg one", "msg two"}, ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"}, }, }, "/discussion") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { OfficeID string `json:"officeId"` Product string `json:"product"` KeyMessages []string `json:"keyMessages"` ShortTerm *struct { Text string `json:"text"` } `json:"shortTerm"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.OfficeID != "LSX" { t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID) } if payload.Data.Product != "afd" { t.Fatalf("expected product afd, got %q", payload.Data.Product) } if len(payload.Data.KeyMessages) != 2 { t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages)) } if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" { t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm) } } func TestDiscussionSupportsTextAndXMLFormats(t *testing.T) { hText := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, "/discussion") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion?format=TEXT", nil) hText.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Forecast Discussion") { t.Fatalf("expected rendered text template body, got %q", w.Body.String()) } hXML := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, "/discussion") w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/discussion?format=XML", nil) hXML.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for xml request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } } func TestDiscussionTimezoneQuery(t *testing.T) { issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) shortIssuedAt := issuedAt.Add(-15 * time.Minute) longIssuedAt := issuedAt.Add(15 * time.Minute) for _, tc := range []struct { name string query string want int }{ {name: "abbreviation", query: "/discussion?tz=CDT", want: -5 * 60 * 60}, {name: "alias", query: "/discussion?tz=Chicago", want: -5 * 60 * 60}, {name: "offset", query: "/discussion?TZ=-5", want: -5 * 60 * 60}, } { t.Run(tc.name, func(t *testing.T) { h := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{ Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, UpdatedAt: &updatedAt, ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt}, LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt}, }, }, "/discussion") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tc.query, nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload discussionTimePayload if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode discussion payload: %v", err) } assertOffsetSeconds(t, payload.Data.IssuedAt, tc.want) assertOffsetSeconds(t, *payload.Data.UpdatedAt, tc.want) assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, tc.want) assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, tc.want) }) } } func TestDiscussionRejectsInvalidQueryParameters(t *testing.T) { tests := []string{ "/discussion?bogus=1", "/discussion?precision=1", "/discussion?tz=not-a-timezone", "/discussion?tz=CDT&TZ=EST", } for _, rawURL := range tests { h := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, "/discussion") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, rawURL, nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("%s: expected 400, got %d", rawURL, w.Code) } } } func TestDefinitionsIncludeDiscussion(t *testing.T) { _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion") _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/key-messages") _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/short-term") _ = definitionForPath(t, Definitions(&fakeService{}), "/discussion/long-term") } func TestDefinitionsIncludeWeatherStories(t *testing.T) { _ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories") _ = definitionForPath(t, Definitions(&fakeService{}), "/weatherstories/latest") } func TestWeatherStoriesNoDataReturnsNullEnvelopeData(t *testing.T) { for _, path := range []string{"/weatherstories", "/weatherstories/latest"} { t.Run(path, func(t *testing.T) { h := newHandler(t, &fakeService{}, 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 *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 TestWeatherStoriesJSONEnvelope(t *testing.T) { h := newHandler(t, &fakeService{ weatherStoryRun: sampleWeatherStoryRun(), }, "/weatherstories") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/weatherstories?tz=CDT", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { OfficeID string `json:"officeId"` AsOf string `json:"asOf"` Stories []struct { Title string `json:"title"` UpdatedAt string `json:"updatedAt"` } `json:"stories"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.OfficeID != "LSX" { t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID) } if len(payload.Data.Stories) != 2 { t.Fatalf("expected 2 stories, got %d", len(payload.Data.Stories)) } if payload.Data.Stories[0].Title != "Rain Chances" { t.Fatalf("unexpected first story title: %q", payload.Data.Stories[0].Title) } if !strings.Contains(payload.Data.AsOf, "-05:00") || !strings.Contains(payload.Data.Stories[0].UpdatedAt, "-05:00") { t.Fatalf("expected CDT offset in weather story times, got asOf=%q updatedAt=%q", payload.Data.AsOf, payload.Data.Stories[0].UpdatedAt) } } func TestWeatherStoriesLatestJSONEnvelope(t *testing.T) { run := sampleWeatherStoryRun() h := newHandler(t, &fakeService{ weatherStory: &run.Stories[1], }, "/weatherstories/latest") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/weatherstories/latest?tz=Chicago", nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload struct { Data struct { Title string `json:"title"` UpdatedAt string `json:"updatedAt"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.Title != "More Rain" { t.Fatalf("expected latest story title More Rain, got %q", payload.Data.Title) } if !strings.Contains(payload.Data.UpdatedAt, "-05:00") { t.Fatalf("expected Chicago offset in updatedAt, got %q", payload.Data.UpdatedAt) } } func TestWeatherStoriesFormatNegotiation(t *testing.T) { hText := newHandler(t, &fakeService{ weatherStoryRun: sampleWeatherStoryRun(), }, "/weatherstories") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/weatherstories?format=TEXT&units=US", nil) hText.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), "Weather Stories") { t.Fatalf("expected rendered weather stories template, got %q", w.Body.String()) } run := sampleWeatherStoryRun() hXML := newHandler(t, &fakeService{ weatherStory: &run.Stories[0], }, "/weatherstories/latest") w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, "/weatherstories/latest?format=XML", nil) hXML.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for xml request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } } func TestWeatherStoriesRejectInvalidQuery(t *testing.T) { tests := []struct { path string query string }{ {path: "/weatherstories", query: "/weatherstories?bogus=1"}, {path: "/weatherstories", query: "/weatherstories?precision=1"}, {path: "/weatherstories", query: "/weatherstories?tz=not-a-timezone"}, {path: "/weatherstories", query: "/weatherstories?tz=CDT&TZ=EST"}, {path: "/weatherstories/latest", query: "/weatherstories/latest?bogus=1"}, {path: "/weatherstories/latest", query: "/weatherstories/latest?precision=1"}, {path: "/weatherstories/latest", query: "/weatherstories/latest?tz=not-a-timezone"}, {path: "/weatherstories/latest", query: "/weatherstories/latest?tz=CDT&TZ=EST"}, } for _, tt := range tests { t.Run(tt.query, func(t *testing.T) { run := sampleWeatherStoryRun() h := newHandler(t, &fakeService{ weatherStoryRun: run, weatherStory: &run.Stories[0], }, tt.path) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tt.query, nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } }) } } func TestDiscussionSubresourcesNoDataReturnsNullEnvelopeData(t *testing.T) { for _, path := range []string{ "/discussion/key-messages", "/discussion/short-term", "/discussion/long-term", } { t.Run(path, func(t *testing.T) { h := newHandler(t, &fakeService{}, 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 *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 TestDiscussionSubresourcesJSONEnvelopeFocusedFields(t *testing.T) { issuedAt := time.Date(2026, 3, 29, 0, 24, 0, 0, time.UTC) shortIssuedAt := issuedAt.Add(-5 * time.Minute) longIssuedAt := issuedAt.Add(10 * time.Minute) run := &model.WeatherForecastDiscussion{ OfficeID: "LSX", OfficeName: "National Weather Service Saint Louis MO", Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, KeyMessages: []string{"msg one", "msg two"}, ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"}, } t.Run("key messages", func(t *testing.T) { h := newHandler(t, &fakeService{discussion: run}, "/discussion/key-messages") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion/key-messages", nil) h.ServeHTTP(w, req) var payload struct { Data struct { OfficeID string `json:"officeId"` KeyMessages []string `json:"keyMessages"` ShortTerm any `json:"shortTerm"` LongTerm any `json:"longTerm"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.OfficeID != "LSX" { t.Fatalf("expected officeId LSX, got %q", payload.Data.OfficeID) } if len(payload.Data.KeyMessages) != 2 { t.Fatalf("expected 2 key messages, got %d", len(payload.Data.KeyMessages)) } if payload.Data.ShortTerm != nil || payload.Data.LongTerm != nil { t.Fatalf("unexpected extra fields in key messages payload") } }) t.Run("short term", func(t *testing.T) { h := newHandler(t, &fakeService{discussion: run}, "/discussion/short-term") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion/short-term", nil) h.ServeHTTP(w, req) var payload struct { Data struct { ShortTerm *struct { Text string `json:"text"` } `json:"shortTerm"` KeyMessages any `json:"keyMessages"` LongTerm any `json:"longTerm"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.ShortTerm == nil || payload.Data.ShortTerm.Text != "Short term text" { t.Fatalf("unexpected shortTerm payload: %+v", payload.Data.ShortTerm) } if payload.Data.KeyMessages != nil || payload.Data.LongTerm != nil { t.Fatalf("unexpected extra fields in short term payload") } }) t.Run("long term", func(t *testing.T) { h := newHandler(t, &fakeService{discussion: run}, "/discussion/long-term") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/discussion/long-term", nil) h.ServeHTTP(w, req) var payload struct { Data struct { LongTerm *struct { Text string `json:"text"` } `json:"longTerm"` KeyMessages any `json:"keyMessages"` ShortTerm any `json:"shortTerm"` } `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode envelope: %v", err) } if payload.Data.LongTerm == nil || payload.Data.LongTerm.Text != "Long term text" { t.Fatalf("unexpected longTerm payload: %+v", payload.Data.LongTerm) } if payload.Data.KeyMessages != nil || payload.Data.ShortTerm != nil { t.Fatalf("unexpected extra fields in long term payload") } }) } func TestDiscussionSubresourcesSupportTextAndXMLFormats(t *testing.T) { tests := []struct { path string textContains string }{ {path: "/discussion/key-messages", textContains: "Forecast Discussion Key Messages"}, {path: "/discussion/short-term", textContains: "Forecast Discussion Short Term"}, {path: "/discussion/long-term", textContains: "Forecast Discussion Long Term"}, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { hText := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, tt.path) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tt.path+"?format=TEXT", nil) hText.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for text request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "text/plain") { t.Fatalf("expected text/plain content type, got %q", w.Header().Get("Content-Type")) } if !strings.Contains(w.Body.String(), tt.textContains) { t.Fatalf("expected rendered text template body, got %q", w.Body.String()) } hXML := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, tt.path) w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodGet, tt.path+"?format=XML", nil) hXML.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200 for xml request, got %d", w.Code) } if !strings.Contains(w.Header().Get("Content-Type"), "application/xml") { t.Fatalf("expected xml content type, got %q", w.Header().Get("Content-Type")) } }) } } func TestDiscussionSubresourcesTimezoneQuery(t *testing.T) { issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) shortIssuedAt := issuedAt.Add(-15 * time.Minute) longIssuedAt := issuedAt.Add(15 * time.Minute) tests := []struct { path string query string want int check func(*testing.T, discussionFocusedTimePayload, int) }{ { path: "/discussion/key-messages", query: "/discussion/key-messages?tz=CDT", want: -5 * 60 * 60, check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { assertOffsetSeconds(t, payload.Data.IssuedAt, want) assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) }, }, { path: "/discussion/short-term", query: "/discussion/short-term?tz=Chicago", want: -5 * 60 * 60, check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { assertOffsetSeconds(t, payload.Data.IssuedAt, want) assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) assertOffsetSeconds(t, *payload.Data.ShortTerm.IssuedAt, want) }, }, { path: "/discussion/long-term", query: "/discussion/long-term?TZ=-5", want: -5 * 60 * 60, check: func(t *testing.T, payload discussionFocusedTimePayload, want int) { assertOffsetSeconds(t, payload.Data.IssuedAt, want) assertOffsetSeconds(t, *payload.Data.UpdatedAt, want) assertOffsetSeconds(t, *payload.Data.LongTerm.IssuedAt, want) }, }, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { h := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{ Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, UpdatedAt: &updatedAt, ShortTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &shortIssuedAt}, LongTerm: &model.WeatherForecastDiscussionSection{IssuedAt: &longIssuedAt}, }, }, tt.path) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, tt.query, nil) h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload discussionFocusedTimePayload if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode discussion payload: %v", err) } tt.check(t, payload, tt.want) }) } } func TestDiscussionSubresourcesRejectInvalidQueryParameters(t *testing.T) { tests := []struct { path string urls []string }{ { path: "/discussion/key-messages", urls: []string{ "/discussion/key-messages?bogus=1", "/discussion/key-messages?precision=1", "/discussion/key-messages?tz=not-a-timezone", "/discussion/key-messages?tz=CDT&TZ=EST", }, }, { path: "/discussion/short-term", urls: []string{ "/discussion/short-term?bogus=1", "/discussion/short-term?precision=1", "/discussion/short-term?tz=not-a-timezone", "/discussion/short-term?tz=CDT&TZ=EST", }, }, { path: "/discussion/long-term", urls: []string{ "/discussion/long-term?bogus=1", "/discussion/long-term?precision=1", "/discussion/long-term?tz=not-a-timezone", "/discussion/long-term?tz=CDT&TZ=EST", }, }, } for _, tt := range tests { t.Run(tt.path, func(t *testing.T) { for _, rawURL := range tt.urls { h := newHandler(t, &fakeService{ discussion: &model.WeatherForecastDiscussion{Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Now().UTC()}, }, tt.path) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, rawURL, nil) h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("%s: expected 400, got %d", rawURL, w.Code) } } }) } } func newHandler(t *testing.T, svc Service, path string) http.Handler { t.Helper() def := definitionForPath(t, Definitions(svc), path) return httpx.Adapt(def, httpx.Dependencies{ Renderers: testRenderers(t), DefaultFormat: render.FormatJSON, }) } func definitionForPath(t *testing.T, defs []endpoint.Definition, path string) endpoint.Definition { t.Helper() for _, def := range defs { if def.Path == path { return def } } t.Fatalf("endpoint not found: %s", path) return endpoint.Definition{} } func endpointExists(defs []endpoint.Definition, path string) bool { for _, def := range defs { if def.Path == path { return true } } return false } func testRenderers(t *testing.T) *render.Registry { t.Helper() reg := render.NewRegistry() if err := reg.Register(render.NewJSONRenderer()); err != nil { t.Fatal(err) } if err := reg.Register(render.NewXMLRenderer()); err != nil { t.Fatal(err) } tmplReg := templates.NewRegistry() for name, body := range map[string]string{ "observations.txt.tmpl": "Observation text", "discussion.txt.tmpl": "Forecast Discussion", "discussion_key_messages.txt.tmpl": "Forecast Discussion Key Messages", "discussion_short_term.txt.tmpl": "Forecast Discussion Short Term", "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\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": "{{if .Data}}Active Alerts\nAlerts: {{len .Data.Alerts}}{{range .Data.Alerts}}\n{{.ID}}{{if .Headline}}\nHeadline: {{.Headline}}{{end}}{{if .Ends}}\nEnds: {{.Ends}}{{end}}{{end}}{{else}}No active alerts data available.{{end}}", "conditions_current.txt.tmpl": "Conditions text", } { tmpl, err := template.New(name).Parse(body) if err != nil { t.Fatal(err) } if err := tmplReg.Register(name, tmpl); err != nil { t.Fatal(err) } } if err := reg.Register(templates.NewRenderer(tmplReg)); err != nil { t.Fatal(err) } return reg } func float64Ptr(v float64) *float64 { return &v } func wmoCodePtr(v model.WMOCode) *model.WMOCode { out := v 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 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) 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]}`), }}, Discussions: []model.WeatherOutlookDiscussion{{ Day: 1, Headline: "Day 1 headline", Summary: "Day 1 summary", Discussion: "Day 1 discussion", UpdatedAt: &discussionUpdatedAt, }}, } } func sampleWeatherStoryRun() *model.WeatherStoryRun { return &model.WeatherStoryRun{ OfficeID: "LSX", AsOf: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC), Stories: []model.WeatherStory{ { OfficeID: "LSX", StartTime: time.Date(2026, 5, 30, 13, 46, 0, 0, time.UTC), EndTime: time.Date(2026, 5, 31, 16, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 5, 30, 14, 0, 34, 0, time.UTC), Title: "Rain Chances", Description: "Several chances for rain through Monday.", AltText: "Forecast graphic.", Priority: false, Order: 1, DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", }, { OfficeID: "LSX", StartTime: time.Date(2026, 5, 30, 15, 46, 0, 0, time.UTC), EndTime: time.Date(2026, 5, 31, 18, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 5, 30, 16, 0, 34, 0, time.UTC), Title: "More Rain", Description: "Showers remain possible.", AltText: "Another forecast graphic.", Priority: true, Order: 2, DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-2", }, }, } } type forecastTimePayload struct { Data struct { IssuedAt time.Time `json:"issuedAt"` UpdatedAt *time.Time `json:"updatedAt"` Periods []struct { StartTime time.Time `json:"startTime"` EndTime time.Time `json:"endTime"` } `json:"periods"` } `json:"data"` } type discussionTimePayload struct { Data struct { IssuedAt time.Time `json:"issuedAt"` UpdatedAt *time.Time `json:"updatedAt"` ShortTerm *struct { IssuedAt *time.Time `json:"issuedAt"` } `json:"shortTerm"` LongTerm *struct { IssuedAt *time.Time `json:"issuedAt"` } `json:"longTerm"` } `json:"data"` } type discussionFocusedTimePayload struct { Data struct { IssuedAt time.Time `json:"issuedAt"` UpdatedAt *time.Time `json:"updatedAt"` ShortTerm *struct { IssuedAt *time.Time `json:"issuedAt"` } `json:"shortTerm"` LongTerm *struct { IssuedAt *time.Time `json:"issuedAt"` } `json:"longTerm"` } `json:"data"` } type outlookDiscussionCheck struct { Day int `json:"day"` Headline string `json:"headline"` } type outlookTimePayload struct { Data struct { AsOf time.Time `json:"asOf"` IssuedAt *time.Time `json:"issuedAt"` Outlooks []struct { ValidFrom time.Time `json:"validFrom"` ValidTo time.Time `json:"validTo"` IssuedAt time.Time `json:"issuedAt"` ExpiresAt time.Time `json:"expiresAt"` } `json:"outlooks"` Discussions []struct { UpdatedAt *time.Time `json:"updatedAt"` } `json:"discussions"` } `json:"data"` } func decodeForecastTimePayload(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { t.Helper() payload := decodeForecastTimePayloadAllowEmpty(t, w) if len(payload.Data.Periods) == 0 { t.Fatalf("expected non-empty periods") } return payload } func decodeForecastTimePayloadAllowEmpty(t *testing.T, w *httptest.ResponseRecorder) forecastTimePayload { t.Helper() var payload forecastTimePayload if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode forecast payload: %v", err) } return payload } func setForecastNowForTest(t *testing.T, ts time.Time) { t.Helper() prev := forecastNow forecastNow = func() time.Time { return ts } t.Cleanup(func() { forecastNow = prev }) } func assertOffsetSeconds(t *testing.T, ts time.Time, want int) { t.Helper() _, got := ts.Zone() if got != want { t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339)) } }