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