// payload_test.go validates presenter conversion and payload shaping behavior. // Layer: adapters/inbound/httpapi/presenter unit and schema tests. package presenter import ( "math" "testing" "time" "gitea.maximumdirect.net/ejr/weatherapi/internal/app" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) func TestObservationPayloadUS(t *testing.T) { obs := &model.WeatherObservation{ StationID: "KSTL", Timestamp: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), ConditionCode: 2, TemperatureC: float64Ptr(20), DewpointC: float64Ptr(10), WindSpeedKmh: float64Ptr(100), WindGustKmh: float64Ptr(80), BarometricPressurePa: float64Ptr(101325), VisibilityMeters: float64Ptr(1609.344), ApparentTemperatureC: float64Ptr(25), RelativeHumidityPercent: float64Ptr(50), } payload := ObservationPayload(obs, UnitsUS, 2) converted, ok := payload.(WeatherObservationUS) if !ok { t.Fatalf("expected WeatherObservationUS payload, got %T", payload) } assertApprox(t, converted.TemperatureF, 68.0, 0.0001) assertApprox(t, converted.WindSpeedMph, 62.14, 0.0001) assertApprox(t, converted.BarometricPressureInHg, 29.92, 0.0001) assertApprox(t, converted.VisibilityMiles, 1.0, 0.0001) if converted.TemperatureF == nil || converted.DewpointF == nil || converted.ApparentTemperatureF == nil { t.Fatalf("expected converted Fahrenheit fields to be populated") } } func TestForecastPayloadUS(t *testing.T) { issuedAt := time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(1 * time.Hour) run := &model.WeatherForecastRun{ LocationID: "stl", IssuedAt: issuedAt, UpdatedAt: &updatedAt, Product: model.ForecastProductHourly, ElevationMeters: float64Ptr(1000), Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(1 * time.Hour), ConditionCode: 63, TemperatureC: float64Ptr(0), TemperatureCMin: float64Ptr(-5), TemperatureCMax: float64Ptr(5), WindSpeedKmh: float64Ptr(64.37376), PrecipitationAmountMm: float64Ptr(25.4), SnowfallDepthMM: float64Ptr(50.8), }}, } payload := ForecastPayload(run, UnitsUS, 2) converted, ok := payload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected WeatherForecastRunUS payload, got %T", payload) } assertApprox(t, converted.ElevationFeet, 3280.84, 0.0001) if len(converted.Periods) != 1 { t.Fatalf("expected 1 period, got %d", len(converted.Periods)) } period := converted.Periods[0] assertApprox(t, period.TemperatureF, 32.0, 0.0001) assertApprox(t, period.TemperatureFMin, 23.0, 0.0001) assertApprox(t, period.TemperatureFMax, 41.0, 0.0001) assertApprox(t, period.WindSpeedMph, 40.0, 0.0001) assertApprox(t, period.PrecipitationAmountIn, 1.0, 0.0001) assertApprox(t, period.SnowfallDepthIn, 2.0, 0.0001) } func TestMetricCopyAndNilHandling(t *testing.T) { obs := &model.WeatherObservation{ TemperatureC: float64Ptr(20.6), } metric := ObservationPayload(obs, UnitsMetric, 0) metricObs, ok := metric.(*model.WeatherObservation) if !ok { t.Fatalf("expected metric payload to remain model type, got %T", metric) } if metricObs == obs { t.Fatalf("expected metric payload to be copied") } assertApprox(t, metricObs.TemperatureC, 21, 0.0001) assertApprox(t, obs.TemperatureC, 20.6, 0.0001) if metricObs.TemperatureC == obs.TemperatureC { t.Fatalf("expected temperature pointer copy, got same pointer") } if ObservationPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil observation input to return nil payload") } if ForecastPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil forecast input to return nil payload") } if AlertsPayload(nil, UnitsUS) != nil { t.Fatalf("expected nil alerts input to return nil payload") } if CurrentConditionsPayload(nil, UnitsUS, 0) != nil { t.Fatalf("expected nil current conditions input to return nil payload") } } func TestCurrentConditionsPayloadMetricAndUS(t *testing.T) { conditions := &app.CurrentConditions{ TemperatureC: float64Ptr(20), ApparentTemperatureC: float64Ptr(18), DewpointC: float64Ptr(10), RelativeHumidityPercent: float64Ptr(55), WindSpeedKmh: float64Ptr(100), WindDirectionDegrees: float64Ptr(225), ConditionCode: 0, IsDay: boolPtr(true), } metricPayload := CurrentConditionsPayload(conditions, UnitsMetric, 2) metric, ok := metricPayload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse metric payload, got %T", metricPayload) } assertApprox(t, metric.TemperatureC, 20, 0.0001) assertApprox(t, metric.WindSpeedKmh, 100, 0.0001) if metric.TemperatureF != nil || metric.WindSpeedMph != nil { t.Fatalf("expected US fields omitted for metric payload") } if metric.ConditionText != "Sunny" { t.Fatalf("expected condition text Sunny, got %q", metric.ConditionText) } usPayload := CurrentConditionsPayload(conditions, UnitsUS, 2) us, ok := usPayload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse US payload, got %T", usPayload) } assertApprox(t, us.TemperatureF, 68, 0.0001) assertApprox(t, us.WindSpeedMph, 62.14, 0.0001) if us.TemperatureC != nil || us.WindSpeedKmh != nil { t.Fatalf("expected metric fields omitted for US payload") } } func TestCurrentConditionsPayloadUsesNightConditionText(t *testing.T) { night := false payload := CurrentConditionsPayload(&app.CurrentConditions{ ConditionCode: 0, IsDay: &night, }, UnitsMetric, 0) metric, ok := payload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload) } if metric.ConditionText != "Clear" { t.Fatalf("expected condition text Clear, got %q", metric.ConditionText) } } func TestCurrentConditionsPayloadRoundsHalfAwayFromZero(t *testing.T) { payload := CurrentConditionsPayload(&app.CurrentConditions{ TemperatureC: float64Ptr(-1.5), }, UnitsMetric, 0) metric, ok := payload.(CurrentConditionsResponse) if !ok { t.Fatalf("expected CurrentConditionsResponse payload, got %T", payload) } assertApprox(t, metric.TemperatureC, -2, 0.0001) } func TestForecastPayloadLatitudeLongitudeNotRounded(t *testing.T) { run := &model.WeatherForecastRun{ Latitude: float64Ptr(38.627123), Longitude: float64Ptr(-90.199456), ElevationMeters: float64Ptr(10.499), Product: model.ForecastProductHourly, IssuedAt: time.Now().UTC(), } metricPayload := ForecastPayload(run, UnitsMetric, 0) metric, ok := metricPayload.(*model.WeatherForecastRun) if !ok { t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) } assertApprox(t, metric.Latitude, 38.627123, 0.000001) assertApprox(t, metric.Longitude, -90.199456, 0.000001) assertApprox(t, metric.ElevationMeters, 10, 0.0001) usPayload := ForecastPayload(run, UnitsUS, 0) us, ok := usPayload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload) } assertApprox(t, us.Latitude, 38.627123, 0.000001) assertApprox(t, us.Longitude, -90.199456, 0.000001) } func float64Ptr(v float64) *float64 { return &v } func boolPtr(v bool) *bool { return &v } func assertApprox(t *testing.T, got *float64, want, eps float64) { t.Helper() if got == nil { t.Fatalf("expected value near %f, got nil", want) } if math.Abs(*got-want) > eps { t.Fatalf("expected %f +/- %f, got %f", want, eps, *got) } }