Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful

This commit is contained in:
2026-03-26 20:06:17 -05:00
parent 6806de3c0a
commit b3ac19a65d
7 changed files with 457 additions and 17 deletions

View File

@@ -394,6 +394,148 @@ func TestForecastPrecisionTwo(t *testing.T) {
}
}
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 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")
@@ -629,3 +771,35 @@ func testRenderers(t *testing.T) *render.Registry {
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()
var payload forecastTimePayload
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 non-empty periods")
}
return payload
}
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))
}
}

View File

@@ -14,13 +14,13 @@ import (
func forecastDefinition(svc Service) endpoint.Definition {
return endpoint.GET(
"/forecast/hourly",
bindPrecisionQuery,
bindForecastPrecisionQuery,
func(ctx context.Context, req precisionQueryRequest) (any, error) {
run, err := svc.LatestHourlyForecast(ctx)
if err != nil {
return nil, err
}
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision)}, nil
return response.Envelope{Data: presenter.ForecastPayload(run, req.Units, req.Precision, req.Timezone)}, nil
},
endpoint.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),

View File

@@ -51,7 +51,7 @@ type WeatherForecastPeriodUS struct {
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
}
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int) any {
func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int, tz *time.Location) any {
if run == nil {
return nil
}
@@ -59,8 +59,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
out := WeatherForecastRunUS{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: run.IssuedAt,
UpdatedAt: copyTimePtr(run.UpdatedAt),
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
@@ -69,8 +69,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
}
for _, p := range run.Periods {
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
StartTime: p.StartTime,
EndTime: p.EndTime,
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,
@@ -103,8 +103,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
out := model.WeatherForecastRun{
LocationID: run.LocationID,
LocationName: run.LocationName,
IssuedAt: run.IssuedAt,
UpdatedAt: copyTimePtr(run.UpdatedAt),
IssuedAt: inLocationTime(run.IssuedAt, tz),
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
Product: run.Product,
Latitude: copyFloat64Ptr(run.Latitude),
Longitude: copyFloat64Ptr(run.Longitude),
@@ -114,8 +114,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
for _, p := range run.Periods {
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
StartTime: p.StartTime,
EndTime: p.EndTime,
StartTime: inLocationTime(p.StartTime, tz),
EndTime: inLocationTime(p.EndTime, tz),
Name: p.Name,
IsDay: copyBoolPtr(p.IsDay),
ConditionCode: p.ConditionCode,

View File

@@ -47,6 +47,21 @@ func copyTimePtr(v *time.Time) *time.Time {
return &out
}
func inLocationTime(v time.Time, loc *time.Location) time.Time {
if loc == nil {
return v
}
return v.In(loc)
}
func inLocationTimePtr(v *time.Time, loc *time.Location) *time.Time {
if v == nil {
return nil
}
out := inLocationTime(*v, loc)
return &out
}
func boolText(v *bool) string {
if v == nil {
return ""

View File

@@ -64,7 +64,7 @@ func TestForecastPayloadUS(t *testing.T) {
}},
}
payload := ForecastPayload(run, UnitsUS, 2)
payload := ForecastPayload(run, UnitsUS, 2, nil)
converted, ok := payload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
@@ -83,6 +83,81 @@ func TestForecastPayloadUS(t *testing.T) {
assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001)
}
func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) {
loc := time.FixedZone("UTC-05:00", -5*60*60)
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(30 * time.Minute)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt.Add(1 * time.Hour),
EndTime: issuedAt.Add(2 * time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, loc)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
assertOffsetSeconds(t, metric.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60)
if !metric.IssuedAt.UTC().Equal(issuedAt) {
t.Fatalf("expected metric issuedAt to preserve instant")
}
usPayload := ForecastPayload(run, UnitsUS, 0, loc)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
}
assertOffsetSeconds(t, us.IssuedAt, -5*60*60)
assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60)
assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60)
// Source model remains untouched.
assertOffsetSeconds(t, run.IssuedAt, 0)
assertOffsetSeconds(t, *run.UpdatedAt, 0)
}
func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) {
issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
updatedAt := issuedAt.Add(time.Hour)
run := &model.WeatherForecastRun{
Product: model.ForecastProductHourly,
IssuedAt: issuedAt,
UpdatedAt: &updatedAt,
Periods: []model.WeatherForecastPeriod{{
StartTime: issuedAt,
EndTime: issuedAt.Add(time.Hour),
ConditionCode: model.WMOUnknown,
}},
}
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
}
if metric == run {
t.Fatalf("expected metric payload to be copied")
}
if metric.UpdatedAt == run.UpdatedAt {
t.Fatalf("expected updatedAt pointer to be copied")
}
if !metric.IssuedAt.Equal(run.IssuedAt) {
t.Fatalf("expected issuedAt to remain unchanged without timezone flag")
}
assertOffsetSeconds(t, metric.IssuedAt, 0)
assertOffsetSeconds(t, metric.Periods[0].StartTime, 0)
}
func TestMetricCopyAndNilHandling(t *testing.T) {
obs := &model.WeatherObservation{
TemperatureC: float64Ptr(20.6),
@@ -104,7 +179,7 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
if ObservationPayload(nil, UnitsUS, 0) != nil {
t.Fatalf("expected nil observation input to return nil payload")
}
if ForecastPayload(nil, UnitsUS, 0) != nil {
if ForecastPayload(nil, UnitsUS, 0, nil) != nil {
t.Fatalf("expected nil forecast input to return nil payload")
}
if AlertsPayload(nil, UnitsUS) != nil {
@@ -190,7 +265,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
IssuedAt: time.Now().UTC(),
}
metricPayload := ForecastPayload(run, UnitsMetric, 0)
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
metric, ok := metricPayload.(*model.WeatherForecastRun)
if !ok {
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
@@ -199,7 +274,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
assertApprox(t, metric.Longitude, -90.199456, 0.000001)
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
usPayload := ForecastPayload(run, UnitsUS, 0)
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
us, ok := usPayload.(WeatherForecastRunUS)
if !ok {
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
@@ -225,3 +300,11 @@ func assertApprox(t *testing.T, got *float64, want, eps float64) {
t.Fatalf("expected %f +/- %f, got %f", want, eps, *got)
}
}
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))
}
}

