Files
weatherapi/internal/adapters/outbound/postgres/repository_test.go
Eric Rakestraw ecea856e8e
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
Implemented weather stories support
2026-05-30 07:06:29 -05:00

299 lines
11 KiB
Go

// 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 TestMapCurrentConditionsRowNoSamplesReturnsNil(t *testing.T) {
got := mapCurrentConditionsRow(currentConditionsRow{
SampleCount: 0,
})
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},
ConditionCode: sql.NullInt64{Int64: 65, Valid: true},
IsDay: sql.NullBool{Bool: isDay, Valid: true},
})
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)
}
}