package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/alerts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/forecasts" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/observations" "gitea.maximumdirect.net/ejr/weatherapi/internal/application/units" "gitea.maximumdirect.net/ejr/weatherapi/internal/core/ports" "gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants" ) type fakeObservationRepo struct { summary ports.ObservationSummaryMetric conditions []ports.ObservationConditionMetric precip []string summaryWin time.Duration conditionsWin time.Duration precipWin time.Duration } func (f *fakeObservationRepo) GetCurrentSummary(_ context.Context, window time.Duration) (ports.ObservationSummaryMetric, error) { f.summaryWin = window return f.summary, nil } func (f *fakeObservationRepo) ListCurrentConditions(_ context.Context, window time.Duration) ([]ports.ObservationConditionMetric, error) { f.conditionsWin = window return f.conditions, nil } func (f *fakeObservationRepo) ListCurrentPrecipitationEvents(_ context.Context, window time.Duration) ([]string, error) { f.precipWin = window return f.precip, nil } type fakeForecastRepo struct { periods []ports.ForecastPeriodMetric gotTS time.Time gotLimit int } func (f *fakeForecastRepo) ListForecastPeriodsAt(_ context.Context, ts time.Time, limit int) ([]ports.ForecastPeriodMetric, error) { f.gotTS = ts f.gotLimit = limit return f.periods, nil } type fakeAlertRepo struct{} func (fakeAlertRepo) ListCurrentAlerts(context.Context) ([]ports.AlertRecord, error) { return nil, nil } func TestForecastInvalidTimestampReturns400(t *testing.T) { fcRepo := &fakeForecastRepo{} obsRepo := &fakeObservationRepo{} server := NewServer( observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), alerts.NewService(fakeAlertRepo{}), ).Handler() req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=bad-time", nil) w := httptest.NewRecorder() server.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", w.Code) } var payload map[string]any if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } if payload["code"] != "invalid_request" { t.Fatalf("expected invalid_request code, got %v", payload["code"]) } } func TestObservationsCurrentReturnsUSUnits(t *testing.T) { summaryTemp := 20.0 summaryAppTemp := 15.0 condTemp := 10.0 station := "KSTL" text := "Cloudy" providerText := "OVC" conditionText := "Cloudy" obsRepo := &fakeObservationRepo{ summary: ports.ObservationSummaryMetric{ TemperatureC: &summaryTemp, ApparentTemperatureC: &summaryAppTemp, }, conditions: []ports.ObservationConditionMetric{ { StationID: &station, ObservedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC), TemperatureC: &condTemp, TextDescription: &text, ProviderRawDescription: &providerText, ConditionText: &conditionText, }, }, precip: []string{"rain"}, } fcRepo := &fakeForecastRepo{} server := NewServer( observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), alerts.NewService(fakeAlertRepo{}), ).Handler() req := httptest.NewRequest(http.MethodGet, "/observations/current", nil) w := httptest.NewRecorder() server.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload map[string]any if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } summary := payload["summary"].(map[string]any) if got := summary["temperatureF"].(float64); got != 68.0 { t.Fatalf("expected summary temperatureF=68.0, got %v", got) } if got := summary["apparentTemperatureF"].(float64); got != 59.0 { t.Fatalf("expected summary apparentTemperatureF=59.0, got %v", got) } if _, exists := summary["temperatureC"]; exists { t.Fatalf("did not expect metric key temperatureC in US response") } conditions := payload["conditions"].([]any) first := conditions[0].(map[string]any) if got := first["temperatureF"].(float64); got != 50.0 { t.Fatalf("expected conditions[0].temperatureF=50.0, got %v", got) } if obsRepo.summaryWin != constants.ObservationWindow || obsRepo.conditionsWin != constants.ObservationWindow || obsRepo.precipWin != constants.ObservationWindow { t.Fatalf("expected observation window %s to be used", constants.ObservationWindow) } } func TestForecastReturnsUSUnits(t *testing.T) { tempC := 0.0 tempMinC := -1.0 tempMaxC := 1.0 appTempC := -2.0 windKmh := 10.0 gustKmh := 16.09344 name := "Now" fcRepo := &fakeForecastRepo{ periods: []ports.ForecastPeriodMetric{ { PeriodIndex: 1, StartTime: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 17, 13, 0, 0, 0, time.UTC), Name: &name, ConditionCode: 1, TemperatureC: &tempC, TemperatureCMin: &tempMinC, TemperatureCMax: &tempMaxC, ApparentTemperatureC: &appTempC, WindSpeedKmh: &windKmh, WindGustKmh: &gustKmh, }, }, } obsRepo := &fakeObservationRepo{} server := NewServer( observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), alerts.NewService(fakeAlertRepo{}), ).Handler() req := httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil) w := httptest.NewRecorder() server.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } var payload map[string]any if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { t.Fatalf("decode response: %v", err) } periods := payload["periods"].([]any) first := periods[0].(map[string]any) if got := first["temperatureF"].(float64); got != 32.0 { t.Fatalf("expected temperatureF=32.0, got %v", got) } if got := first["temperatureFMin"].(float64); got != 30.2 { t.Fatalf("expected temperatureFMin=30.2, got %v", got) } if got := first["temperatureFMax"].(float64); got != 33.8 { t.Fatalf("expected temperatureFMax=33.8, got %v", got) } if got := first["apparentTemperatureF"].(float64); got != 28.4 { t.Fatalf("expected apparentTemperatureF=28.4, got %v", got) } if got := first["windSpeedMph"].(float64); got != 6.2 { t.Fatalf("expected windSpeedMph=6.2, got %v", got) } if got := first["windGustMph"].(float64); got != 10.0 { t.Fatalf("expected windGustMph=10.0, got %v", got) } if _, exists := first["windSpeedKmh"]; exists { t.Fatalf("did not expect metric key windSpeedKmh in US response") } if fcRepo.gotLimit != constants.ForecastQueryLimit { t.Fatalf("expected forecast query limit %d, got %d", constants.ForecastQueryLimit, fcRepo.gotLimit) } if fcRepo.gotTS.IsZero() { t.Fatalf("expected forecast timestamp passed to repo") } } func TestNullFieldsAreOmittedFromJSON(t *testing.T) { obsRepo := &fakeObservationRepo{ summary: ports.ObservationSummaryMetric{}, conditions: []ports.ObservationConditionMetric{ { ObservedAt: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC), }, }, } fcRepo := &fakeForecastRepo{ periods: []ports.ForecastPeriodMetric{ { PeriodIndex: 1, StartTime: time.Date(2026, 3, 17, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 17, 13, 0, 0, 0, time.UTC), ConditionCode: 1, }, }, } server := NewServer( observations.NewService(obsRepo, units.USConverter{}, constants.ObservationWindow), forecasts.NewService(fcRepo, units.USConverter{}, constants.ForecastQueryLimit), alerts.NewService(fakeAlertRepo{}), ).Handler() wObs := httptest.NewRecorder() server.ServeHTTP(wObs, httptest.NewRequest(http.MethodGet, "/observations/current", nil)) if wObs.Code != http.StatusOK { t.Fatalf("expected observations 200, got %d", wObs.Code) } var obsPayload map[string]any if err := json.Unmarshal(wObs.Body.Bytes(), &obsPayload); err != nil { t.Fatalf("decode observations response: %v", err) } summary := obsPayload["summary"].(map[string]any) if _, exists := summary["temperatureF"]; exists { t.Fatalf("expected summary.temperatureF to be omitted when nil") } conditions := obsPayload["conditions"].([]any) firstCond := conditions[0].(map[string]any) if _, exists := firstCond["temperatureF"]; exists { t.Fatalf("expected conditions[0].temperatureF to be omitted when nil") } wFc := httptest.NewRecorder() server.ServeHTTP(wFc, httptest.NewRequest(http.MethodGet, "/forecast?timestamp=2026-03-17T12:30:00Z", nil)) if wFc.Code != http.StatusOK { t.Fatalf("expected forecast 200, got %d", wFc.Code) } var fcPayload map[string]any if err := json.Unmarshal(wFc.Body.Bytes(), &fcPayload); err != nil { t.Fatalf("decode forecast response: %v", err) } periods := fcPayload["periods"].([]any) firstPeriod := periods[0].(map[string]any) if _, exists := firstPeriod["temperatureF"]; exists { t.Fatalf("expected periods[0].temperatureF to be omitted when nil") } if _, exists := firstPeriod["windSpeedMph"]; exists { t.Fatalf("expected periods[0].windSpeedMph to be omitted when nil") } }