View File

@@ -5,6 +5,7 @@ package httpapi
import (
"net/http"
"strings"
"time"
"gitea.maximumdirect.net/ejr/feedapi/bind"
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
@@ -17,6 +18,7 @@ type queryRequest struct {
type precisionQueryRequest struct {
Units presenter.Units
Precision int
Timezone *time.Location
}
func bindQuery(r *http.Request) (queryRequest, error) {
@@ -41,16 +43,29 @@ func bindQuery(r *http.Request) (queryRequest, error) {
}
func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, false)
}
func bindForecastPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
return bindPrecisionQueryInternal(r, true)
}
func bindPrecisionQueryInternal(r *http.Request, allowTimezone bool) (precisionQueryRequest, error) {
normalizeCommonQueryValue(r, "units")
normalizeCommonQueryValue(r, "format")
normalizeCommonQueryValue(r, "precision")
allowedExtra := []string{"precision"}
if allowTimezone {
allowedExtra = append(allowedExtra, "tz", "TZ")
}
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
AllowUnits: true,
AllowFormat: true,
DefaultUnits: string(presenter.UnitsMetric),
RejectUnknown: true,
}, "precision")
}, allowedExtra...)
if err != nil {
return precisionQueryRequest{}, err
}
@@ -70,5 +85,18 @@ func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
if units == "" {
units = presenter.UnitsMetric
}
return precisionQueryRequest{Units: units, Precision: precision}, nil
var tz *time.Location
if allowTimezone {
tz, err = parseTimezoneQuery(r)
if err != nil {
return precisionQueryRequest{}, err
}
}
return precisionQueryRequest{
Units: units,
Precision: precision,
Timezone: tz,
}, nil
}

