From dbefa8ed2830de8edbf5b136fc5ba72a240ae64b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 26 Mar 2026 20:44:55 -0500 Subject: [PATCH] Added support for /forecast/hourly/today and /forecast/hourly/tomorrow endpoints --- .../adapters/inbound/httpapi/endpoints.go | 5 +- .../inbound/httpapi/endpoints_test.go | 303 +++++++++++++++++- .../inbound/httpapi/forecast_endpoint.go | 60 +++- 3 files changed, 360 insertions(+), 8 deletions(-) diff --git a/internal/adapters/inbound/httpapi/endpoints.go b/internal/adapters/inbound/httpapi/endpoints.go index 32f36bb..3ceab54 100644 --- a/internal/adapters/inbound/httpapi/endpoints.go +++ b/internal/adapters/inbound/httpapi/endpoints.go @@ -5,10 +5,11 @@ package httpapi import "gitea.maximumdirect.net/ejr/feedapi/endpoint" func Definitions(svc Service) []endpoint.Definition { - return []endpoint.Definition{ + defs := []endpoint.Definition{ observationDefinition(svc), - forecastDefinition(svc), alertsDefinition(svc), conditionsDefinition(svc), } + defs = append(defs, forecastDefinitions(svc)...) + return defs } diff --git a/internal/adapters/inbound/httpapi/endpoints_test.go b/internal/adapters/inbound/httpapi/endpoints_test.go index dd02326..3cd0346 100644 --- a/internal/adapters/inbound/httpapi/endpoints_test.go +++ b/internal/adapters/inbound/httpapi/endpoints_test.go @@ -524,6 +524,284 @@ func TestForecastTimezoneConflictingKeyValuesRejected(t *testing.T) { } } +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") @@ -786,16 +1064,33 @@ type forecastTimePayload struct { 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) - } + 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() diff --git a/internal/adapters/inbound/httpapi/forecast_endpoint.go b/internal/adapters/inbound/httpapi/forecast_endpoint.go index d426412..6981a0d 100644 --- a/internal/adapters/inbound/httpapi/forecast_endpoint.go +++ b/internal/adapters/inbound/httpapi/forecast_endpoint.go @@ -4,25 +4,81 @@ package httpapi import ( "context" + "time" "gitea.maximumdirect.net/ejr/feedapi/endpoint" "gitea.maximumdirect.net/ejr/feedapi/render" "gitea.maximumdirect.net/ejr/feedapi/response" "gitea.maximumdirect.net/ejr/weatherapi/internal/adapters/inbound/httpapi/presenter" + "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) -func forecastDefinition(svc Service) endpoint.Definition { +type forecastDaySlice int + +const ( + forecastDaySliceAll forecastDaySlice = iota + forecastDaySliceToday + forecastDaySliceTomorrow +) + +var forecastNow = time.Now + +func forecastDefinitions(svc Service) []endpoint.Definition { + return []endpoint.Definition{ + forecastDefinition(svc, "/forecast/hourly", forecastDaySliceAll), + forecastDefinition(svc, "/forecast/hourly/today", forecastDaySliceToday), + forecastDefinition(svc, "/forecast/hourly/tomorrow", forecastDaySliceTomorrow), + } +} + +func forecastDefinition(svc Service, path string, daySlice forecastDaySlice) endpoint.Definition { return endpoint.GET( - "/forecast/hourly", + path, bindForecastPrecisionQuery, func(ctx context.Context, req precisionQueryRequest) (any, error) { run, err := svc.LatestHourlyForecast(ctx) if err != nil { return nil, err } + + if daySlice != forecastDaySliceAll { + run = filterForecastRunByDaySlice(run, req.Timezone, daySlice) + } + 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"), ) } + +func filterForecastRunByDaySlice(run *model.WeatherForecastRun, tz *time.Location, daySlice forecastDaySlice) *model.WeatherForecastRun { + if run == nil { + return nil + } + + loc := tz + if loc == nil { + loc = time.UTC + } + + now := forecastNow().In(loc) + target := now + if daySlice == forecastDaySliceTomorrow { + target = target.AddDate(0, 0, 1) + } + year, month, day := target.Date() + + periods := make([]model.WeatherForecastPeriod, 0, len(run.Periods)) + for _, period := range run.Periods { + start := period.StartTime.In(loc) + y, m, d := start.Date() + if y == year && m == month && d == day { + periods = append(periods, period) + } + } + + cloned := *run + cloned.Periods = periods + return &cloned +}