package library import ( "context" "errors" "io" "log/slog" "os" "testing" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) // triggerTestPool returns a pool against MINSTREL_TEST_DATABASE_URL or skips. func triggerTestPool(t *testing.T) *pgxpool.Pool { t.Helper() url := os.Getenv("MINSTREL_TEST_DATABASE_URL") if url == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } pool, err := pgxpool.New(context.Background(), url) if err != nil { t.Fatalf("connect: %v", err) } t.Cleanup(pool.Close) // Reset scan_runs so each test starts clean. if _, err := pool.Exec(context.Background(), `DELETE FROM scan_runs`); err != nil { t.Fatalf("reset: %v", err) } return pool } // quietLogger returns a logger that drops all output. func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func TestTryStartScan_NoInFlight_Starts(t *testing.T) { pool := triggerTestPool(t) started, existing, err := TryStartScan( context.Background(), pool, nil, nil, // scanner/enricher: nil OK because the goroutine that uses them is detached and we don't await it. quietLogger(), RunScanConfig{}, ) if err != nil { t.Fatalf("err = %v, want nil", err) } if !started { t.Errorf("started = false, want true") } if existing != nil { t.Errorf("existing = %v, want nil", existing) } // Give the detached goroutine a moment. time.Sleep(50 * time.Millisecond) } func TestTryStartScan_FreshInFlight_Skips(t *testing.T) { pool := triggerTestPool(t) // Seed a fresh in-flight row. var rowID pgtype.UUID if err := pool.QueryRow(context.Background(), `INSERT INTO scan_runs (started_at) VALUES (now()) RETURNING id`, ).Scan(&rowID); err != nil { t.Fatalf("seed: %v", err) } started, existing, err := TryStartScan( context.Background(), pool, nil, nil, quietLogger(), RunScanConfig{}, ) if err != nil { t.Fatalf("err = %v, want nil", err) } if started { t.Errorf("started = true, want false") } if existing == nil { t.Fatal("existing = nil, want non-nil") } if existing.ID != rowID { t.Errorf("existing.ID = %v, want %v", existing.ID, rowID) } } func TestTryStartScan_StaleInFlight_Reaps(t *testing.T) { pool := triggerTestPool(t) // Seed a stale in-flight row (started 2 hours ago). var staleID pgtype.UUID if err := pool.QueryRow(context.Background(), `INSERT INTO scan_runs (started_at) VALUES (now() - interval '2 hours') RETURNING id`, ).Scan(&staleID); err != nil { t.Fatalf("seed: %v", err) } started, existing, err := TryStartScan( context.Background(), pool, nil, nil, quietLogger(), RunScanConfig{}, ) if err != nil { t.Fatalf("err = %v, want nil", err) } if !started { t.Errorf("started = false, want true (stale row should reap and proceed)") } if existing != nil { t.Errorf("existing = %v, want nil", existing) } // Verify the stale row was reaped: finished_at non-NULL, error_message contains "reaped". var finishedAt pgtype.Timestamptz var errMsg *string if err := pool.QueryRow(context.Background(), `SELECT finished_at, error_message FROM scan_runs WHERE id = $1`, staleID, ).Scan(&finishedAt, &errMsg); err != nil { t.Fatalf("verify reap: %v", err) } if !finishedAt.Valid { t.Errorf("finished_at = NULL, want non-NULL after reap") } if errMsg == nil || *errMsg != "reaped (stale)" { t.Errorf("error_message = %v, want 'reaped (stale)'", errMsg) } time.Sleep(50 * time.Millisecond) } func TestTryStartScan_DBError_Propagates(t *testing.T) { url := os.Getenv("MINSTREL_TEST_DATABASE_URL") if url == "" { t.Skip("MINSTREL_TEST_DATABASE_URL not set") } pool, err := pgxpool.New(context.Background(), url) if err != nil { t.Fatalf("connect: %v", err) } pool.Close() // close immediately to force errors on subsequent queries started, existing, err := TryStartScan( context.Background(), pool, nil, nil, quietLogger(), RunScanConfig{}, ) if err == nil { t.Errorf("err = nil, want non-nil for closed pool") } if errors.Is(err, pgx.ErrNoRows) { t.Errorf("err is ErrNoRows; want a different DB error type") } if started { t.Errorf("started = true, want false on DB error") } if existing != nil { t.Errorf("existing = %v, want nil on DB error", existing) } }