View File

@@ -0,0 +1,140 @@
// query_timezone.go parses and validates timezone query parameters.
// Layer: adapters/inbound/httpapi request binding helpers.
package httpapi
import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
apierrors "gitea.maximumdirect.net/ejr/feedapi/errors"
)
const maxUTCOffsetSeconds = 14 * 60 * 60
var usTimezoneAbbreviations = map[string]int{
"CDT": -5 * 60 * 60,
"CST": -6 * 60 * 60,
"EDT": -4 * 60 * 60,
"EST": -5 * 60 * 60,
"MDT": -6 * 60 * 60,
"MST": -7 * 60 * 60,
"PDT": -7 * 60 * 60,
"PST": -8 * 60 * 60,
}
var timezoneAliases = map[string]string{
"chicago": "America/Chicago",
"stl": "America/Chicago",
}
func parseTimezoneQuery(r *http.Request) (*time.Location, error) {
lower := strings.TrimSpace(r.URL.Query().Get("tz"))
upper := strings.TrimSpace(r.URL.Query().Get("TZ"))
if lower != "" && upper != "" && !strings.EqualFold(lower, upper) {
return nil, apierrors.InvalidParameter("tz and TZ must match when both are provided")
}
raw := lower
if raw == "" {
raw = upper
}
if raw == "" {
return nil, nil
}
loc, err := parseTimezoneValue(raw)
if err != nil {
return nil, apierrors.InvalidParameter(err.Error())
}
return loc, nil
}
func parseTimezoneValue(raw string) (*time.Location, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if offsetSeconds, ok, err := parseUTCOffsetSeconds(raw); ok {
if err != nil {
return nil, err
}
return time.FixedZone(formatUTCOffsetName(offsetSeconds), offsetSeconds), nil
}
if offsetSeconds, ok := usTimezoneAbbreviations[strings.ToUpper(raw)]; ok {
return time.FixedZone(strings.ToUpper(raw), offsetSeconds), nil
}
if alias, ok := timezoneAliases[strings.ToLower(raw)]; ok {
raw = alias
}
loc, err := time.LoadLocation(raw)
if err != nil {
return nil, fmt.Errorf("tz must be a valid timezone")
}
return loc, nil
}
func parseUTCOffsetSeconds(raw string) (int, bool, error) {
if len(raw) < 2 {
return 0, false, nil
}
sign := raw[0]
if sign != '+' && sign != '-' {
return 0, false, nil
}
remainder := raw[1:]
parts := strings.Split(remainder, ":")
if len(parts) > 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
hours, err := strconv.Atoi(parts[0])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
if hours < 0 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes := 0
if len(parts) == 2 {
if len(parts[1]) != 2 {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
minutes, err = strconv.Atoi(parts[1])
if err != nil {
return 0, true, fmt.Errorf("tz offset must be in ±H, ±HH, or ±HH:MM format")
}
}
if minutes < 0 || minutes > 59 {
return 0, true, fmt.Errorf("tz offset minutes must be between 00 and 59")
}
offset := (hours * 60 * 60) + (minutes * 60)
if sign == '-' {
offset = -offset
}
if offset < -maxUTCOffsetSeconds || offset > maxUTCOffsetSeconds {
return 0, true, fmt.Errorf("tz offset must be between -14 and +14 hours")
}
return offset, true, nil
}
func formatUTCOffsetName(offsetSeconds int) string {
sign := "+"
if offsetSeconds < 0 {
sign = "-"
offsetSeconds = -offsetSeconds
}
hours := offsetSeconds / 3600
minutes := (offsetSeconds % 3600) / 60
return fmt.Sprintf("UTC%s%02d:%02d", sign, hours, minutes)
}