406 lines
11 KiB
Go
406 lines
11 KiB
Go
// 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
|
|
}
|