From 63c8f33a2aa0ac383235d3b20448b52d11f46f5c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 11 Jun 2026 15:33:26 +0000 Subject: [PATCH] Add convective outlook Postgres read adapter --- .../outbound/postgres/outlooks_mapper.go | 60 ++++++ .../outbound/postgres/outlooks_mapper_test.go | 174 ++++++++++++++++++ .../outbound/postgres/outlooks_queries.go | 45 +++++ .../outbound/postgres/outlooks_read.go | 92 +++++++++ .../outbound/postgres/outlooks_rows.go | 42 +++++ 5 files changed, 413 insertions(+) create mode 100644 internal/adapters/outbound/postgres/outlooks_mapper.go create mode 100644 internal/adapters/outbound/postgres/outlooks_mapper_test.go create mode 100644 internal/adapters/outbound/postgres/outlooks_queries.go create mode 100644 internal/adapters/outbound/postgres/outlooks_read.go create mode 100644 internal/adapters/outbound/postgres/outlooks_rows.go diff --git a/internal/adapters/outbound/postgres/outlooks_mapper.go b/internal/adapters/outbound/postgres/outlooks_mapper.go new file mode 100644 index 0000000..5777ca8 --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_mapper.go @@ -0,0 +1,60 @@ +// outlooks_mapper.go maps outlook rows into weather model payloads. +// Layer: adapters/outbound/postgres outlook feature. +package postgres + +import ( + "database/sql" + "encoding/json" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func mapOutlookRunParentRow(row outlookRunParentRow) model.WeatherOutlookRun { + return model.WeatherOutlookRun{ + LocationID: stringValue(row.LocationID), + LocationName: stringValue(row.LocationName), + Latitude: float64Ptr(row.Latitude), + Longitude: float64Ptr(row.Longitude), + AsOf: row.AsOf.UTC(), + IssuedAt: timePtr(row.IssuedAt), + } +} + +func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) { + geometry := []byte(row.GeometryJSON) + if !json.Valid(geometry) { + return model.WeatherOutlook{}, fmt.Errorf("decode outlook geometry: invalid JSON") + } + + return model.WeatherOutlook{ + ID: row.OutlookID, + Provider: row.Provider, + Product: row.Product, + Day: row.Day, + OutlookType: row.OutlookType, + Label: row.Label, + LabelText: stringValue(row.LabelText), + SeverityRank: intPtr(row.SeverityRank), + ValidFrom: row.ValidFrom.UTC(), + ValidTo: row.ValidTo.UTC(), + IssuedAt: row.IssuedAt.UTC(), + ExpiresAt: row.ExpiresAt.UTC(), + Forecaster: stringValue(row.Forecaster), + Headline: stringValue(row.Headline), + Summary: stringValue(row.Summary), + Discussion: stringValue(row.Discussion), + SourceURL: stringValue(row.SourceURL), + ImageURL: stringValue(row.ImageURL), + ContainsLocation: row.ContainsLocation, + Geometry: json.RawMessage(append([]byte(nil), geometry...)), + }, nil +} + +func intPtr(v sql.NullInt64) *int { + if !v.Valid { + return nil + } + out := int(v.Int64) + return &out +} diff --git a/internal/adapters/outbound/postgres/outlooks_mapper_test.go b/internal/adapters/outbound/postgres/outlooks_mapper_test.go new file mode 100644 index 0000000..34a1bdd --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_mapper_test.go @@ -0,0 +1,174 @@ +// 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}, + 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}, + 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" || outlook.Headline == "" || outlook.Summary == "" || outlook.Discussion == "" { + t.Fatalf("unexpected text fields: %+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.Headline != "" || + outlook.Summary != "" || outlook.Discussion != "" || 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") + } +} diff --git a/internal/adapters/outbound/postgres/outlooks_queries.go b/internal/adapters/outbound/postgres/outlooks_queries.go new file mode 100644 index 0000000..dff648e --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_queries.go @@ -0,0 +1,45 @@ +// outlooks_queries.go contains SQL text for outlook reads. +// Layer: adapters/outbound/postgres outlook feature. +package postgres + +const ( + queryLatestConvectiveOutlookRun = ` +SELECT + event_id, + location_id, + location_name, + latitude, + longitude, + as_of, + issued_at +FROM outlook_runs +ORDER BY as_of DESC, event_emitted_at DESC +LIMIT 1` + + queryOutlooksForRun = ` +SELECT + outlook_index, + outlook_id, + provider, + product, + day, + outlook_type, + label, + label_text, + severity_rank, + valid_from, + valid_to, + issued_at, + expires_at, + forecaster, + headline, + summary, + discussion, + source_url, + image_url, + contains_location, + geometry_json +FROM outlooks +WHERE run_event_id = $1 +ORDER BY outlook_index ASC` +) diff --git a/internal/adapters/outbound/postgres/outlooks_read.go b/internal/adapters/outbound/postgres/outlooks_read.go new file mode 100644 index 0000000..29f9f07 --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_read.go @@ -0,0 +1,92 @@ +// outlooks_read.go executes outlook-run and outlook queries. +// Layer: adapters/outbound/postgres outlook feature. +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "gitea.maximumdirect.net/ejr/weatherfeeder/model" +) + +func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.WeatherOutlookRun, error) { + if r == nil || r.db == nil { + return nil, fmt.Errorf("postgres repository is not configured") + } + + var row outlookRunParentRow + err := r.db.QueryRowContext(ctx, queryLatestConvectiveOutlookRun).Scan( + &row.EventID, + &row.LocationID, + &row.LocationName, + &row.Latitude, + &row.Longitude, + &row.AsOf, + &row.IssuedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("query latest convective outlook run: %w", err) + } + + run := mapOutlookRunParentRow(row) + outlooks, err := r.loadOutlooks(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Outlooks = outlooks + + return &run, nil +} + +func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model.WeatherOutlook, error) { + rows, err := r.db.QueryContext(ctx, queryOutlooksForRun, eventID) + if err != nil { + return nil, fmt.Errorf("query outlooks: %w", err) + } + defer rows.Close() + + out := make([]model.WeatherOutlook, 0) + for rows.Next() { + var row outlookRow + if err := rows.Scan( + &row.OutlookIndex, + &row.OutlookID, + &row.Provider, + &row.Product, + &row.Day, + &row.OutlookType, + &row.Label, + &row.LabelText, + &row.SeverityRank, + &row.ValidFrom, + &row.ValidTo, + &row.IssuedAt, + &row.ExpiresAt, + &row.Forecaster, + &row.Headline, + &row.Summary, + &row.Discussion, + &row.SourceURL, + &row.ImageURL, + &row.ContainsLocation, + &row.GeometryJSON, + ); err != nil { + return nil, fmt.Errorf("scan outlook row: %w", err) + } + + outlook, err := mapOutlookRow(row) + if err != nil { + return nil, fmt.Errorf("map outlook row: %w", err) + } + out = append(out, outlook) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate outlook rows: %w", err) + } + return out, nil +} diff --git a/internal/adapters/outbound/postgres/outlooks_rows.go b/internal/adapters/outbound/postgres/outlooks_rows.go new file mode 100644 index 0000000..cfcef01 --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_rows.go @@ -0,0 +1,42 @@ +// outlooks_rows.go defines row DTOs for outlook reads. +// Layer: adapters/outbound/postgres outlook feature. +package postgres + +import ( + "database/sql" + "time" +) + +type outlookRunParentRow struct { + EventID string + LocationID sql.NullString + LocationName sql.NullString + Latitude sql.NullFloat64 + Longitude sql.NullFloat64 + AsOf time.Time + IssuedAt sql.NullTime +} + +type outlookRow struct { + OutlookIndex int + OutlookID string + Provider string + Product string + Day int + OutlookType string + Label string + LabelText sql.NullString + SeverityRank sql.NullInt64 + ValidFrom time.Time + ValidTo time.Time + IssuedAt time.Time + ExpiresAt time.Time + Forecaster sql.NullString + Headline sql.NullString + Summary sql.NullString + Discussion sql.NullString + SourceURL sql.NullString + ImageURL sql.NullString + ContainsLocation bool + GeometryJSON string +}