All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
833 lines
30 KiB
Go
833 lines
30 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
fkevent "gitea.maximumdirect.net/ejr/feedkit/event"
|
|
fksinks "gitea.maximumdirect.net/ejr/feedkit/sinks"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/model"
|
|
"gitea.maximumdirect.net/ejr/weatherfeeder/standards"
|
|
)
|
|
|
|
func TestMapPostgresEventObservationStructPayload(t *testing.T) {
|
|
isDay := true
|
|
temp := 21.5
|
|
obs := model.WeatherObservation{
|
|
StationID: "KSTL",
|
|
StationName: "St. Louis",
|
|
Timestamp: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
|
ConditionCode: model.WMOCode(1),
|
|
IsDay: &isDay,
|
|
TextDescription: "few clouds",
|
|
TemperatureC: &temp,
|
|
PresentWeather: []model.PresentWeather{{Raw: map[string]any{"a": 1, "b": "x"}}},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherObservationV1, standards.KindObservation, obs))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 2 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 2", len(writes))
|
|
}
|
|
if writes[0].Table != tableObservations {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableObservations)
|
|
}
|
|
if got := writes[0].Values["station_id"]; got != "KSTL" {
|
|
t.Fatalf("observations station_id = %#v, want KSTL", got)
|
|
}
|
|
if writes[1].Table != tableObservationPresentWeather {
|
|
t.Fatalf("writes[1].Table = %q, want %q", writes[1].Table, tableObservationPresentWeather)
|
|
}
|
|
if got := writes[1].Values["raw_text"]; got != `{"a":1,"b":"x"}` {
|
|
t.Fatalf("present_weather raw_text = %#v, want compact JSON", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventForecastStructPayload(t *testing.T) {
|
|
isDay := true
|
|
temp := 10.5
|
|
run := model.WeatherForecastRun{
|
|
LocationID: "LOC-1",
|
|
LocationName: "St. Louis",
|
|
IssuedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
|
Product: model.ForecastProductHourly,
|
|
Periods: []model.WeatherForecastPeriod{
|
|
{
|
|
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
|
EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
|
IsDay: &isDay,
|
|
ConditionCode: wmoCodePtr(model.WMOCode(2)),
|
|
TemperatureC: &temp,
|
|
},
|
|
{
|
|
StartTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
|
EndTime: time.Date(2026, 3, 16, 21, 0, 0, 0, time.UTC),
|
|
ConditionCode: nil,
|
|
},
|
|
},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 3 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 3", len(writes))
|
|
}
|
|
|
|
if writes[0].Table != tableForecasts {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableForecasts)
|
|
}
|
|
if got := writes[0].Values["period_count"]; got != 2 {
|
|
t.Fatalf("forecasts period_count = %#v, want 2", got)
|
|
}
|
|
if writes[1].Table != tableForecastPeriods || writes[2].Table != tableForecastPeriods {
|
|
t.Fatalf("forecast period writes not in expected order")
|
|
}
|
|
if got := writes[1].Values["period_index"]; got != 0 {
|
|
t.Fatalf("first period index = %#v, want 0", got)
|
|
}
|
|
if got := writes[2].Values["condition_code"]; got != nil {
|
|
t.Fatalf("second period condition_code = %#v, want nil", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventAlertStructPayload(t *testing.T) {
|
|
sent := time.Date(2026, 3, 16, 17, 0, 0, 0, time.UTC)
|
|
ends := time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC)
|
|
expires := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
|
run := model.WeatherAlertRun{
|
|
AsOf: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
|
Alerts: []model.WeatherAlert{
|
|
{
|
|
ID: "urn:alert:1",
|
|
Headline: "Winter Weather Advisory",
|
|
Severity: "Moderate",
|
|
Ends: &ends,
|
|
Expires: &expires,
|
|
References: []model.AlertReference{
|
|
{ID: "urn:ref:1", Sent: &sent},
|
|
{Identifier: "ref-two"},
|
|
},
|
|
},
|
|
{
|
|
ID: "urn:alert:2",
|
|
Headline: "Second alert",
|
|
},
|
|
},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherAlertV1, standards.KindAlert, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 5 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 5", len(writes))
|
|
}
|
|
|
|
counts := map[string]int{}
|
|
for _, w := range writes {
|
|
counts[w.Table]++
|
|
}
|
|
if counts[tableAlertRuns] != 1 || counts[tableAlerts] != 2 || counts[tableAlertReferences] != 2 {
|
|
t.Fatalf("unexpected table write counts: %#v", counts)
|
|
}
|
|
|
|
firstAlert, ok := firstWriteForTable(writes, tableAlerts)
|
|
if !ok {
|
|
t.Fatalf("missing alerts write")
|
|
}
|
|
if got := firstAlert.Values["reference_count"]; got != 2 {
|
|
t.Fatalf("alerts reference_count = %#v, want 2", got)
|
|
}
|
|
if got := firstAlert.Values["ends"]; got != ends {
|
|
t.Fatalf("alerts ends = %#v, want %#v", got, ends)
|
|
}
|
|
if got := firstAlert.Values["expires"]; got != expires {
|
|
t.Fatalf("alerts expires = %#v, want %#v", got, expires)
|
|
}
|
|
|
|
alertWrites := writesForTable(writes, tableAlerts)
|
|
if len(alertWrites) != 2 {
|
|
t.Fatalf("alert writes len = %d, want 2", len(alertWrites))
|
|
}
|
|
if got := alertWrites[1].Values["ends"]; got != nil {
|
|
t.Fatalf("second alert ends = %#v, want nil", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventForecastDiscussionStructPayload(t *testing.T) {
|
|
updatedAt := time.Date(2026, 3, 28, 20, 29, 47, 0, time.UTC)
|
|
shortIssuedAt := time.Date(2026, 3, 28, 19, 19, 0, 0, time.UTC)
|
|
run := model.WeatherForecastDiscussion{
|
|
OfficeID: "LSX",
|
|
OfficeName: "National Weather Service Saint Louis MO",
|
|
Product: model.ForecastDiscussionProductAFD,
|
|
IssuedAt: time.Date(2026, 3, 28, 19, 24, 0, 0, time.UTC),
|
|
UpdatedAt: &updatedAt,
|
|
KeyMessages: []string{"msg one", "msg two"},
|
|
ShortTerm: &model.WeatherForecastDiscussionSection{Qualifier: "(Tonight)", IssuedAt: &shortIssuedAt, Text: "Short term text"},
|
|
LongTerm: &model.WeatherForecastDiscussionSection{Text: "Long term text"},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, standards.KindForecastDiscussion, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 3 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 3", len(writes))
|
|
}
|
|
|
|
if writes[0].Table != tableForecastDiscussions {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableForecastDiscussions)
|
|
}
|
|
if got := writes[0].Values["key_message_count"]; got != 2 {
|
|
t.Fatalf("forecast_discussions key_message_count = %#v, want 2", got)
|
|
}
|
|
if got := writes[0].Values["short_term_qualifier"]; got != "(Tonight)" {
|
|
t.Fatalf("forecast_discussions short_term_qualifier = %#v, want (Tonight)", got)
|
|
}
|
|
if got := writes[0].Values["long_term_issued_at"]; got != nil {
|
|
t.Fatalf("forecast_discussions long_term_issued_at = %#v, want nil", got)
|
|
}
|
|
if writes[1].Table != tableForecastDiscussionKeyMessages || writes[2].Table != tableForecastDiscussionKeyMessages {
|
|
t.Fatalf("forecast discussion key message writes not in expected order")
|
|
}
|
|
if got := writes[2].Values["message_index"]; got != 1 {
|
|
t.Fatalf("second key message index = %#v, want 1", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventWeatherStoryStructPayload(t *testing.T) {
|
|
run := model.WeatherStoryRun{
|
|
OfficeID: "LSX",
|
|
AsOf: time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC),
|
|
Stories: []model.WeatherStory{
|
|
{
|
|
OfficeID: "LSX",
|
|
StartTime: time.Date(2026, 5, 30, 8, 46, 0, 0, time.UTC),
|
|
EndTime: time.Date(2026, 5, 31, 11, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC),
|
|
Title: "Several Chances for Rain Through Monday",
|
|
Description: "Scattered showers and thunderstorms.",
|
|
AltText: "This slide shows the forecast.",
|
|
Priority: true,
|
|
Order: 1,
|
|
DownloadURL: "https://api.weather.gov/offices/LSX/weatherstories/download/story-1",
|
|
},
|
|
},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 2 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 2", len(writes))
|
|
}
|
|
if writes[0].Table != tableWeatherStoryRuns {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableWeatherStoryRuns)
|
|
}
|
|
if got := writes[0].Values["story_count"]; got != 1 {
|
|
t.Fatalf("weather_story_runs story_count = %#v, want 1", got)
|
|
}
|
|
if writes[1].Table != tableWeatherStories {
|
|
t.Fatalf("writes[1].Table = %q, want %q", writes[1].Table, tableWeatherStories)
|
|
}
|
|
if got := writes[1].Values["download_url"]; got != "https://api.weather.gov/offices/LSX/weatherstories/download/story-1" {
|
|
t.Fatalf("weather_stories download_url = %#v", got)
|
|
}
|
|
if got := writes[1].Values["story_order"]; got != 1 {
|
|
t.Fatalf("weather_stories story_order = %#v, want 1", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
|
|
lat := 38.6239
|
|
lon := -90.3571
|
|
issuedAt := time.Date(2026, 6, 11, 19, 45, 0, 0, time.FixedZone("UTC-5", -5*60*60))
|
|
updatedAt := time.Date(2026, 6, 11, 21, 15, 0, 0, time.FixedZone("UTC-5", -5*60*60))
|
|
severity := 3
|
|
run := model.WeatherOutlookRun{
|
|
LocationID: "stl",
|
|
LocationName: "St. Louis, MO",
|
|
Latitude: &lat,
|
|
Longitude: &lon,
|
|
AsOf: time.Date(2026, 6, 12, 0, 45, 0, 0, time.UTC),
|
|
IssuedAt: &issuedAt,
|
|
Outlooks: []model.WeatherOutlook{
|
|
{
|
|
ID: "outlook-1",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "categorical",
|
|
Label: "SLGT",
|
|
LabelText: "Slight Risk",
|
|
SeverityRank: &severity,
|
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.FixedZone("UTC-5", -5*60*60)),
|
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
IssuedAt: issuedAt,
|
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
Forecaster: "SMITH",
|
|
SourceURL: "https://example.invalid/day1.geojson",
|
|
ContainsLocation: true,
|
|
Geometry: json.RawMessage(`{ "type" : "Polygon", "coordinates" : [ [ [ -91.0, 38.0 ], [ -90.0, 38.0 ], [ -90.0, 39.0 ], [ -91.0, 39.0 ], [ -91.0, 38.0 ] ] ] }`),
|
|
},
|
|
{
|
|
ID: "outlook-2",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "wind",
|
|
Label: "15",
|
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
ContainsLocation: true,
|
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-100,35],[-98,35],[-98,37],[-100,37],[-100,35]]]}`),
|
|
},
|
|
},
|
|
Discussions: []model.WeatherOutlookDiscussion{
|
|
{
|
|
Day: 1,
|
|
Headline: "Day 1 Convective Outlook",
|
|
Summary: "Severe thunderstorms are possible.",
|
|
Discussion: "Full discussion text.",
|
|
UpdatedAt: &updatedAt,
|
|
},
|
|
},
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 4 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 4", len(writes))
|
|
}
|
|
if writes[0].Table != tableOutlookRuns {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableOutlookRuns)
|
|
}
|
|
if got := writes[0].Values["outlook_count"]; got != 2 {
|
|
t.Fatalf("outlook_runs outlook_count = %#v, want 2", got)
|
|
}
|
|
if got := writes[0].Values["discussion_count"]; got != 1 {
|
|
t.Fatalf("outlook_runs discussion_count = %#v, want 1", got)
|
|
}
|
|
if got := writes[0].Values["issued_at"]; got != issuedAt.UTC() {
|
|
t.Fatalf("outlook_runs issued_at = %#v, want UTC %s", got, issuedAt.UTC())
|
|
}
|
|
if writes[1].Table != tableOutlooks || writes[2].Table != tableOutlooks {
|
|
t.Fatalf("outlook writes not in expected order")
|
|
}
|
|
if got := writes[1].Values["outlook_index"]; got != 0 {
|
|
t.Fatalf("first outlook index = %#v, want 0", got)
|
|
}
|
|
if got := writes[1].Values["outlook_id"]; got != "outlook-1" {
|
|
t.Fatalf("first outlook_id = %#v, want outlook-1", got)
|
|
}
|
|
if got := writes[1].Values["provider"]; got != "spc" {
|
|
t.Fatalf("first provider = %#v, want spc", got)
|
|
}
|
|
if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
|
|
t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
|
|
}
|
|
if got := writes[1].Values["geometry_json"]; got != `{"type":"Polygon","coordinates":[[[-91.0,38.0],[-90.0,38.0],[-90.0,39.0],[-91.0,39.0],[-91.0,38.0]]]}` {
|
|
t.Fatalf("first geometry_json = %#v", got)
|
|
}
|
|
if got := writes[2].Values["contains_location"]; got != true {
|
|
t.Fatalf("second contains_location = %#v, want true", got)
|
|
}
|
|
if writes[3].Table != tableOutlookDiscussions {
|
|
t.Fatalf("writes[3].Table = %q, want %q", writes[3].Table, tableOutlookDiscussions)
|
|
}
|
|
if got := writes[3].Values["discussion_index"]; got != 0 {
|
|
t.Fatalf("discussion_index = %#v, want 0", got)
|
|
}
|
|
if got := writes[3].Values["as_of"]; got != run.AsOf.UTC() {
|
|
t.Fatalf("discussion as_of = %#v, want %s", got, run.AsOf.UTC())
|
|
}
|
|
if got := writes[3].Values["day"]; got != 1 {
|
|
t.Fatalf("discussion day = %#v, want 1", got)
|
|
}
|
|
if got := writes[3].Values["headline"]; got != "Day 1 Convective Outlook" {
|
|
t.Fatalf("discussion headline = %#v", got)
|
|
}
|
|
if got := writes[3].Values["summary"]; got != "Severe thunderstorms are possible." {
|
|
t.Fatalf("discussion summary = %#v", got)
|
|
}
|
|
if got := writes[3].Values["discussion"]; got != "Full discussion text." {
|
|
t.Fatalf("discussion text = %#v", got)
|
|
}
|
|
if got := writes[3].Values["updated_at"]; got != updatedAt.UTC() {
|
|
t.Fatalf("discussion updated_at = %#v, want UTC %s", got, updatedAt.UTC())
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookEmptyLocalRun(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 1 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 1", len(writes))
|
|
}
|
|
if writes[0].Table != tableOutlookRuns {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableOutlookRuns)
|
|
}
|
|
if got := writes[0].Values["outlook_count"]; got != 0 {
|
|
t.Fatalf("outlook_runs outlook_count = %#v, want 0", got)
|
|
}
|
|
if got := writes[0].Values["discussion_count"]; got != 0 {
|
|
t.Fatalf("outlook_runs discussion_count = %#v, want 0", got)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, model.WeatherOutlookRun{}))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
|
}
|
|
if !strings.Contains(err.Error(), "asOf is required") {
|
|
t.Fatalf("error = %q, want asOf context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
|
|
base := model.WeatherOutlook{
|
|
ID: "outlook-1",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "categorical",
|
|
Label: "SLGT",
|
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
ContainsLocation: true,
|
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*model.WeatherOutlook)
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing id",
|
|
mutate: func(outlook *model.WeatherOutlook) { outlook.ID = "" },
|
|
wantErr: "outlooks[0].id is required",
|
|
},
|
|
{
|
|
name: "missing provider",
|
|
mutate: func(outlook *model.WeatherOutlook) { outlook.Provider = "" },
|
|
wantErr: "outlooks[0].provider is required",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
outlook := base
|
|
tt.mutate(&outlook)
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Outlooks: []model.WeatherOutlook{outlook},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("error = %q, want %q", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Outlooks: []model.WeatherOutlook{{
|
|
ID: "outlook-1",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "categorical",
|
|
Label: "SLGT",
|
|
ContainsLocation: true,
|
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
|
}},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want missing time error")
|
|
}
|
|
if !strings.Contains(err.Error(), "outlooks[0] validFrom/validTo are required") {
|
|
t.Fatalf("error = %q, want outlook time context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsEmptyGeometry(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Outlooks: []model.WeatherOutlook{{
|
|
ID: "outlook-1",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "categorical",
|
|
Label: "SLGT",
|
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
ContainsLocation: true,
|
|
}},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want geometry error")
|
|
}
|
|
if !strings.Contains(err.Error(), "outlooks[0].geometry is required") {
|
|
t.Fatalf("error = %q, want geometry context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsDuplicateDiscussionDay(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Discussions: []model.WeatherOutlookDiscussion{
|
|
{Day: 1, Discussion: "First day one discussion."},
|
|
{Day: 1, Discussion: "Duplicate day one discussion."},
|
|
},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want duplicate discussion day error")
|
|
}
|
|
if !strings.Contains(err.Error(), "discussions[1].day duplicates discussions[0].day 1") {
|
|
t.Fatalf("error = %q, want duplicate discussion day context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsInvalidDiscussionDay(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Discussions: []model.WeatherOutlookDiscussion{{Day: 4, Discussion: "Invalid day."}},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want invalid discussion day error")
|
|
}
|
|
if !strings.Contains(err.Error(), "discussions[0].day must be 1, 2, or 3") {
|
|
t.Fatalf("error = %q, want invalid discussion day context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsEmptyDiscussionContent(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Discussions: []model.WeatherOutlookDiscussion{{Day: 1}},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want empty discussion content error")
|
|
}
|
|
if !strings.Contains(err.Error(), "discussions[0] headline, summary, or discussion is required") {
|
|
t.Fatalf("error = %q, want empty discussion content context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventOutlookRejectsContainsLocationFalse(t *testing.T) {
|
|
run := model.WeatherOutlookRun{
|
|
AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
Outlooks: []model.WeatherOutlook{validTestOutlook()},
|
|
}
|
|
run.Outlooks[0].ContainsLocation = false
|
|
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV2, standards.KindOutlook, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want containsLocation error")
|
|
}
|
|
if !strings.Contains(err.Error(), "outlooks[0].containsLocation must be true") {
|
|
t.Fatalf("error = %q, want containsLocation context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventWeatherStoryRejectsMissingAsOf(t *testing.T) {
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, model.WeatherStoryRun{}))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want missing asOf error")
|
|
}
|
|
if !strings.Contains(err.Error(), "asOf is required") {
|
|
t.Fatalf("error = %q, want asOf context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventWeatherStoryRejectsMissingStoryTimes(t *testing.T) {
|
|
run := model.WeatherStoryRun{
|
|
AsOf: time.Date(2026, 5, 30, 9, 0, 34, 0, time.UTC),
|
|
Stories: []model.WeatherStory{{Title: "missing times"}},
|
|
}
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherStoryV1, standards.KindWeatherStory, run))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() error = nil, want missing story times error")
|
|
}
|
|
if !strings.Contains(err.Error(), "stories[0] startTime/endTime/updatedAt are required") {
|
|
t.Fatalf("error = %q, want story time context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventMapPayload(t *testing.T) {
|
|
run := model.WeatherForecastRun{
|
|
IssuedAt: time.Date(2026, 3, 16, 18, 0, 0, 0, time.UTC),
|
|
Product: model.ForecastProductHourly,
|
|
Periods: []model.WeatherForecastPeriod{
|
|
{
|
|
StartTime: time.Date(2026, 3, 16, 19, 0, 0, 0, time.UTC),
|
|
EndTime: time.Date(2026, 3, 16, 20, 0, 0, 0, time.UTC),
|
|
ConditionCode: wmoCodePtr(model.WMOCode(2)),
|
|
},
|
|
},
|
|
}
|
|
b, err := json.Marshal(run)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(b, &payload); err != nil {
|
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, payload))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 2 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 2", len(writes))
|
|
}
|
|
if writes[0].Table != tableForecasts {
|
|
t.Fatalf("writes[0].Table = %q, want %q", writes[0].Table, tableForecasts)
|
|
}
|
|
|
|
assertAllWritesIncludeAllColumns(t, writes)
|
|
}
|
|
|
|
func TestMapPostgresEventUnknownSchemaNoOp(t *testing.T) {
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent("weather.unknown.v1", standards.KindObservation, map[string]any{"x": 1}))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 0 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 0", len(writes))
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventLegacyOutlookSchemaNoOp(t *testing.T) {
|
|
run := model.WeatherOutlookRun{AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC)}
|
|
writes, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, standards.KindOutlook, run))
|
|
if err != nil {
|
|
t.Fatalf("mapPostgresEvent() error = %v", err)
|
|
}
|
|
if len(writes) != 0 {
|
|
t.Fatalf("mapPostgresEvent() writes len = %d, want 0", len(writes))
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventMalformedPayload(t *testing.T) {
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastV1, standards.KindForecast, "bad"))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode forecast payload") {
|
|
t.Fatalf("error = %q, want decode forecast payload context", err)
|
|
}
|
|
}
|
|
|
|
func TestMapPostgresEventForecastDiscussionMalformedPayload(t *testing.T) {
|
|
_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherForecastDiscussionV1, standards.KindForecastDiscussion, "bad"))
|
|
if err == nil {
|
|
t.Fatalf("mapPostgresEvent() expected error for malformed payload")
|
|
}
|
|
if !strings.Contains(err.Error(), "decode forecast discussion payload") {
|
|
t.Fatalf("error = %q, want decode forecast discussion payload context", err)
|
|
}
|
|
}
|
|
|
|
func TestParentEventValuesAddsEnvelopeAndPreservesProductValues(t *testing.T) {
|
|
emittedAt := time.Date(2026, 3, 16, 13, 31, 0, 0, time.FixedZone("CDT", -5*60*60))
|
|
effectiveAt := time.Date(2026, 3, 16, 13, 30, 0, 0, time.FixedZone("CDT", -5*60*60))
|
|
event := fkevent.Event{
|
|
ID: "evt-envelope",
|
|
Kind: fkevent.Kind(standards.KindForecast),
|
|
Source: "test-source",
|
|
Schema: standards.SchemaWeatherForecastV1,
|
|
EmittedAt: emittedAt,
|
|
EffectiveAt: &effectiveAt,
|
|
}
|
|
|
|
got := parentEventValues(event, map[string]any{"product_col": "product-value"})
|
|
|
|
assertParentEnvelopeValues(t, got, event)
|
|
if got["product_col"] != "product-value" {
|
|
t.Fatalf("product_col = %#v, want product-value", got["product_col"])
|
|
}
|
|
}
|
|
|
|
func TestParentEventValuesNullEffectiveAt(t *testing.T) {
|
|
base := fkevent.Event{
|
|
ID: "evt-envelope",
|
|
Kind: fkevent.Kind(standards.KindObservation),
|
|
Source: "test-source",
|
|
Schema: standards.SchemaWeatherObservationV1,
|
|
EmittedAt: time.Date(2026, 3, 16, 18, 31, 0, 0, time.UTC),
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
mut func(*fkevent.Event)
|
|
}{
|
|
{name: "nil", mut: func(*fkevent.Event) {}},
|
|
{name: "zero", mut: func(event *fkevent.Event) {
|
|
zero := time.Time{}
|
|
event.EffectiveAt = &zero
|
|
}},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
event := base
|
|
tt.mut(&event)
|
|
got := parentEventValues(event, nil)
|
|
if got["event_effective_at"] != nil {
|
|
t.Fatalf("event_effective_at = %#v, want nil", got["event_effective_at"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testEvent(schema string, kind fkevent.Kind, payload any) fkevent.Event {
|
|
effectiveAt := time.Date(2026, 3, 16, 18, 30, 0, 0, time.UTC)
|
|
return fkevent.Event{
|
|
ID: "evt-1",
|
|
Kind: kind,
|
|
Source: "test-source",
|
|
Schema: schema,
|
|
EmittedAt: time.Date(2026, 3, 16, 18, 31, 0, 0, time.UTC),
|
|
EffectiveAt: &effectiveAt,
|
|
Payload: payload,
|
|
}
|
|
}
|
|
|
|
func assertParentEnvelopeValues(t *testing.T, values map[string]any, event fkevent.Event) {
|
|
t.Helper()
|
|
|
|
if got := values["event_id"]; got != event.ID {
|
|
t.Fatalf("event_id = %#v, want %q", got, event.ID)
|
|
}
|
|
if got := values["event_kind"]; got != string(event.Kind) {
|
|
t.Fatalf("event_kind = %#v, want %q", got, event.Kind)
|
|
}
|
|
if got := values["event_source"]; got != event.Source {
|
|
t.Fatalf("event_source = %#v, want %q", got, event.Source)
|
|
}
|
|
if got := values["event_schema"]; got != event.Schema {
|
|
t.Fatalf("event_schema = %#v, want %q", got, event.Schema)
|
|
}
|
|
if got := values["event_emitted_at"]; got != event.EmittedAt.UTC() {
|
|
t.Fatalf("event_emitted_at = %#v, want %s", got, event.EmittedAt.UTC())
|
|
}
|
|
wantEffective := nullableTime(event.EffectiveAt)
|
|
if got := values["event_effective_at"]; got != wantEffective {
|
|
t.Fatalf("event_effective_at = %#v, want %#v", got, wantEffective)
|
|
}
|
|
}
|
|
|
|
func firstWriteForTable(writes []fksinks.PostgresWrite, table string) (fksinks.PostgresWrite, bool) {
|
|
for _, w := range writes {
|
|
if w.Table == table {
|
|
return w, true
|
|
}
|
|
}
|
|
return fksinks.PostgresWrite{}, false
|
|
}
|
|
|
|
func writesForTable(writes []fksinks.PostgresWrite, table string) []fksinks.PostgresWrite {
|
|
out := make([]fksinks.PostgresWrite, 0)
|
|
for _, w := range writes {
|
|
if w.Table == table {
|
|
out = append(out, w)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func assertAllWritesIncludeAllColumns(t *testing.T, writes []fksinks.PostgresWrite) {
|
|
t.Helper()
|
|
colCounts := tableColumnCounts()
|
|
for i, w := range writes {
|
|
expectedCount, ok := colCounts[w.Table]
|
|
if !ok {
|
|
t.Fatalf("writes[%d] references unknown table %q", i, w.Table)
|
|
}
|
|
if len(w.Values) != expectedCount {
|
|
t.Fatalf("writes[%d] table=%q has %d values, want %d", i, w.Table, len(w.Values), expectedCount)
|
|
}
|
|
}
|
|
}
|
|
|
|
func tableColumnCounts() map[string]int {
|
|
s := PostgresSchema()
|
|
m := make(map[string]int, len(s.Tables))
|
|
for _, tbl := range s.Tables {
|
|
m[tbl.Name] = len(tbl.Columns)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func validTestOutlook() model.WeatherOutlook {
|
|
return model.WeatherOutlook{
|
|
ID: "outlook-1",
|
|
Provider: "spc",
|
|
Product: "convective",
|
|
Day: 1,
|
|
OutlookType: "categorical",
|
|
Label: "SLGT",
|
|
ValidFrom: time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
|
|
ValidTo: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
IssuedAt: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
|
|
ExpiresAt: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
|
|
ContainsLocation: true,
|
|
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
|
|
}
|
|
}
|
|
|
|
func wmoCodePtr(v model.WMOCode) *model.WMOCode {
|
|
out := v
|
|
return &out
|
|
}
|