From 09429c94b8bf6fb2ffc3b8a0eb8425bc15b3c200 Mon Sep 17 00:00:00 2001 From: 84hero <84hero@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:42:59 +0800 Subject: [PATCH 1/4] ci: add redis service to test workflow --- .github/workflows/test.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c94ef5..82033c2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,16 @@ jobs: test: name: Run Tests runs-on: ubuntu-latest + services: + redis: + image: redis:alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Checkout code uses: actions/checkout@v4 From 21650f3cf132a5ca27547904bb112d43349ef03f Mon Sep 17 00:00:00 2001 From: 84hero <84hero@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:06:44 +0800 Subject: [PATCH 2/4] test: fix flaky TestMultiClient_Failover by allowing background sync errors --- pkg/rpc/client_test.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index 486a6e8..8a56765 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -18,12 +18,12 @@ func TestNodeScore(t *testing.T) { n := &Node{ config: NodeConfig{Priority: 10}, } - + // Initial score: 10 * 100 = 1000 assert.Equal(t, int64(1000), n.Score(0)) // Simulate latency (no errors recorded) - n.RecordMetric(time.Now().Add(-100*time.Millisecond), nil) + n.RecordMetric(time.Now().Add(-100*time.Millisecond), nil) // Latency update: (old=0) -> set to 100. // Score: 1000 - (100/10) = 990 assert.Equal(t, int64(990), n.Score(0)) @@ -58,8 +58,8 @@ func TestMultiClient_Failover(t *testing.T) { assert.NoError(t, err) assert.Equal(t, uint64(100), h) - // Check metrics: Node 1 should have errors - assert.Equal(t, uint64(1), node1.GetTotalErrors()) + // Check metrics: Node 1 should have at least 1 error (from background sync or manual call) + assert.GreaterOrEqual(t, node1.GetTotalErrors(), uint64(1)) } func TestNode_ScoreLag(t *testing.T) { @@ -77,7 +77,7 @@ func TestExecute_RetryLimit(t *testing.T) { mockEth := new(MockEthClient) // Fail 3 times. Also allow background sync calls. mockEth.On("BlockNumber", mock.Anything).Return(uint64(0), errors.New("fail")).Maybe() - + node := NewNodeWithClient(NodeConfig{URL: "node1", Priority: 10}, mockEth) mc, _ := NewClientWithNodes(ctx, []*Node{node}, 100) @@ -89,7 +89,7 @@ func TestExecute_ContextCanceled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) mockEth := new(MockEthClient) mockEth.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Maybe() - + node := NewNodeWithClient(NodeConfig{URL: "node1", Priority: 10}, mockEth) mc, _ := NewClientWithNodes(ctx, []*Node{node}, 100) @@ -102,7 +102,7 @@ func TestExecute_ContextCanceled(t *testing.T) { func TestProxyMethods(t *testing.T) { ctx := context.Background() mockEth := new(MockEthClient) - + // Expect background sync calls (immediate one) mockEth.On("BlockNumber", mock.Anything).Return(uint64(100), nil).Maybe() @@ -150,7 +150,7 @@ func TestProxyMethods(t *testing.T) { func TestNewClient_Errors(t *testing.T) { _, err := NewClient(context.Background(), []NodeConfig{}, 10) assert.Error(t, err) - + _, err = NewClientWithNodes(context.Background(), []*Node{}, 10) assert.Error(t, err) } @@ -170,4 +170,3 @@ func TestNodeGetters(t *testing.T) { assert.Equal(t, "http://test", n.URL()) assert.Equal(t, 5, n.Priority()) } - From 10d78639c919397ec9463fa0568b6492fdfa4daf Mon Sep 17 00:00:00 2001 From: 84hero <84hero@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:07:39 +0800 Subject: [PATCH 3/4] test: silence redis failure test noise and add integration test --- pkg/storage/store_test.go | 305 +++++++++++++++++--------------------- 1 file changed, 134 insertions(+), 171 deletions(-) diff --git a/pkg/storage/store_test.go b/pkg/storage/store_test.go index 24ee472..e279163 100644 --- a/pkg/storage/store_test.go +++ b/pkg/storage/store_test.go @@ -1,6 +1,7 @@ package storage import ( + "context" "database/sql" "regexp" "testing" @@ -26,7 +27,7 @@ func TestMemoryStore(t *testing.T) { h, err = s.LoadCursor("unknown") assert.NoError(t, err) assert.Equal(t, uint64(0), h) - + // Memory store Close is no-op assert.NoError(t, s.Close()) } @@ -97,180 +98,142 @@ func TestPostgresStore_SaveLoad(t *testing.T) { WillReturnError(assert.AnError) _, err = store.LoadCursor("task3") assert.Error(t, err) - - // 6. Test Close - - mock.ExpectClose() - - assert.NoError(t, store.Close()) - - } - - - - // Note: NewPostgresStore involves real sql.Open, making it difficult to fully mock the driver layer. - - - - // However, we can test passing an invalid URL. - - - - func TestNewPostgresStore_InvalidURL(t *testing.T) { - - - - // This is a malformed connection string - - - - _, err := NewPostgresStore("postgres://invalid-url?param=^^", "prefix") - - - - assert.Error(t, err) - - - - } - - - - - - - - func TestNewPostgresStore_Mock(t *testing.T) { - - - - // We can't easily mock the 'sql.Open' call inside NewPostgresStore because it's a package level function, - - - - // but the code is already mostly covered by the Save/Load tests which use a manually constructed PostgresStore. - - - - } - - - - - - - - // --- Redis Store Tests --- - - - - func TestRedisStore_SaveLoad(t *testing.T) { - - db, mock := redismock.NewClientMock() - - - - store := &RedisStore{ - - client: db, - - prefix: "scan:", - - } - - - - // 1. Test Save Success - - mock.ExpectSet("scan:task1", uint64(100), time.Duration(0)).SetVal("OK") - - err := store.SaveCursor("task1", 100) - - assert.NoError(t, err) - - - - // 2. Test Save Error - - mock.ExpectSet("scan:task1", uint64(100), time.Duration(0)).SetErr(assert.AnError) - - err = store.SaveCursor("task1", 100) - - assert.Error(t, err) - - - - // 3. Test Load Success - - mock.ExpectGet("scan:task1").SetVal("500") - - h, err := store.LoadCursor("task1") - - assert.NoError(t, err) - - assert.Equal(t, uint64(500), h) - - - - // 4. Test Load Not Found (Redis Nil) - - mock.ExpectGet("scan:task2").SetErr(redis.Nil) - - h, err = store.LoadCursor("task2") - - assert.NoError(t, err) - - assert.Equal(t, uint64(0), h) - - - - // 5. Test Load Error - - mock.ExpectGet("scan:task3").SetErr(assert.AnError) - - _, err = store.LoadCursor("task3") - - assert.Error(t, err) - - - - // 6. Test Close - - // redismock doesn't fully support ExpectClose in older versions or some implementations, - - // but RedisStore.Close just calls client.Close. - - // We can't easily mock Close error with redismock without custom wrapper, - - // but calling it ensures coverage hits the line. - - assert.NoError(t, store.Close()) - + + // 6. Test Close + + mock.ExpectClose() + + assert.NoError(t, store.Close()) + +} + +// Note: NewPostgresStore involves real sql.Open, making it difficult to fully mock the driver layer. + +// However, we can test passing an invalid URL. + +func TestNewPostgresStore_InvalidURL(t *testing.T) { + + // This is a malformed connection string + + _, err := NewPostgresStore("postgres://invalid-url?param=^^", "prefix") + + assert.Error(t, err) + +} + +func TestNewPostgresStore_Mock(t *testing.T) { + + // We can't easily mock the 'sql.Open' call inside NewPostgresStore because it's a package level function, + + // but the code is already mostly covered by the Save/Load tests which use a manually constructed PostgresStore. + +} + +// --- Redis Store Tests --- + +func TestRedisStore_SaveLoad(t *testing.T) { + + db, mock := redismock.NewClientMock() + + store := &RedisStore{ + + client: db, + + prefix: "scan:", } - - - - func TestNewRedisStore_Mock(t *testing.T) { + + // 1. Test Save Success + + mock.ExpectSet("scan:task1", uint64(100), time.Duration(0)).SetVal("OK") + + err := store.SaveCursor("task1", 100) + + assert.NoError(t, err) + + // 2. Test Save Error + + mock.ExpectSet("scan:task1", uint64(100), time.Duration(0)).SetErr(assert.AnError) + + err = store.SaveCursor("task1", 100) + + assert.Error(t, err) + + // 3. Test Load Success + + mock.ExpectGet("scan:task1").SetVal("500") + + h, err := store.LoadCursor("task1") + + assert.NoError(t, err) + + assert.Equal(t, uint64(500), h) + + // 4. Test Load Not Found (Redis Nil) + + mock.ExpectGet("scan:task2").SetErr(redis.Nil) + + h, err = store.LoadCursor("task2") + + assert.NoError(t, err) + + assert.Equal(t, uint64(0), h) + + // 5. Test Load Error + + mock.ExpectGet("scan:task3").SetErr(assert.AnError) + + _, err = store.LoadCursor("task3") + + assert.Error(t, err) + + // 6. Test Close + + // redismock doesn't fully support ExpectClose in older versions or some implementations, + + // but RedisStore.Close just calls client.Close. + + // We can't easily mock Close error with redismock without custom wrapper, + + // but calling it ensures coverage hits the line. + + assert.NoError(t, store.Close()) + +} + +func TestNewRedisStore_Mock(t *testing.T) { // redismock doesn't directly mock NewRedisStore because it calls redis.NewClient inside. // But we can verify our Load/Save tests already cover the logic. } // TestNewRedisStore_PingFail attempts to test connection failure logic. - - // Note that NewRedisStore performs an actual Ping, so we need an unreachable address. - - func TestNewRedisStore_PingFail(t *testing.T) { - - // Use an unreachable address. - - // We rely on Ping failing. - - // In CI environments, localhost:65432 is typically unreachable. - - _, err := NewRedisStore("localhost:65432", "", 0, "p_") - - assert.Error(t, err) - +func TestNewRedisStore_PingFail(t *testing.T) { + // Use an unreachable address with no retries to avoid noise and 2s delay + client := redis.NewClient(&redis.Options{ + Addr: "localhost:65432", + MaxRetries: -1, // Disable retries + }) + defer client.Close() + + err := client.Ping(context.Background()).Err() + assert.Error(t, err) + + _, err = NewRedisStore("localhost:65432", "", 0, "p_") + assert.Error(t, err) +} + +func TestRedisStore_Integration(t *testing.T) { + // If Redis is running in CI or local (default port) + s, err := NewRedisStore("localhost:6379", "", 0, "integration:") + if err != nil { + t.Skip("Redis not available on localhost:6379, skipping integration test") + return } - - \ No newline at end of file + defer s.Close() + + err = s.SaveCursor("chain1", 12345) + assert.NoError(t, err) + + val, err := s.LoadCursor("chain1") + assert.NoError(t, err) + assert.Equal(t, uint64(12345), val) +} From a112b8b0ebfc409a81445de4587d61b112f14111 Mon Sep 17 00:00:00 2001 From: 84hero <84hero@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:11:20 +0800 Subject: [PATCH 4/4] test: fix noisy connection error in TestWebhookOutput_Async --- pkg/sink/sink_test.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pkg/sink/sink_test.go b/pkg/sink/sink_test.go index 8e60c98..cbaab8e 100644 --- a/pkg/sink/sink_test.go +++ b/pkg/sink/sink_test.go @@ -191,7 +191,7 @@ func TestPostgresOutput_Send(t *testing.T) { logs := []DecodedLog{ { - Log: types.Log{BlockNumber: 100, TxHash: common.HexToHash("0xabc"), Index: 1}, + Log: types.Log{BlockNumber: 100, TxHash: common.HexToHash("0xabc"), Index: 1}, EventName: "Transfer", }, } @@ -207,12 +207,29 @@ func TestPostgresOutput_Send(t *testing.T) { } func TestWebhookOutput_Async(t *testing.T) { - wo := NewWebhookOutput("http://localhost", "secret", 1, "1s", "10s", true, 10, 1) + called := make(chan bool, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called <- true + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + wo := NewWebhookOutput(ts.URL, "secret", 1, "1s", "10s", true, 10, 1) logs := []DecodedLog{{Log: types.Log{Index: 1}}} + start := time.Now() err := wo.Send(context.Background(), logs) assert.NoError(t, err) + // Must return immediately in async mode assert.Less(t, time.Since(start), 100*time.Millisecond) + + // Wait for background worker to deliver + select { + case <-called: + case <-time.After(1 * time.Second): + t.Fatal("Async webhook was never delivered") + } + err = wo.Close() assert.NoError(t, err) } @@ -223,4 +240,4 @@ func TestConsoleOutput(t *testing.T) { err := c.Send(context.Background(), []DecodedLog{{Log: types.Log{Index: 1, Topics: []common.Hash{}}}}) assert.NoError(t, err) assert.NoError(t, c.Close()) -} \ No newline at end of file +}