Files
weatherapi/internal/adapters/outbound/postgres/outlooks_mapper_test.go

217 lines
7.8 KiB
Go

// 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)
}
}