// payload_test.go validates presenter conversion and payload shaping behavior. // Layer: adapters/inbound/httpapi/presenter unit and schema tests. package presenter import ( "encoding/json" "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: wmoCodePtr(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, nil) 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 TestForecastPayloadOmitsLegacyDescriptionFields(t *testing.T) { run := &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC), Periods: []model.WeatherForecastPeriod{{ StartTime: time.Date(2026, 3, 20, 12, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 20, 13, 0, 0, 0, time.UTC), ConditionCode: wmoCodePtr(model.WMOUnknown), TextDescription: "Cloudy", }}, } assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsMetric, 0, nil)) assertForecastPayloadHasNoLegacyDescriptionFields(t, ForecastPayload(run, UnitsUS, 0, nil)) } func TestForecastPayloadTimezoneConversionMetricAndUS(t *testing.T) { loc := time.FixedZone("UTC-05:00", -5*60*60) issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) run := &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: issuedAt, UpdatedAt: &updatedAt, Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt.Add(1 * time.Hour), EndTime: issuedAt.Add(2 * time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), }}, } metricPayload := ForecastPayload(run, UnitsMetric, 0, loc) metric, ok := metricPayload.(*model.WeatherForecastRun) if !ok { t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) } assertOffsetSeconds(t, metric.IssuedAt, -5*60*60) assertOffsetSeconds(t, *metric.UpdatedAt, -5*60*60) assertOffsetSeconds(t, metric.Periods[0].StartTime, -5*60*60) assertOffsetSeconds(t, metric.Periods[0].EndTime, -5*60*60) if !metric.IssuedAt.UTC().Equal(issuedAt) { t.Fatalf("expected metric issuedAt to preserve instant") } usPayload := ForecastPayload(run, UnitsUS, 0, loc) us, ok := usPayload.(WeatherForecastRunUS) if !ok { t.Fatalf("expected us payload type WeatherForecastRunUS, got %T", usPayload) } assertOffsetSeconds(t, us.IssuedAt, -5*60*60) assertOffsetSeconds(t, *us.UpdatedAt, -5*60*60) assertOffsetSeconds(t, us.Periods[0].StartTime, -5*60*60) assertOffsetSeconds(t, us.Periods[0].EndTime, -5*60*60) // Source model remains untouched. assertOffsetSeconds(t, run.IssuedAt, 0) assertOffsetSeconds(t, *run.UpdatedAt, 0) } func TestForecastPayloadNoTimezonePreservesUTCAndCopySemantics(t *testing.T) { issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(time.Hour) run := &model.WeatherForecastRun{ Product: model.ForecastProductHourly, IssuedAt: issuedAt, UpdatedAt: &updatedAt, Periods: []model.WeatherForecastPeriod{{ StartTime: issuedAt, EndTime: issuedAt.Add(time.Hour), ConditionCode: wmoCodePtr(model.WMOUnknown), }}, } metricPayload := ForecastPayload(run, UnitsMetric, 0, nil) metric, ok := metricPayload.(*model.WeatherForecastRun) if !ok { t.Fatalf("expected metric payload type *model.WeatherForecastRun, got %T", metricPayload) } if metric == run { t.Fatalf("expected metric payload to be copied") } if metric.UpdatedAt == run.UpdatedAt { t.Fatalf("expected updatedAt pointer to be copied") } if !metric.IssuedAt.Equal(run.IssuedAt) { t.Fatalf("expected issuedAt to remain unchanged without timezone flag") } assertOffsetSeconds(t, metric.IssuedAt, 0) assertOffsetSeconds(t, metric.Periods[0].StartTime, 0) } func TestDiscussionPayloadTimezoneConversionAndCopySemantics(t *testing.T) { loc := time.FixedZone("UTC-05:00", -5*60*60) issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) shortIssuedAt := issuedAt.Add(-15 * time.Minute) run := &model.WeatherForecastDiscussion{ OfficeID: "LSX", Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, UpdatedAt: &updatedAt, KeyMessages: []string{"msg one"}, ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, } payload := DiscussionPayload(run, UnitsMetric, loc) discussion, ok := payload.(*model.WeatherForecastDiscussion) if !ok { t.Fatalf("expected *model.WeatherForecastDiscussion payload, got %T", payload) } if discussion == run { t.Fatalf("expected discussion payload to be copied") } if discussion.ShortTerm == run.ShortTerm { t.Fatalf("expected shortTerm pointer to be copied") } assertOffsetSeconds(t, discussion.IssuedAt, -5*60*60) assertOffsetSeconds(t, *discussion.UpdatedAt, -5*60*60) assertOffsetSeconds(t, *discussion.ShortTerm.IssuedAt, -5*60*60) if discussion.KeyMessages[0] != "msg one" { t.Fatalf("expected key message preserved, got %#v", discussion.KeyMessages) } assertOffsetSeconds(t, run.IssuedAt, 0) } func TestDiscussionFocusedPayloads(t *testing.T) { loc := time.FixedZone("UTC-05:00", -5*60*60) issuedAt := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) updatedAt := issuedAt.Add(30 * time.Minute) shortIssuedAt := issuedAt.Add(-15 * time.Minute) longIssuedAt := issuedAt.Add(15 * time.Minute) run := &model.WeatherForecastDiscussion{ OfficeID: "LSX", OfficeName: "National Weather Service Saint Louis MO", Product: model.ForecastDiscussionProductAFD, IssuedAt: issuedAt, UpdatedAt: &updatedAt, KeyMessages: nil, ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"}, LongTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tomorrow)", IssuedAt: &longIssuedAt, Text: "Long term text"}, } keyMessagesPayload := DiscussionKeyMessagesOnlyPayload(run, UnitsMetric, loc) keyMessages, ok := keyMessagesPayload.(DiscussionKeyMessagesPayload) if !ok { t.Fatalf("expected DiscussionKeyMessagesPayload, got %T", keyMessagesPayload) } if keyMessages.KeyMessages == nil { t.Fatalf("expected keyMessages slice to be non-nil") } if len(keyMessages.KeyMessages) != 0 { t.Fatalf("expected empty keyMessages slice, got %#v", keyMessages.KeyMessages) } assertOffsetSeconds(t, keyMessages.IssuedAt, -5*60*60) assertOffsetSeconds(t, *keyMessages.UpdatedAt, -5*60*60) shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, loc) shortTerm, ok := shortPayload.(DiscussionShortTermPayload) if !ok { t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload) } if shortTerm.ShortTerm == nil { t.Fatalf("expected shortTerm section") } if shortTerm.ShortTerm == run.ShortTerm { t.Fatalf("expected shortTerm section copy") } assertOffsetSeconds(t, *shortTerm.ShortTerm.IssuedAt, -5*60*60) longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, loc) longTerm, ok := longPayload.(DiscussionLongTermPayload) if !ok { t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload) } if longTerm.LongTerm == nil { t.Fatalf("expected longTerm section") } if longTerm.LongTerm == run.LongTerm { t.Fatalf("expected longTerm section copy") } assertOffsetSeconds(t, *longTerm.LongTerm.IssuedAt, -5*60*60) } func TestDiscussionFocusedPayloadsPreserveNilSections(t *testing.T) { run := &model.WeatherForecastDiscussion{ OfficeID: "LSX", Product: model.ForecastDiscussionProductAFD, IssuedAt: time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), } shortPayload := DiscussionShortTermOnlyPayload(run, UnitsMetric, nil) shortTerm, ok := shortPayload.(DiscussionShortTermPayload) if !ok { t.Fatalf("expected DiscussionShortTermPayload, got %T", shortPayload) } if shortTerm.ShortTerm != nil { t.Fatalf("expected nil shortTerm, got %+v", shortTerm.ShortTerm) } longPayload := DiscussionLongTermOnlyPayload(run, UnitsMetric, nil) longTerm, ok := longPayload.(DiscussionLongTermPayload) if !ok { t.Fatalf("expected DiscussionLongTermPayload, got %T", longPayload) } if longTerm.LongTerm != nil { t.Fatalf("expected nil longTerm, got %+v", longTerm.LongTerm) } } 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) != 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 DiscussionPayload(nil, UnitsUS, nil) != nil { t.Fatalf("expected nil discussion input to return nil payload") } if DiscussionKeyMessagesOnlyPayload(nil, UnitsUS, nil) != nil { t.Fatalf("expected nil key messages discussion input to return nil payload") } if DiscussionShortTermOnlyPayload(nil, UnitsUS, nil) != nil { t.Fatalf("expected nil short term discussion input to return nil payload") } if DiscussionLongTermOnlyPayload(nil, UnitsUS, nil) != nil { t.Fatalf("expected nil long term discussion 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, nil) 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, nil) 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 TestOutlookRunPayloadNilInputReturnsNil(t *testing.T) { if OutlookRunPayload(nil, UnitsUS, time.UTC) != nil { t.Fatalf("expected nil outlook run input to return nil payload") } } func TestOutlookRunPayloadTimezoneConversionAndCopySemantics(t *testing.T) { loc := time.FixedZone("UTC-05:00", -5*60*60) asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC) issuedAt := asOf.Add(-1 * time.Hour) severityRank := 5 latitude := 38.627123 longitude := -90.199456 geometry := json.RawMessage(`{"type":"Point","coordinates":[-90.2,38.6]}`) run := &model.WeatherOutlookRun{ LocationID: "stl", LocationName: "St. Louis", Latitude: &latitude, Longitude: &longitude, AsOf: asOf, IssuedAt: &issuedAt, Outlooks: []model.WeatherOutlook{{ ID: "cat-1", Provider: "spc", Product: "convective", Day: 1, OutlookType: "categorical", Label: "SLGT", LabelText: "Slight Risk", SeverityRank: &severityRank, ValidFrom: asOf, ValidTo: asOf.Add(6 * time.Hour), IssuedAt: issuedAt, ExpiresAt: asOf.Add(6 * time.Hour), Forecaster: "DIAL", Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", Discussion: "Discussion text.", SourceURL: "https://example.test/source", ImageURL: "https://example.test/image.png", ContainsLocation: true, Geometry: geometry, }}, } payload := OutlookRunPayload(run, UnitsUS, loc) out, ok := payload.(*model.WeatherOutlookRun) if !ok { t.Fatalf("expected *model.WeatherOutlookRun payload, got %T", payload) } if out == run { t.Fatalf("expected outlook run payload to be copied") } if out.Latitude == run.Latitude || out.Longitude == run.Longitude || out.IssuedAt == run.IssuedAt { t.Fatalf("expected run pointers to be copied") } if len(out.Outlooks) != 1 { t.Fatalf("expected one outlook, got %d", len(out.Outlooks)) } if out.Outlooks[0].SeverityRank == run.Outlooks[0].SeverityRank { t.Fatalf("expected severity rank pointer to be copied") } if &out.Outlooks[0].Geometry[0] == &run.Outlooks[0].Geometry[0] { t.Fatalf("expected geometry bytes to be copied") } assertOffsetSeconds(t, out.AsOf, -5*60*60) assertOffsetSeconds(t, *out.IssuedAt, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].ValidFrom, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].ValidTo, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].IssuedAt, -5*60*60) assertOffsetSeconds(t, out.Outlooks[0].ExpiresAt, -5*60*60) if !out.AsOf.UTC().Equal(asOf) || !out.Outlooks[0].ValidFrom.UTC().Equal(asOf) { t.Fatalf("expected timezone conversion to preserve instants") } if *out.Latitude != latitude || *out.Longitude != longitude { t.Fatalf("expected latitude/longitude preserved, got %v %v", out.Latitude, out.Longitude) } if out.Outlooks[0].SeverityRank == nil || *out.Outlooks[0].SeverityRank != severityRank { t.Fatalf("expected severity rank preserved, got %v", out.Outlooks[0].SeverityRank) } if string(out.Outlooks[0].Geometry) != string(geometry) { t.Fatalf("expected geometry bytes preserved, got %s", out.Outlooks[0].Geometry) } *out.Latitude = 99 *out.Longitude = -99 *out.IssuedAt = time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC) *out.Outlooks[0].SeverityRank = 99 out.Outlooks[0].Geometry[0] = '[' if *run.Latitude != latitude || *run.Longitude != longitude || !run.IssuedAt.Equal(issuedAt) { t.Fatalf("expected source run pointers not to mutate") } if *run.Outlooks[0].SeverityRank != severityRank { t.Fatalf("expected source severity rank not to mutate") } if string(run.Outlooks[0].Geometry) != string(geometry) { t.Fatalf("expected source geometry not to mutate, got %s", run.Outlooks[0].Geometry) } assertOffsetSeconds(t, run.AsOf, 0) assertOffsetSeconds(t, run.Outlooks[0].ValidFrom, 0) } func float64Ptr(v float64) *float64 { return &v } func boolPtr(v bool) *bool { return &v } func wmoCodePtr(v model.WMOCode) *model.WMOCode { out := v return &out } 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) } } func assertOffsetSeconds(t *testing.T, ts time.Time, want int) { t.Helper() _, got := ts.Zone() if got != want { t.Fatalf("expected offset %d, got %d for %s", want, got, ts.Format(time.RFC3339)) } } func assertForecastPayloadHasNoLegacyDescriptionFields(t *testing.T, payload any) { t.Helper() b, err := json.Marshal(payload) if err != nil { t.Fatalf("json.Marshal(payload) error = %v", err) } var root map[string]any if err := json.Unmarshal(b, &root); err != nil { t.Fatalf("json.Unmarshal(payload) error = %v", err) } periodsRaw, ok := root["periods"].([]any) if !ok || len(periodsRaw) == 0 { t.Fatalf("expected non-empty periods in payload: %#v", root["periods"]) } period, ok := periodsRaw[0].(map[string]any) if !ok { t.Fatalf("expected first period map, got %#v", periodsRaw[0]) } for _, key := range []string{"conditionText", "providerRawDescription", "detailedText", "iconUrl"} { if _, exists := period[key]; exists { t.Fatalf("unexpected legacy field %q in payload period: %#v", key, period) } } if period["textDescription"] != "Cloudy" { t.Fatalf("expected textDescription Cloudy, got %#v", period["textDescription"]) } }