package rest import ( "context" "database/sql" "encoding/json" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "github.com/explorer/backend/api/freshness" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) type fakeStatsRow struct { scan func(dest ...any) error } func (r fakeStatsRow) Scan(dest ...any) error { return r.scan(dest...) } func TestLoadExplorerStatsReturnsValues(t *testing.T) { rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Method string `json:"method"` } require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) w.Header().Set("Content-Type", "application/json") switch req.Method { case "eth_blockNumber": _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x2c"}`)) case "eth_getBlockByNumber": ts := time.Now().Add(-2 * time.Second).Unix() _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`)) default: http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest) } })) defer rpc.Close() t.Setenv("RPC_URL", rpc.URL) var call int queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { call++ return fakeStatsRow{ scan: func(dest ...any) error { switch call { case 1: *dest[0].(*int64) = 11 case 2: *dest[0].(*int64) = 22 case 3: *dest[0].(*int64) = 33 case 4: *dest[0].(*int64) = 44 case 5: *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 2000, Valid: true} case 6: *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 1.25, Valid: true} case 7: *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 37.5, Valid: true} case 8: *dest[0].(*sql.NullInt64) = sql.NullInt64{Int64: 18, Valid: true} case 9: *dest[0].(*int64) = 44 *dest[1].(*time.Time) = time.Now().Add(-2 * time.Second) case 10: *dest[0].(*string) = "0xabc" *dest[1].(*int64) = 40 *dest[2].(*time.Time) = time.Now().Add(-5 * time.Second) case 11: *dest[0].(*int64) = 40 *dest[1].(*time.Time) = time.Now().Add(-5 * time.Second) case 12: *dest[0].(*int64) = 42 *dest[1].(*time.Time) = time.Now().Add(-3 * time.Second) case 13: *dest[0].(*int64) = 128 *dest[1].(*int64) = 10 *dest[2].(*int64) = 22 default: t.Fatalf("unexpected query call %d", call) } return nil }, } } stats, err := loadExplorerStats(context.Background(), 138, queryRow) require.NoError(t, err) require.Equal(t, int64(11), stats.TotalBlocks) require.Equal(t, int64(22), stats.TotalTransactions) require.Equal(t, int64(33), stats.TotalAddresses) require.Equal(t, int64(44), stats.LatestBlock) require.NotNil(t, stats.AverageBlockTime) require.Equal(t, 2000.0, *stats.AverageBlockTime) require.NotNil(t, stats.GasPrices) require.NotNil(t, stats.GasPrices.Average) require.Equal(t, 1.25, *stats.GasPrices.Average) require.NotNil(t, stats.NetworkUtilizationPercentage) require.Equal(t, 37.5, *stats.NetworkUtilizationPercentage) require.NotNil(t, stats.TransactionsToday) require.Equal(t, int64(18), *stats.TransactionsToday) require.NotNil(t, stats.Freshness.ChainHead.BlockNumber) require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber) require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead) require.Equal(t, "active", stats.Diagnostics.ActivityState) require.Equal(t, int64(4), *stats.Diagnostics.TxLagBlocks) require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source)) require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics) require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric) } func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) { t.Setenv("RPC_URL", "") queryRow := func(_ context.Context, query string, _ ...any) pgx.Row { return fakeStatsRow{ scan: func(dest ...any) error { if strings.Contains(query, "COUNT(*) FROM transactions") { return errors.New("boom") } target, ok := dest[0].(*int64) require.True(t, ok) *target = 1 return nil }, } } _, err := loadExplorerStats(context.Background(), 138, queryRow) require.Error(t, err) require.Contains(t, err.Error(), "query total transactions") }