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