// 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 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)) } }