Add timezone support to /forecast/hourly endpoint
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
@@ -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) {
|
func TestCurrentConditionsNoDataReturnsNullEnvelopeData(t *testing.T) {
|
||||||
h := newHandler(t, &fakeService{}, "/conditions/current")
|
h := newHandler(t, &fakeService{}, "/conditions/current")
|
||||||
|
|
||||||
@@ -629,3 +771,35 @@ func testRenderers(t *testing.T) *render.Registry {
|
|||||||
func float64Ptr(v float64) *float64 {
|
func float64Ptr(v float64) *float64 {
|
||||||
return &v
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,13 +14,13 @@ import (
|
|||||||
func forecastDefinition(svc Service) endpoint.Definition {
|
func forecastDefinition(svc Service) endpoint.Definition {
|
||||||
return endpoint.GET(
|
return endpoint.GET(
|
||||||
"/forecast/hourly",
|
"/forecast/hourly",
|
||||||
bindPrecisionQuery,
|
bindForecastPrecisionQuery,
|
||||||
func(ctx context.Context, req precisionQueryRequest) (any, error) {
|
func(ctx context.Context, req precisionQueryRequest) (any, error) {
|
||||||
run, err := svc.LatestHourlyForecast(ctx)
|
run, err := svc.LatestHourlyForecast(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.WithProduces(render.FormatJSON, render.FormatXML, render.FormatText),
|
||||||
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),
|
endpoint.WithTemplate("forecast_hourly.txt.tmpl"),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ type WeatherForecastPeriodUS struct {
|
|||||||
UVIndex *float64 `json:"uvIndex,omitempty" xml:"uvIndex,omitempty"`
|
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 {
|
if run == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -59,8 +59,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
|||||||
out := WeatherForecastRunUS{
|
out := WeatherForecastRunUS{
|
||||||
LocationID: run.LocationID,
|
LocationID: run.LocationID,
|
||||||
LocationName: run.LocationName,
|
LocationName: run.LocationName,
|
||||||
IssuedAt: run.IssuedAt,
|
IssuedAt: inLocationTime(run.IssuedAt, tz),
|
||||||
UpdatedAt: copyTimePtr(run.UpdatedAt),
|
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
|
||||||
Product: run.Product,
|
Product: run.Product,
|
||||||
Latitude: copyFloat64Ptr(run.Latitude),
|
Latitude: copyFloat64Ptr(run.Latitude),
|
||||||
Longitude: copyFloat64Ptr(run.Longitude),
|
Longitude: copyFloat64Ptr(run.Longitude),
|
||||||
@@ -69,8 +69,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
|||||||
}
|
}
|
||||||
for _, p := range run.Periods {
|
for _, p := range run.Periods {
|
||||||
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
|
out.Periods = append(out.Periods, WeatherForecastPeriodUS{
|
||||||
StartTime: p.StartTime,
|
StartTime: inLocationTime(p.StartTime, tz),
|
||||||
EndTime: p.EndTime,
|
EndTime: inLocationTime(p.EndTime, tz),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
IsDay: copyBoolPtr(p.IsDay),
|
IsDay: copyBoolPtr(p.IsDay),
|
||||||
ConditionCode: p.ConditionCode,
|
ConditionCode: p.ConditionCode,
|
||||||
@@ -103,8 +103,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
|||||||
out := model.WeatherForecastRun{
|
out := model.WeatherForecastRun{
|
||||||
LocationID: run.LocationID,
|
LocationID: run.LocationID,
|
||||||
LocationName: run.LocationName,
|
LocationName: run.LocationName,
|
||||||
IssuedAt: run.IssuedAt,
|
IssuedAt: inLocationTime(run.IssuedAt, tz),
|
||||||
UpdatedAt: copyTimePtr(run.UpdatedAt),
|
UpdatedAt: inLocationTimePtr(run.UpdatedAt, tz),
|
||||||
Product: run.Product,
|
Product: run.Product,
|
||||||
Latitude: copyFloat64Ptr(run.Latitude),
|
Latitude: copyFloat64Ptr(run.Latitude),
|
||||||
Longitude: copyFloat64Ptr(run.Longitude),
|
Longitude: copyFloat64Ptr(run.Longitude),
|
||||||
@@ -114,8 +114,8 @@ func ForecastPayload(run *model.WeatherForecastRun, units Units, precision int)
|
|||||||
|
|
||||||
for _, p := range run.Periods {
|
for _, p := range run.Periods {
|
||||||
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
|
out.Periods = append(out.Periods, model.WeatherForecastPeriod{
|
||||||
StartTime: p.StartTime,
|
StartTime: inLocationTime(p.StartTime, tz),
|
||||||
EndTime: p.EndTime,
|
EndTime: inLocationTime(p.EndTime, tz),
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
IsDay: copyBoolPtr(p.IsDay),
|
IsDay: copyBoolPtr(p.IsDay),
|
||||||
ConditionCode: p.ConditionCode,
|
ConditionCode: p.ConditionCode,
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ func copyTimePtr(v *time.Time) *time.Time {
|
|||||||
return &out
|
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 {
|
func boolText(v *bool) string {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestForecastPayloadUS(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := ForecastPayload(run, UnitsUS, 2)
|
payload := ForecastPayload(run, UnitsUS, 2, nil)
|
||||||
converted, ok := payload.(WeatherForecastRunUS)
|
converted, ok := payload.(WeatherForecastRunUS)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload)
|
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)
|
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) {
|
func TestMetricCopyAndNilHandling(t *testing.T) {
|
||||||
obs := &model.WeatherObservation{
|
obs := &model.WeatherObservation{
|
||||||
TemperatureC: float64Ptr(20.6),
|
TemperatureC: float64Ptr(20.6),
|
||||||
@@ -104,7 +179,7 @@ func TestMetricCopyAndNilHandling(t *testing.T) {
|
|||||||
if ObservationPayload(nil, UnitsUS, 0) != nil {
|
if ObservationPayload(nil, UnitsUS, 0) != nil {
|
||||||
t.Fatalf("expected nil observation input to return nil payload")
|
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")
|
t.Fatalf("expected nil forecast input to return nil payload")
|
||||||
}
|
}
|
||||||
if AlertsPayload(nil, UnitsUS) != nil {
|
if AlertsPayload(nil, UnitsUS) != nil {
|
||||||
@@ -190,7 +265,7 @@ func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) {
|
|||||||
IssuedAt: time.Now().UTC(),
|
IssuedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
metricPayload := ForecastPayload(run, UnitsMetric, 0)
|
metricPayload := ForecastPayload(run, UnitsMetric, 0, nil)
|
||||||
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
metric, ok := metricPayload.(*model.WeatherForecastRun)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload)
|
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.Longitude, -90.199456, 0.000001)
|
||||||
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
|
assertApprox(t, metric.ElevationMeters, 10, 0.0001)
|
||||||
|
|
||||||
usPayload := ForecastPayload(run, UnitsUS, 0)
|
usPayload := ForecastPayload(run, UnitsUS, 0, nil)
|
||||||
us, ok := usPayload.(WeatherForecastRunUS)
|
us, ok := usPayload.(WeatherForecastRunUS)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload)
|
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)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package httpapi
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/ejr/feedapi/bind"
|
"gitea.maximumdirect.net/ejr/feedapi/bind"
|
||||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
"gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter"
|
||||||
@@ -17,6 +18,7 @@ type queryRequest struct {
|
|||||||
type precisionQueryRequest struct {
|
type precisionQueryRequest struct {
|
||||||
Units presenter.Units
|
Units presenter.Units
|
||||||
Precision int
|
Precision int
|
||||||
|
Timezone *time.Location
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindQuery(r *http.Request) (queryRequest, error) {
|
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) {
|
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, "units")
|
||||||
normalizeCommonQueryValue(r, "format")
|
normalizeCommonQueryValue(r, "format")
|
||||||
normalizeCommonQueryValue(r, "precision")
|
normalizeCommonQueryValue(r, "precision")
|
||||||
|
|
||||||
|
allowedExtra := []string{"precision"}
|
||||||
|
if allowTimezone {
|
||||||
|
allowedExtra = append(allowedExtra, "tz", "TZ")
|
||||||
|
}
|
||||||
|
|
||||||
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
common, err := bind.CommonQueryParams(r, bind.QueryPolicy{
|
||||||
AllowUnits: true,
|
AllowUnits: true,
|
||||||
AllowFormat: true,
|
AllowFormat: true,
|
||||||
DefaultUnits: string(presenter.UnitsMetric),
|
DefaultUnits: string(presenter.UnitsMetric),
|
||||||
RejectUnknown: true,
|
RejectUnknown: true,
|
||||||
}, "precision")
|
}, allowedExtra...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return precisionQueryRequest{}, err
|
return precisionQueryRequest{}, err
|
||||||
}
|
}
|
||||||
@@ -70,5 +85,18 @@ func bindPrecisionQuery(r *http.Request) (precisionQueryRequest, error) {
|
|||||||
if units == "" {
|
if units == "" {
|
||||||
units = presenter.UnitsMetric
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal file
140
internal/adapters/inbound/httpapi/query_timezone.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user