// repository_test.go validates Postgres row mapping and attachment helpers. // Layer: adapters/outbound/postgres mapper regression tests. package postgres import ( "database/sql" "testing" "time" "gitea.maximumdirect.net/ejr/weatherfeeder/model" ) func TestMapObservationParentRowNullables(t *testing.T) { observedAt := time.Date(2026, 3, 19, 23, 45, 0, 0, time.FixedZone("CST", -6*3600)) isDay := true temp := 18.25 obs := mapObservationParentRow(observationParentRow{ StationID: sql.NullString{String: "KSTL", Valid: true}, StationName: sql.NullString{String: "St. Louis", Valid: true}, ObservedAt: observedAt, ConditionCode: 2, IsDay: sql.NullBool{Bool: isDay, Valid: true}, TemperatureC: sql.NullFloat64{Float64: temp, Valid: true}, TextDescription: sql.NullString{String: "Partly Cloudy", Valid: true}, }) if obs.StationID != "KSTL" { t.Fatalf("expected station id KSTL, got %q", obs.StationID) } if obs.IsDay == nil || !*obs.IsDay { t.Fatalf("expected isDay pointer true, got %v", obs.IsDay) } if obs.TemperatureC == nil || *obs.TemperatureC != temp { t.Fatalf("expected temperature %v, got %v", temp, obs.TemperatureC) } if obs.DewpointC != nil { t.Fatalf("expected nil dewpoint, got %v", *obs.DewpointC) } if got := obs.Timestamp.Location().String(); got != "UTC" { t.Fatalf("expected UTC timestamp, got %s", got) } } func TestMapObservationPresentWeatherRow(t *testing.T) { row := observationPresentWeatherRow{ WeatherIndex: 1, RawText: sql.NullString{String: `{"code":61,"text":"rain"}`, Valid: true}, } pw, err := mapObservationPresentWeatherRow(row) if err != nil { t.Fatalf("unexpected error: %v", err) } if pw.Raw == nil { t.Fatalf("expected raw map to be populated") } if got, ok := pw.Raw["text"].(string); !ok || got != "rain" { t.Fatalf("expected raw text rain, got %#v", pw.Raw["text"]) } } func TestMapForecastPeriodRowNullables(t *testing.T) { start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC) end := start.Add(1 * time.Hour) period := mapForecastPeriodRow(forecastPeriodRow{ StartTime: start, EndTime: end, ConditionCode: sql.NullInt64{Int64: 80, Valid: true}, Name: sql.NullString{String: "Midnight", Valid: true}, TemperatureC: sql.NullFloat64{Float64: 12.5, Valid: true}, TemperatureCMin: sql.NullFloat64{Valid: false}, }) if period.Name != "Midnight" { t.Fatalf("expected period name Midnight, got %q", period.Name) } if period.TemperatureC == nil || *period.TemperatureC != 12.5 { t.Fatalf("expected temperature pointer 12.5, got %v", period.TemperatureC) } if period.TemperatureCMin != nil { t.Fatalf("expected nil TemperatureCMin, got %v", *period.TemperatureCMin) } if !period.StartTime.Equal(start) || !period.EndTime.Equal(end) { t.Fatalf("unexpected time range: %s - %s", period.StartTime, period.EndTime) } if period.ConditionCode == nil || *period.ConditionCode != 80 { t.Fatalf("expected condition code pointer 80, got %v", period.ConditionCode) } } func TestMapForecastPeriodRowConditionCodeNullable(t *testing.T) { period := mapForecastPeriodRow(forecastPeriodRow{ StartTime: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC), ConditionCode: sql.NullInt64{Valid: false}, }) if period.ConditionCode != nil { t.Fatalf("expected nil condition code, got %v", period.ConditionCode) } } func TestMapDiscussionParentRowNullables(t *testing.T) { issuedAt := time.Date(2026, 3, 28, 19, 24, 0, 0, time.FixedZone("CDT", -5*3600)) updatedAt := issuedAt.Add(time.Hour) shortIssuedAt := issuedAt.Add(-5 * time.Minute) discussion := mapDiscussionParentRow(discussionParentRow{ OfficeID: sql.NullString{String: "LSX", Valid: true}, OfficeName: sql.NullString{String: "National Weather Service Saint Louis MO", Valid: true}, IssuedAt: issuedAt, UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true}, Product: "afd", ShortTermQualifier: sql.NullString{String: "(Tonight)", Valid: true}, ShortTermIssuedAt: sql.NullTime{Time: shortIssuedAt, Valid: true}, ShortTermText: sql.NullString{String: "Short term text", Valid: true}, }) if discussion.OfficeID != "LSX" { t.Fatalf("expected office id LSX, got %q", discussion.OfficeID) } if discussion.Product != model.ForecastDiscussionProductAFD { t.Fatalf("expected product afd, got %q", discussion.Product) } if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" { t.Fatalf("expected updatedAt to be UTC-normalized, got %v", discussion.UpdatedAt) } if discussion.ShortTerm == nil { t.Fatalf("expected shortTerm section to be populated") } if discussion.ShortTerm.Qualifier != "(Tonight)" || discussion.ShortTerm.Text != "Short term text" { t.Fatalf("unexpected shortTerm section: %+v", discussion.ShortTerm) } if discussion.LongTerm != nil { t.Fatalf("expected longTerm nil, got %+v", discussion.LongTerm) } } func TestMapDiscussionSectionNilWhenAllFieldsMissing(t *testing.T) { got := discussionSectionPtr(sql.NullString{}, sql.NullTime{}, sql.NullString{}) if got != nil { t.Fatalf("expected nil discussion section, got %+v", got) } } func TestMapDiscussionKeyMessagesPreserveOrder(t *testing.T) { rows := []discussionKeyMessageRow{ {MessageIndex: 0, MessageText: sql.NullString{String: "first", Valid: true}}, {MessageIndex: 1, MessageText: sql.NullString{String: "second", Valid: true}}, } got := make([]string, 0, len(rows)) for _, row := range rows { got = append(got, mapDiscussionKeyMessageRow(row)) } if len(got) != 2 || got[0] != "first" || got[1] != "second" { t.Fatalf("unexpected key message order: %#v", got) } } func TestMapWeatherStoryRunParentRowNullables(t *testing.T) { asOf := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600)) run := mapWeatherStoryRunParentRow(weatherStoryRunParentRow{ EventID: "evt-story-run", OfficeID: sql.NullString{String: "LSX", Valid: true}, AsOf: asOf, }) if run.OfficeID != "LSX" { t.Fatalf("expected office id LSX, got %q", run.OfficeID) } if run.AsOf.Location().String() != "UTC" { t.Fatalf("expected asOf to be UTC-normalized, got %v", run.AsOf) } if run.Stories != nil { t.Fatalf("expected nil stories before child load, got %+v", run.Stories) } } func TestMapWeatherStoryRowMapsFields(t *testing.T) { start := time.Date(2026, 5, 30, 8, 46, 0, 0, time.FixedZone("CDT", -5*3600)) end := time.Date(2026, 5, 31, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600)) updated := time.Date(2026, 5, 30, 9, 0, 34, 0, time.FixedZone("CDT", -5*3600)) story := mapWeatherStoryRow(weatherStoryRow{ StoryIndex: 2, OfficeID: sql.NullString{String: "LSX", Valid: true}, StartTime: start, EndTime: end, UpdatedAt: updated, Title: sql.NullString{String: "Several Chances for Rain Through Monday", Valid: true}, Description: sql.NullString{String: "Scattered showers and thunderstorms.", Valid: true}, AltText: sql.NullString{String: "Forecast slide.", Valid: true}, Priority: true, StoryOrder: 1, DownloadURL: sql.NullString{String: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1", Valid: true}, }) if story.OfficeID != "LSX" { t.Fatalf("expected office id LSX, got %q", story.OfficeID) } if story.Title != "Several Chances for Rain Through Monday" { t.Fatalf("unexpected title: %q", story.Title) } if !story.Priority { t.Fatalf("expected priority true") } if story.Order != 1 { t.Fatalf("expected order 1, got %d", story.Order) } if story.DownloadURL == "" { t.Fatalf("expected download URL") } if story.StartTime.Location().String() != "UTC" || story.EndTime.Location().String() != "UTC" || story.UpdatedAt.Location().String() != "UTC" { t.Fatalf("expected story timestamps to be UTC-normalized, got %s %s %s", story.StartTime, story.EndTime, story.UpdatedAt) } } func TestAttachAlertReferencesPreservesOrder(t *testing.T) { sent1 := time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC) sent2 := sent1.Add(10 * time.Minute) alerts := []indexedAlert{ {Index: 4, Alert: mapAlertRow(alertRow{AlertIndex: 4, AlertID: "a-4"}).Alert}, {Index: 9, Alert: mapAlertRow(alertRow{AlertIndex: 9, AlertID: "a-9"}).Alert}, } references := []indexedAlertReference{ mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r1", Valid: true}, Sent: sql.NullTime{Time: sent1, Valid: true}}), mapAlertReferenceRow(alertReferenceRow{AlertIndex: 4, Identifier: sql.NullString{String: "r2", Valid: true}, Sent: sql.NullTime{Time: sent2, Valid: true}}), mapAlertReferenceRow(alertReferenceRow{AlertIndex: 9, Identifier: sql.NullString{String: "r9", Valid: true}}), } out := attachAlertReferences(alerts, references) if len(out) != 2 { t.Fatalf("expected 2 alerts, got %d", len(out)) } if len(out[0].References) != 2 { t.Fatalf("expected first alert to have 2 references, got %d", len(out[0].References)) } if out[0].References[0].Identifier != "r1" || out[0].References[1].Identifier != "r2" { t.Fatalf("unexpected first alert reference order: %+v", out[0].References) } if len(out[1].References) != 1 || out[1].References[0].Identifier != "r9" { t.Fatalf("unexpected second alert references: %+v", out[1].References) } } func TestMapAlertRowMapsEndsAndExpires(t *testing.T) { ends := time.Date(2026, 6, 16, 14, 0, 0, 0, time.FixedZone("CDT", -5*3600)) expires := time.Date(2026, 6, 16, 11, 0, 0, 0, time.FixedZone("CDT", -5*3600)) alert := mapAlertRow(alertRow{ AlertIndex: 1, AlertID: "alert-1", Ends: sql.NullTime{Time: ends, Valid: true}, Expires: sql.NullTime{Time: expires, Valid: true}, }).Alert if alert.Ends == nil || alert.Ends.Location().String() != "UTC" || !alert.Ends.Equal(ends.UTC()) { t.Fatalf("expected ends UTC %s, got %v", ends.UTC(), alert.Ends) } if alert.Expires == nil || alert.Expires.Location().String() != "UTC" || !alert.Expires.Equal(expires.UTC()) { t.Fatalf("expected expires UTC %s, got %v", expires.UTC(), alert.Expires) } } func TestMapAlertRowNullableEnds(t *testing.T) { alert := mapAlertRow(alertRow{ AlertIndex: 1, AlertID: "alert-1", }).Alert if alert.Ends != nil { t.Fatalf("expected nil ends, got %v", alert.Ends) } } func TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) { got := mapCurrentConditionsRow(currentConditionsRow{ SampleCount: 0, }, model.WMOUnknown) if got != nil { t.Fatalf("expected nil for empty sample window, got %+v", got) } } func TestMapCurrentConditionsRowMapsFields(t *testing.T) { isDay := true got := mapCurrentConditionsRow(currentConditionsRow{ SampleCount: 12, TemperatureC: sql.NullFloat64{Float64: 15.5, Valid: true}, ApparentTemperatureC: sql.NullFloat64{Float64: 14.2, Valid: true}, DewpointC: sql.NullFloat64{Float64: 10.1, Valid: true}, RelativeHumidityPercent: sql.NullFloat64{Float64: 72, Valid: true}, WindSpeedKmh: sql.NullFloat64{Float64: 24.8, Valid: true}, WindDirectionDegrees: sql.NullFloat64{Float64: 182.5, Valid: true}, IsDay: sql.NullBool{Bool: isDay, Valid: true}, }, 65) if got == nil { t.Fatalf("expected mapped current conditions") } if got.TemperatureC == nil || *got.TemperatureC != 15.5 { t.Fatalf("expected temperature pointer 15.5, got %v", got.TemperatureC) } if got.ApparentTemperatureC == nil || *got.ApparentTemperatureC != 14.2 { t.Fatalf("expected apparent temp pointer 14.2, got %v", got.ApparentTemperatureC) } if got.DewpointC == nil || *got.DewpointC != 10.1 { t.Fatalf("expected dewpoint pointer 10.1, got %v", got.DewpointC) } if got.RelativeHumidityPercent == nil || *got.RelativeHumidityPercent != 72 { t.Fatalf("expected rh pointer 72, got %v", got.RelativeHumidityPercent) } if got.WindSpeedKmh == nil || *got.WindSpeedKmh != 24.8 { t.Fatalf("expected wind speed pointer 24.8, got %v", got.WindSpeedKmh) } if got.WindDirectionDegrees == nil || *got.WindDirectionDegrees != 182.5 { t.Fatalf("expected wind direction pointer 182.5, got %v", got.WindDirectionDegrees) } if got.ConditionCode != 65 { t.Fatalf("expected condition code 65, got %d", got.ConditionCode) } if got.IsDay == nil || !*got.IsDay { t.Fatalf("expected isDay pointer true, got %v", got.IsDay) } }