All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
1146 lines
35 KiB
Go
1146 lines
35 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
|
|
alerts *model.WeatherAlertRun
|
|
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) LatestAlertRun(context.Context) (*model.WeatherAlertRun, error) {
|
|
return s.alerts, 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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 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 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",
|
|
"forecast_hourly.txt.tmpl": "Forecast text",
|
|
"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
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
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))
|
|
}
|
|
}
|