// outlooks_mapper_test.go validates outlook row mapping. // Layer: adapters/outbound/postgres outlook mapper tests. package postgres import ( "database/sql" "testing" "time" ) func TestMapOutlookRunParentRowNullables(t *testing.T) { asOf := time.Date(2026, 6, 11, 11, 30, 0, 0, time.FixedZone("CDT", -5*3600)) issuedAt := asOf.Add(-30 * time.Minute) latitude := 38.62 run := mapOutlookRunParentRow(outlookRunParentRow{ EventID: "evt-outlook-run", LocationID: sql.NullString{String: "stl", Valid: true}, LocationName: sql.NullString{String: "St. Louis", Valid: true}, Latitude: sql.NullFloat64{Float64: latitude, Valid: true}, Longitude: sql.NullFloat64{Valid: false}, AsOf: asOf, IssuedAt: sql.NullTime{Time: issuedAt, Valid: true}, }) if run.LocationID != "stl" || run.LocationName != "St. Louis" { t.Fatalf("unexpected location metadata: %+v", run) } if run.Latitude == nil || *run.Latitude != latitude { t.Fatalf("expected latitude pointer %v, got %v", latitude, run.Latitude) } if run.Longitude != nil { t.Fatalf("expected nil longitude, got %v", *run.Longitude) } if run.AsOf.Location().String() != "UTC" { t.Fatalf("expected asOf UTC normalization, got %s", run.AsOf.Location()) } if run.IssuedAt == nil || run.IssuedAt.Location().String() != "UTC" { t.Fatalf("expected issuedAt UTC pointer, got %v", run.IssuedAt) } if run.Outlooks != nil { t.Fatalf("expected nil outlooks before child load, got %+v", run.Outlooks) } } func TestMapOutlookRunParentRowMissingOptionals(t *testing.T) { run := mapOutlookRunParentRow(outlookRunParentRow{ AsOf: time.Date(2026, 6, 11, 16, 30, 0, 0, time.UTC), }) if run.LocationID != "" || run.LocationName != "" { t.Fatalf("expected empty location metadata, got %+v", run) } if run.Latitude != nil || run.Longitude != nil || run.IssuedAt != nil { t.Fatalf("expected nil optional pointers, got %+v", run) } } func TestMapOutlookRowMapsFields(t *testing.T) { validFrom := time.Date(2026, 6, 11, 7, 0, 0, 0, time.FixedZone("CDT", -5*3600)) validTo := validFrom.Add(12 * time.Hour) issuedAt := validFrom.Add(-1 * time.Hour) expiresAt := validTo severityRank := int64(5) geometry := `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}` outlook, err := mapOutlookRow(outlookRow{ OutlookIndex: 3, OutlookID: "spc-20260611-1300-day1-cat-slight", Provider: "spc", Product: "convective", Day: 1, OutlookType: "categorical", Label: "SLGT", LabelText: sql.NullString{String: "Slight Risk", Valid: true}, SeverityRank: sql.NullInt64{Int64: severityRank, Valid: true}, ValidFrom: validFrom, ValidTo: validTo, IssuedAt: issuedAt, ExpiresAt: expiresAt, Forecaster: sql.NullString{String: "DIAL", Valid: true}, SourceURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.html", Valid: true}, ImageURL: sql.NullString{String: "https://www.spc.noaa.gov/products/outlook/day1otlk.gif", Valid: true}, ContainsLocation: true, GeometryJSON: geometry, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if outlook.ID != "spc-20260611-1300-day1-cat-slight" { t.Fatalf("unexpected outlook id: %q", outlook.ID) } if outlook.Provider != "spc" || outlook.Product != "convective" || outlook.Day != 1 || outlook.OutlookType != "categorical" { t.Fatalf("unexpected core fields: %+v", outlook) } if outlook.Label != "SLGT" || outlook.LabelText != "Slight Risk" { t.Fatalf("unexpected label fields: %+v", outlook) } if outlook.SeverityRank == nil || *outlook.SeverityRank != int(severityRank) { t.Fatalf("expected severity rank %d, got %v", severityRank, outlook.SeverityRank) } if outlook.Forecaster != "DIAL" { t.Fatalf("unexpected forecaster: %+v", outlook) } if outlook.SourceURL == "" || outlook.ImageURL == "" { t.Fatalf("expected source and image URLs: %+v", outlook) } if !outlook.ContainsLocation { t.Fatal("expected containsLocation true") } if outlook.ValidFrom.Location().String() != "UTC" || outlook.ValidTo.Location().String() != "UTC" || outlook.IssuedAt.Location().String() != "UTC" || outlook.ExpiresAt.Location().String() != "UTC" { t.Fatalf("expected UTC timestamps, got %s %s %s %s", outlook.ValidFrom, outlook.ValidTo, outlook.IssuedAt, outlook.ExpiresAt) } if string(outlook.Geometry) != geometry { t.Fatalf("expected geometry bytes preserved, got %s", outlook.Geometry) } } func TestMapOutlookRowMissingOptionals(t *testing.T) { outlook, err := mapOutlookRow(outlookRow{ OutlookID: "tor-1", Provider: "spc", Product: "convective", Day: 1, OutlookType: "tornado", Label: "2%", ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), ContainsLocation: false, GeometryJSON: `{"type":"Point","coordinates":[-90.2,38.6]}`, }) if err != nil { t.Fatalf("unexpected error: %v", err) } if outlook.LabelText != "" || outlook.Forecaster != "" || outlook.SourceURL != "" || outlook.ImageURL != "" { t.Fatalf("expected optional strings to map to empty values, got %+v", outlook) } if outlook.SeverityRank != nil { t.Fatalf("expected nil severity rank, got %v", *outlook.SeverityRank) } if outlook.ContainsLocation { t.Fatal("expected containsLocation false") } } func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) { _, err := mapOutlookRow(outlookRow{ OutlookID: "bad-geometry", Provider: "spc", Product: "convective", Day: 1, OutlookType: "categorical", Label: "SLGT", ValidFrom: time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC), ValidTo: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), IssuedAt: time.Date(2026, 6, 11, 11, 0, 0, 0, time.UTC), ExpiresAt: time.Date(2026, 6, 12, 0, 0, 0, 0, time.UTC), GeometryJSON: `{"type":"Point"`, }) if err == nil { t.Fatal("expected invalid geometry error") } } func TestMapOutlookDiscussionRowMapsFields(t *testing.T) { updatedAt := time.Date(2026, 6, 11, 7, 30, 0, 0, time.FixedZone("CDT", -5*3600)) discussion := mapOutlookDiscussionRow(outlookDiscussionRow{ DiscussionIndex: 2, Day: 2, Headline: sql.NullString{String: "Severe storms possible", Valid: true}, Summary: sql.NullString{String: "Scattered severe storms are possible.", Valid: true}, Discussion: sql.NullString{String: "Discussion text.", Valid: true}, UpdatedAt: sql.NullTime{Time: updatedAt, Valid: true}, }) if discussion.Day != 2 { t.Fatalf("expected day 2, got %d", discussion.Day) } if discussion.Headline != "Severe storms possible" { t.Fatalf("unexpected headline: %q", discussion.Headline) } if discussion.Summary != "Scattered severe storms are possible." { t.Fatalf("unexpected summary: %q", discussion.Summary) } if discussion.Discussion != "Discussion text." { t.Fatalf("unexpected discussion: %q", discussion.Discussion) } if discussion.UpdatedAt == nil || discussion.UpdatedAt.Location().String() != "UTC" { t.Fatalf("expected updatedAt UTC pointer, got %v", discussion.UpdatedAt) } } func TestMapOutlookDiscussionRowMissingOptionals(t *testing.T) { discussion := mapOutlookDiscussionRow(outlookDiscussionRow{ DiscussionIndex: 1, Day: 1, }) if discussion.Day != 1 { t.Fatalf("expected day 1, got %d", discussion.Day) } if discussion.Headline != "" || discussion.Summary != "" || discussion.Discussion != "" { t.Fatalf("expected empty optional strings, got %+v", discussion) } if discussion.UpdatedAt != nil { t.Fatalf("expected nil updatedAt, got %v", discussion.UpdatedAt) } }