diff --git a/internal/adapters/outbound/postgres/outlooks_mapper.go b/internal/adapters/outbound/postgres/outlooks_mapper.go index 1cfb5b4..b79427d 100644 --- a/internal/adapters/outbound/postgres/outlooks_mapper.go +++ b/internal/adapters/outbound/postgres/outlooks_mapper.go @@ -48,6 +48,16 @@ func mapOutlookRow(row outlookRow) (model.WeatherOutlook, error) { }, nil } +func mapOutlookDiscussionRow(row outlookDiscussionRow) model.WeatherOutlookDiscussion { + return model.WeatherOutlookDiscussion{ + Day: row.Day, + Headline: stringValue(row.Headline), + Summary: stringValue(row.Summary), + Discussion: stringValue(row.Discussion), + UpdatedAt: timePtr(row.UpdatedAt), + } +} + func intPtr(v sql.NullInt64) *int { if !v.Valid { return nil diff --git a/internal/adapters/outbound/postgres/outlooks_mapper_test.go b/internal/adapters/outbound/postgres/outlooks_mapper_test.go index be8ff78..d487b17 100644 --- a/internal/adapters/outbound/postgres/outlooks_mapper_test.go +++ b/internal/adapters/outbound/postgres/outlooks_mapper_test.go @@ -168,3 +168,49 @@ func TestMapOutlookRowRejectsInvalidGeometry(t *testing.T) { 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) + } +} diff --git a/internal/adapters/outbound/postgres/outlooks_queries.go b/internal/adapters/outbound/postgres/outlooks_queries.go index 6c0d9ba..4215c38 100644 --- a/internal/adapters/outbound/postgres/outlooks_queries.go +++ b/internal/adapters/outbound/postgres/outlooks_queries.go @@ -39,4 +39,16 @@ SELECT FROM outlooks WHERE run_event_id = $1 ORDER BY outlook_index ASC` + + queryOutlookDiscussionsForRun = ` +SELECT + discussion_index, + day, + headline, + summary, + discussion, + updated_at +FROM outlook_discussions +WHERE run_event_id = $1 +ORDER BY discussion_index ASC` ) diff --git a/internal/adapters/outbound/postgres/outlooks_read.go b/internal/adapters/outbound/postgres/outlooks_read.go index 38866d6..8075883 100644 --- a/internal/adapters/outbound/postgres/outlooks_read.go +++ b/internal/adapters/outbound/postgres/outlooks_read.go @@ -40,6 +40,12 @@ func (r *Repository) LatestConvectiveOutlookRun(ctx context.Context) (*model.Wea } run.Outlooks = outlooks + discussions, err := r.loadOutlookDiscussions(ctx, row.EventID) + if err != nil { + return nil, err + } + run.Discussions = discussions + return &run, nil } @@ -87,3 +93,32 @@ func (r *Repository) loadOutlooks(ctx context.Context, eventID string) ([]model. } return out, nil } + +func (r *Repository) loadOutlookDiscussions(ctx context.Context, eventID string) ([]model.WeatherOutlookDiscussion, error) { + rows, err := r.db.QueryContext(ctx, queryOutlookDiscussionsForRun, eventID) + if err != nil { + return nil, fmt.Errorf("query outlook discussions: %w", err) + } + defer rows.Close() + + out := make([]model.WeatherOutlookDiscussion, 0) + for rows.Next() { + var row outlookDiscussionRow + if err := rows.Scan( + &row.DiscussionIndex, + &row.Day, + &row.Headline, + &row.Summary, + &row.Discussion, + &row.UpdatedAt, + ); err != nil { + return nil, fmt.Errorf("scan outlook discussion row: %w", err) + } + + out = append(out, mapOutlookDiscussionRow(row)) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate outlook discussion rows: %w", err) + } + return out, nil +} diff --git a/internal/adapters/outbound/postgres/outlooks_read_test.go b/internal/adapters/outbound/postgres/outlooks_read_test.go new file mode 100644 index 0000000..461232f --- /dev/null +++ b/internal/adapters/outbound/postgres/outlooks_read_test.go @@ -0,0 +1,405 @@ +// outlooks_read_test.go validates outlook repository read flow. +// Layer: adapters/outbound/postgres outlook read tests. +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" +) + +const outlookTestDriverName = "weatherapi_outlook_read_test" + +func init() { + sql.Register(outlookTestDriverName, outlookTestDriver{}) +} + +func TestLatestConvectiveOutlookRunLoadsOutlooksAndDiscussions(t *testing.T) { + asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC) + issuedAt := asOf.Add(-1 * time.Hour) + discussionUpdated := asOf.Add(-30 * time.Minute) + repo, closeDB := openOutlookTestRepository(t, + outlookParentQuery([][]driver.Value{{ + "evt-outlook-run", + "stl", + "St. Louis", + float64(38.62), + float64(-90.2), + asOf, + issuedAt, + }}), + outlookRowsQuery([][]driver.Value{ + outlookReadRow(1, "day-1", 1, "categorical"), + outlookReadRow(2, "day-2", 2, "wind"), + }, nil), + outlookDiscussionsQuery([][]driver.Value{ + {int64(1), int64(1), "Day 1 headline", "Day 1 summary", "Day 1 discussion", discussionUpdated}, + {int64(2), int64(2), "Day 2 headline", nil, "Day 2 discussion", nil}, + }, nil), + ) + defer closeDB() + + run, err := repo.LatestConvectiveOutlookRun(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run == nil { + t.Fatal("expected outlook run") + } + if run.LocationID != "stl" || run.LocationName != "St. Louis" { + t.Fatalf("unexpected run metadata: %+v", run) + } + if len(run.Outlooks) != 2 { + t.Fatalf("expected 2 outlooks, got %d", len(run.Outlooks)) + } + if run.Outlooks[0].ID != "day-1" || run.Outlooks[1].ID != "day-2" { + t.Fatalf("expected outlook order from rows, got %+v", run.Outlooks) + } + if len(run.Discussions) != 2 { + t.Fatalf("expected 2 discussions, got %d", len(run.Discussions)) + } + if run.Discussions[0].Day != 1 || run.Discussions[0].Headline != "Day 1 headline" { + t.Fatalf("unexpected first discussion: %+v", run.Discussions[0]) + } + if run.Discussions[1].Day != 2 || run.Discussions[1].Summary != "" { + t.Fatalf("unexpected second discussion: %+v", run.Discussions[1]) + } + if run.Discussions[0].UpdatedAt == nil || run.Discussions[0].UpdatedAt.Location().String() != "UTC" { + t.Fatalf("expected discussion updatedAt UTC pointer, got %v", run.Discussions[0].UpdatedAt) + } + if run.Discussions[1].UpdatedAt != nil { + t.Fatalf("expected nil discussion updatedAt, got %v", run.Discussions[1].UpdatedAt) + } + assertOutlookTestQueriesConsumed(t) +} + +func TestLatestConvectiveOutlookRunMissingParentReturnsNil(t *testing.T) { + repo, closeDB := openOutlookTestRepository(t, outlookParentQuery(nil)) + defer closeDB() + + run, err := repo.LatestConvectiveOutlookRun(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if run != nil { + t.Fatalf("expected nil run, got %+v", run) + } + assertOutlookTestQueriesConsumed(t) +} + +func TestLatestConvectiveOutlookRunWrapsReadErrors(t *testing.T) { + asOf := time.Date(2026, 6, 11, 18, 0, 0, 0, time.UTC) + + tests := []struct { + name string + queries []scriptedOutlookQuery + want string + }{ + { + name: "parent query", + queries: []scriptedOutlookQuery{ + {name: "parent", query: queryLatestConvectiveOutlookRun, err: errors.New("parent unavailable")}, + }, + want: "query latest convective outlook run", + }, + { + name: "outlooks query", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + {name: "outlooks", query: queryOutlooksForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("outlooks unavailable")}, + }, + want: "query outlooks", + }, + { + name: "outlook scan", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery([][]driver.Value{ + {int64(1), "day-1"}, + }, nil), + }, + want: "scan outlook row", + }, + { + name: "outlook map", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery([][]driver.Value{ + outlookReadRowWithGeometry(1, "day-1", 1, "categorical", `{"type":"Point"`), + }, nil), + }, + want: "map outlook row", + }, + { + name: "outlook iteration", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery([][]driver.Value{ + outlookReadRow(1, "day-1", 1, "categorical"), + }, errors.New("outlook iteration failed")), + }, + want: "iterate outlook rows", + }, + { + name: "discussions query", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery(nil, nil), + {name: "discussions", query: queryOutlookDiscussionsForRun, args: []driver.Value{"evt-outlook-run"}, err: errors.New("discussions unavailable")}, + }, + want: "query outlook discussions", + }, + { + name: "discussion scan", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery(nil, nil), + outlookDiscussionsQuery([][]driver.Value{ + {int64(1), "bad day", nil, nil, nil, nil}, + }, nil), + }, + want: "scan outlook discussion row", + }, + { + name: "discussion iteration", + queries: []scriptedOutlookQuery{ + outlookParentQuery([][]driver.Value{{"evt-outlook-run", nil, nil, nil, nil, asOf, nil}}), + outlookRowsQuery(nil, nil), + outlookDiscussionsQuery([][]driver.Value{ + {int64(1), int64(1), nil, nil, nil, nil}, + }, errors.New("discussion iteration failed")), + }, + want: "iterate outlook discussion rows", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo, closeDB := openOutlookTestRepository(t, tt.queries...) + defer closeDB() + + _, err := repo.LatestConvectiveOutlookRun(context.Background()) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected error containing %q, got %v", tt.want, err) + } + }) + } +} + +func openOutlookTestRepository(t *testing.T, queries ...scriptedOutlookQuery) (*Repository, func()) { + t.Helper() + outlookTestScript.set(queries) + + db, err := sql.Open(outlookTestDriverName, "") + if err != nil { + t.Fatalf("open test db: %v", err) + } + db.SetMaxOpenConns(1) + + return NewRepository(db), func() { + _ = db.Close() + outlookTestScript.set(nil) + } +} + +func assertOutlookTestQueriesConsumed(t *testing.T) { + t.Helper() + if remaining := outlookTestScript.remaining(); remaining != 0 { + t.Fatalf("expected all scripted queries consumed, got %d remaining", remaining) + } +} + +func outlookParentQuery(rows [][]driver.Value) scriptedOutlookQuery { + return scriptedOutlookQuery{ + name: "parent", + query: queryLatestConvectiveOutlookRun, + columns: []string{"event_id", "location_id", "location_name", "latitude", "longitude", "as_of", "issued_at"}, + rows: rows, + } +} + +func outlookRowsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery { + return scriptedOutlookQuery{ + name: "outlooks", + query: queryOutlooksForRun, + args: []driver.Value{"evt-outlook-run"}, + columns: []string{"outlook_index", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "source_url", "image_url", "contains_location", "geometry_json"}, + rows: rows, + nextErr: nextErr, + } +} + +func outlookDiscussionsQuery(rows [][]driver.Value, nextErr error) scriptedOutlookQuery { + return scriptedOutlookQuery{ + name: "discussions", + query: queryOutlookDiscussionsForRun, + args: []driver.Value{"evt-outlook-run"}, + columns: []string{"discussion_index", "day", "headline", "summary", "discussion", "updated_at"}, + rows: rows, + nextErr: nextErr, + } +} + +func outlookReadRow(index int64, id string, day int64, outlookType string) []driver.Value { + return outlookReadRowWithGeometry(index, id, day, outlookType, `{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,38]]]}`) +} + +func outlookReadRowWithGeometry(index int64, id string, day int64, outlookType string, geometry string) []driver.Value { + validFrom := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) + validTo := validFrom.Add(6 * time.Hour) + return []driver.Value{ + index, + id, + "spc", + "convective", + day, + outlookType, + "SLGT", + "Slight Risk", + int64(5), + validFrom, + validTo, + validFrom.Add(-1 * time.Hour), + validTo, + "DIAL", + "https://example.test/source", + "https://example.test/image.png", + true, + geometry, + } +} + +type outlookTestDriver struct{} + +func (outlookTestDriver) Open(string) (driver.Conn, error) { + return outlookTestConn{}, nil +} + +type outlookTestConn struct{} + +func (outlookTestConn) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("prepare is not supported") +} + +func (outlookTestConn) Close() error { + return nil +} + +func (outlookTestConn) Begin() (driver.Tx, error) { + return nil, errors.New("transactions are not supported") +} + +func (outlookTestConn) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { + return outlookTestScript.next(query, args) +} + +type scriptedOutlookQuery struct { + name string + query string + args []driver.Value + columns []string + rows [][]driver.Value + err error + nextErr error +} + +type outlookTestScriptState struct { + mu sync.Mutex + queries []scriptedOutlookQuery +} + +var outlookTestScript outlookTestScriptState + +func (s *outlookTestScriptState) set(queries []scriptedOutlookQuery) { + s.mu.Lock() + defer s.mu.Unlock() + s.queries = append([]scriptedOutlookQuery(nil), queries...) +} + +func (s *outlookTestScriptState) remaining() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.queries) +} + +func (s *outlookTestScriptState) next(query string, args []driver.NamedValue) (driver.Rows, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if len(s.queries) == 0 { + return nil, fmt.Errorf("unexpected query: %s", compactSQL(query)) + } + next := s.queries[0] + s.queries = s.queries[1:] + + if compactSQL(query) != compactSQL(next.query) { + return nil, fmt.Errorf("expected %s query %q, got %q", next.name, compactSQL(next.query), compactSQL(query)) + } + if len(args) != len(next.args) { + return nil, fmt.Errorf("expected %s args %v, got %v", next.name, next.args, namedValues(args)) + } + for i, arg := range args { + if arg.Value != next.args[i] { + return nil, fmt.Errorf("expected %s arg %d to be %v, got %v", next.name, i, next.args[i], arg.Value) + } + } + if next.err != nil { + return nil, next.err + } + return &outlookTestRows{ + columns: append([]string(nil), next.columns...), + rows: append([][]driver.Value(nil), next.rows...), + nextErr: next.nextErr, + }, nil +} + +type outlookTestRows struct { + columns []string + rows [][]driver.Value + index int + nextErr error +} + +func (r *outlookTestRows) Columns() []string { + return r.columns +} + +func (r *outlookTestRows) Close() error { + return nil +} + +func (r *outlookTestRows) Next(dest []driver.Value) error { + if r.index >= len(r.rows) { + if r.nextErr != nil { + err := r.nextErr + r.nextErr = nil + return err + } + return io.EOF + } + copy(dest, r.rows[r.index]) + r.index++ + return nil +} + +func compactSQL(query string) string { + return strings.Join(strings.Fields(query), " ") +} + +func namedValues(args []driver.NamedValue) []driver.Value { + out := make([]driver.Value, len(args)) + for i := range args { + out[i] = args[i].Value + } + return out +} diff --git a/internal/adapters/outbound/postgres/outlooks_rows.go b/internal/adapters/outbound/postgres/outlooks_rows.go index 9110324..95e6f0b 100644 --- a/internal/adapters/outbound/postgres/outlooks_rows.go +++ b/internal/adapters/outbound/postgres/outlooks_rows.go @@ -37,3 +37,12 @@ type outlookRow struct { ContainsLocation bool GeometryJSON string } + +type outlookDiscussionRow struct { + DiscussionIndex int + Day int + Headline sql.NullString + Summary sql.NullString + Discussion sql.NullString + UpdatedAt sql.NullTime +}