// 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
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) 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
}
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) {
h := newHandler(t, &fakeService{
alerts: &model.WeatherAlertRun{
AsOf: time.Now().UTC(),
Alerts: []model.WeatherAlert{{
ID: "abc",
Headline: "A headline",
}},
},
}, "/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"])
}
}
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 TestAlertsRejectPrecisionQueryParameter(t *testing.T) {
h := newHandler(t, &fakeService{
alerts: &model.WeatherAlertRun{AsOf: time.Now().UTC()},
}, "/alerts/active")
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/alerts/active?precision=1", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
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 []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&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")
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 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": "Alerts text",
"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 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))
}
}