diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index b94c317d6..06d99db59 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -2103,6 +2103,9 @@ func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { // approval_token_hash is NULL on rows migration 000100 found with no // raw token (every canceled row among them). var approvalTokenHash sql.NullString + // error is nullable (migration 000001); one NULL row must not fail the + // whole list scan (issue #1465). exec.Error stays "" for those. + var errText sql.NullString err := rows.Scan( &planID, @@ -2116,7 +2119,7 @@ func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { &exec.TotalUpfrontCost, &exec.EstimatedSavings, &completedAt, - &exec.Error, + &errText, &expiresAt, &exec.CloudAccountID, &exec.Source, @@ -2146,6 +2149,7 @@ func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { exec.IdempotencyKey = idempotencyKey.String } exec.ApprovalToken = approvalTokenHash.String + exec.Error = errText.String // Unmarshal recommendations if err := json.Unmarshal(recommendationsJSON, &exec.Recommendations); err != nil { diff --git a/internal/config/store_postgres_execution_null_error_integration_test.go b/internal/config/store_postgres_execution_null_error_integration_test.go new file mode 100644 index 000000000..d8409c84c --- /dev/null +++ b/internal/config/store_postgres_execution_null_error_integration_test.go @@ -0,0 +1,71 @@ +//go:build integration +// +build integration + +package config + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestIntegration_ExecutionReaders_NullErrorColumn is the issue #1465 +// regression. purchase_executions.error is nullable, and one row with a NULL +// there used to fail the scan for the whole result set, hiding every +// actionable execution from the history page and approval queue. +func TestIntegration_ExecutionReaders_NullErrorColumn(t *testing.T) { + ctx := context.Background() + store := setupRampStepStore(ctx, t) + + base := time.Now().Add(-time.Hour) + saveClean := func(offset int, status string) string { + exec := &PurchaseExecution{ + ExecutionID: uuid.New().String(), + IdempotencyKey: uuid.New().String(), + Status: status, + ScheduledDate: base.Add(time.Duration(offset) * time.Minute), + } + require.NoError(t, store.SavePurchaseExecution(ctx, exec)) + return exec.ExecutionID + } + other := saveClean(1, "pending") + nullErr := saveClean(2, "pending") + last := saveClean(3, "notified") + _, err := store.db.Exec(ctx, `UPDATE purchase_executions SET error = NULL WHERE execution_id = $1`, nullErr) + require.NoError(t, err) + + wantIDs := []string{last, nullErr, other} + assertAll := func(t *testing.T, got []PurchaseExecution) { + t.Helper() + ids := make([]string, len(got)) + for i := range got { + ids[i] = got[i].ExecutionID + assert.Empty(t, got[i].Error, "NULL error scans as empty string") + } + assert.Equal(t, wantIDs, ids) + } + + statuses := []string{"pending", "notified"} + byStatuses, err := store.GetExecutionsByStatuses(ctx, statuses, 100) + require.NoError(t, err) + assertAll(t, byStatuses) + + page, err := store.ListHistoryExecutionsPage(ctx, HistoryExecutionsPageQuery{Statuses: statuses, Limit: 100}) + require.NoError(t, err) + assertAll(t, page) + + pending, err := store.GetPendingExecutions(ctx) + require.NoError(t, err) + require.Len(t, pending, 3) + for i := range pending { + assert.Empty(t, pending[i].Error) + } + + single, err := store.GetExecutionByID(ctx, nullErr) + require.NoError(t, err) + assert.Empty(t, single.Error) +}