2461 lines
80 KiB
Go
2461 lines
80 KiB
Go
// 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(), "<LocationID>stl</LocationID>") {
|
|
t.Fatalf("expected outlook XML payload, got %q", w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "<Discussions>") || !strings.Contains(w.Body.String(), "<Headline>Day 1 headline</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))
|
|
}
|
|
}
|