Files
weatherapi/internal/adapters/inbound/httpapi/endpoints_test.go
Eric Rakestraw 8deb4fd12e
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
Add a /conditions/current endpoint
2026-03-19 23:16:26 -05:00

494 lines
14 KiB
Go

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/core"
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
)
type fakeService struct {
observation *model.WeatherObservation
forecast *model.WeatherForecastRun
alerts *model.WeatherAlertRun
conditions *core.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) LatestActiveAlerts(context.Context) (*model.WeatherAlertRun, error) {
return s.alerts, s.err
}
func (s *fakeService) CurrentConditions(context.Context) (*core.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: &core.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: &core.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: &core.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
}