Skip to content

Commit 3fda517

Browse files
qiujian16claude
andcommitted
🧪 Add comprehensive unit tests for gRPC heartbeat functionality
This commit adds extensive unit test coverage for the heartbeat functionality introduced in the gRPC cloudevents implementation. The tests ensure proper operation of heartbeat generation, health checking, and error handling. Test coverage includes: - Heartbeater component: periodic heartbeat generation, context cancellation, channel overflow handling, and proper cleanup - HealthChecker component: timeout detection, timer reset functionality, error channel handling, and graceful degradation - Protocol integration: heartbeat filtering, health check timeouts, validation error handling, and end-to-end scenarios - Broker integration: heartbeat/event separation, send error recovery, subscription management, and performance under load All tests validate the robustness of the heartbeat system and ensure graceful handling of network failures and edge cases. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jian Qiu <jqiu@redhat.com>
1 parent ef88575 commit 3fda517

10 files changed

Lines changed: 1721 additions & 84 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
package protocol
2+
3+
import (
4+
"context"
5+
"net"
6+
"sync/atomic"
7+
"testing"
8+
"time"
9+
10+
"github.com/google/uuid"
11+
"google.golang.org/grpc"
12+
"google.golang.org/grpc/credentials/insecure"
13+
"google.golang.org/grpc/test/bufconn"
14+
"google.golang.org/protobuf/types/known/emptypb"
15+
"k8s.io/utils/ptr"
16+
17+
pbv1 "open-cluster-management.io/sdk-go/pkg/cloudevents/generic/options/grpc/protobuf/v1"
18+
"open-cluster-management.io/sdk-go/pkg/cloudevents/generic/types"
19+
)
20+
21+
// mockCloudEventServiceWithHeartbeat provides more control over heartbeat behavior
22+
type mockCloudEventServiceWithHeartbeat struct {
23+
pbv1.UnimplementedCloudEventServiceServer
24+
heartbeatInterval time.Duration
25+
stopAfterEvents int
26+
eventsSent int
27+
}
28+
29+
func (m *mockCloudEventServiceWithHeartbeat) Publish(ctx context.Context, req *pbv1.PublishRequest) (*emptypb.Empty, error) {
30+
return &emptypb.Empty{}, nil
31+
}
32+
33+
func (m *mockCloudEventServiceWithHeartbeat) Subscribe(req *pbv1.SubscriptionRequest, stream pbv1.CloudEventService_SubscribeServer) error {
34+
if m.heartbeatInterval > 0 {
35+
ticker := time.NewTicker(m.heartbeatInterval)
36+
defer ticker.Stop()
37+
38+
for {
39+
select {
40+
case <-ticker.C:
41+
if m.stopAfterEvents > 0 && m.eventsSent >= m.stopAfterEvents {
42+
return nil
43+
}
44+
45+
heartbeat := &pbv1.CloudEvent{
46+
SpecVersion: "1.0",
47+
Id: uuid.New().String(),
48+
Type: types.HeartbeatCloudEventsType,
49+
}
50+
51+
if err := stream.Send(heartbeat); err != nil {
52+
return err
53+
}
54+
m.eventsSent++
55+
56+
case <-stream.Context().Done():
57+
return stream.Context().Err()
58+
}
59+
}
60+
}
61+
62+
// Keep the stream open indefinitely if no heartbeat interval
63+
<-stream.Context().Done()
64+
return stream.Context().Err()
65+
}
66+
67+
func setupMockServerWithHeartbeat(t *testing.T, heartbeatInterval time.Duration, stopAfterEvents int) (*grpc.ClientConn, func()) {
68+
lis := bufconn.Listen(bufSize)
69+
s := grpc.NewServer()
70+
71+
service := &mockCloudEventServiceWithHeartbeat{
72+
heartbeatInterval: heartbeatInterval,
73+
stopAfterEvents: stopAfterEvents,
74+
}
75+
pbv1.RegisterCloudEventServiceServer(s, service)
76+
77+
go func() {
78+
if err := s.Serve(lis); err != nil {
79+
t.Logf("Server exited with error: %v", err)
80+
}
81+
}()
82+
83+
time.Sleep(50 * time.Millisecond)
84+
85+
bufDialer := func(context.Context, string) (net.Conn, error) {
86+
return lis.Dial()
87+
}
88+
89+
conn, err := grpc.NewClient("passthrough:///bufnet",
90+
grpc.WithTransportCredentials(insecure.NewCredentials()),
91+
grpc.WithContextDialer(bufDialer))
92+
93+
if err != nil {
94+
t.Fatalf("Failed to create client: %v", err)
95+
}
96+
97+
cleanup := func() {
98+
conn.Close()
99+
s.Stop()
100+
lis.Close()
101+
}
102+
103+
return conn, cleanup
104+
}
105+
106+
func TestProtocol_HeartbeatIntegration(t *testing.T) {
107+
tests := []struct {
108+
name string
109+
heartbeatInterval time.Duration
110+
serverHealthinessTimeout *time.Duration
111+
expectHealthCheckError bool
112+
testDuration time.Duration
113+
stopServerAfterEvents int
114+
}{
115+
{
116+
name: "successful heartbeat with health check enabled",
117+
heartbeatInterval: 100 * time.Millisecond,
118+
serverHealthinessTimeout: ptr.To(500 * time.Millisecond),
119+
expectHealthCheckError: false,
120+
testDuration: 800 * time.Millisecond,
121+
stopServerAfterEvents: 0,
122+
},
123+
{
124+
name: "health check timeout when no heartbeats",
125+
heartbeatInterval: 0, // No heartbeats
126+
serverHealthinessTimeout: ptr.To(200 * time.Millisecond),
127+
expectHealthCheckError: true,
128+
testDuration: 500 * time.Millisecond,
129+
stopServerAfterEvents: 0,
130+
},
131+
{
132+
name: "health check disabled",
133+
heartbeatInterval: 0, // No heartbeats
134+
serverHealthinessTimeout: nil, // Disabled
135+
expectHealthCheckError: false,
136+
testDuration: 300 * time.Millisecond,
137+
stopServerAfterEvents: 0,
138+
},
139+
{
140+
name: "heartbeat stops mid-stream",
141+
heartbeatInterval: 50 * time.Millisecond,
142+
serverHealthinessTimeout: ptr.To(200 * time.Millisecond),
143+
expectHealthCheckError: true,
144+
testDuration: 600 * time.Millisecond,
145+
stopServerAfterEvents: 3, // Stop after 3 heartbeats
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
conn, cleanup := setupMockServerWithHeartbeat(t, tt.heartbeatInterval, tt.stopServerAfterEvents)
152+
defer cleanup()
153+
154+
reconnectErrorChan := make(chan error, 1)
155+
p, err := NewProtocol(
156+
conn,
157+
WithSubscribeOption(&SubscribeOption{
158+
Source: "test",
159+
ClusterName: "test-cluster",
160+
DataType: "io.open-cluster-management.test",
161+
}),
162+
WithReconnectErrorChan(reconnectErrorChan),
163+
WithServerHealthinessTimeout(tt.serverHealthinessTimeout),
164+
)
165+
if err != nil {
166+
t.Fatal(err)
167+
}
168+
169+
ctx, cancel := context.WithTimeout(context.Background(), tt.testDuration)
170+
defer cancel()
171+
172+
go func() {
173+
if err := p.OpenInbound(ctx); err != nil {
174+
select {
175+
case p.reconnectErrorChan <- err:
176+
default:
177+
}
178+
}
179+
}()
180+
181+
if tt.expectHealthCheckError {
182+
select {
183+
case err := <-reconnectErrorChan:
184+
if err == nil {
185+
t.Error("Expected health check error, but got nil")
186+
}
187+
case <-ctx.Done():
188+
t.Error("Expected health check error before context timeout")
189+
}
190+
} else {
191+
select {
192+
case err := <-reconnectErrorChan:
193+
t.Errorf("Unexpected error from health check: %v", err)
194+
case <-ctx.Done():
195+
// Expected - no error
196+
}
197+
}
198+
})
199+
}
200+
}
201+
202+
func TestProtocol_StartEventsReceiver_HeartbeatFiltering(t *testing.T) {
203+
conn, cleanup := setupMockServerWithHeartbeat(t, 50*time.Millisecond, 5)
204+
defer cleanup()
205+
206+
reconnectErrorChan := make(chan error, 1)
207+
p, err := NewProtocol(
208+
conn,
209+
WithSubscribeOption(&SubscribeOption{
210+
Source: "test",
211+
ClusterName: "test-cluster",
212+
DataType: "io.open-cluster-management.test",
213+
}),
214+
WithReconnectErrorChan(reconnectErrorChan),
215+
WithServerHealthinessTimeout(ptr.To(1*time.Second)),
216+
)
217+
if err != nil {
218+
t.Fatal(err)
219+
}
220+
221+
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
222+
defer cancel()
223+
224+
var receivedEvents atomic.Int32
225+
go func() {
226+
for {
227+
_, err := p.Receive(ctx)
228+
if err != nil {
229+
return
230+
}
231+
receivedEvents.Add(1)
232+
}
233+
}()
234+
235+
go func() {
236+
if err := p.OpenInbound(ctx); err != nil {
237+
select {
238+
case p.reconnectErrorChan <- err:
239+
default:
240+
}
241+
}
242+
}()
243+
244+
<-ctx.Done()
245+
246+
// Should receive 0 events since only heartbeats are sent (which are filtered out)
247+
if receivedEvents.Load() > 0 {
248+
t.Errorf("Expected 0 events, got %d (heartbeats should be filtered out)", receivedEvents.Load())
249+
}
250+
}
251+
252+
func TestProtocol_OpenInbound_ValidationErrors(t *testing.T) {
253+
tests := []struct {
254+
name string
255+
subscribeOption *SubscribeOption
256+
expectError bool
257+
expectedErrMsg string
258+
}{
259+
{
260+
name: "nil subscribe option",
261+
subscribeOption: nil,
262+
expectError: true,
263+
expectedErrMsg: "the subscribe option must not be nil",
264+
},
265+
{
266+
name: "empty source and cluster name",
267+
subscribeOption: &SubscribeOption{
268+
Source: "",
269+
ClusterName: "",
270+
DataType: "io.open-cluster-management.test",
271+
},
272+
expectError: true,
273+
expectedErrMsg: "the source and cluster name of subscribe option cannot both be empty",
274+
},
275+
{
276+
name: "valid source only",
277+
subscribeOption: &SubscribeOption{
278+
Source: "test-source",
279+
ClusterName: "",
280+
DataType: "io.open-cluster-management.test",
281+
},
282+
expectError: false,
283+
},
284+
{
285+
name: "valid cluster name only",
286+
subscribeOption: &SubscribeOption{
287+
Source: "",
288+
ClusterName: "test-cluster",
289+
DataType: "io.open-cluster-management.test",
290+
},
291+
expectError: false,
292+
},
293+
}
294+
295+
for _, tt := range tests {
296+
t.Run(tt.name, func(t *testing.T) {
297+
// Create a fresh connection for each test case to avoid interference
298+
conn, cleanup := setupMockServerWithHeartbeat(t, 0, 0) // No heartbeat for validation tests
299+
defer cleanup()
300+
301+
reconnectErrorChan := make(chan error, 1)
302+
p, err := NewProtocol(
303+
conn,
304+
WithSubscribeOption(tt.subscribeOption),
305+
WithReconnectErrorChan(reconnectErrorChan),
306+
)
307+
308+
// Check if validation error occurred during protocol creation
309+
if err != nil {
310+
if tt.expectError && err.Error() == tt.expectedErrMsg {
311+
return // Test passed - expected error occurred during creation
312+
}
313+
t.Fatal(err)
314+
}
315+
316+
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
317+
defer cancel()
318+
319+
err = p.OpenInbound(ctx)
320+
321+
if tt.expectError {
322+
if err == nil {
323+
t.Error("Expected error, but got nil")
324+
} else if err.Error() != tt.expectedErrMsg {
325+
t.Errorf("Expected error message '%s', got '%s'", tt.expectedErrMsg, err.Error())
326+
}
327+
} else {
328+
if err != nil && err != context.DeadlineExceeded {
329+
t.Errorf("Unexpected error: %v", err)
330+
}
331+
}
332+
})
333+
}
334+
}

0 commit comments

Comments
 (0)