package httpapi import ( "context" "encoding/json" "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/weatherfeeder/model" ) type fakeService struct { observation *model.WeatherObservation forecast *model.WeatherForecastRun alerts *model.WeatherAlertRun 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) LatestActiveAlerts(context.Context) (*model.WeatherAlertRun, error) { return s.alerts, 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 TestFormatNegotiationXMLAndText(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", nil) req.Header.Set("Accept", "application/xml") 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 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", } { 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 }