Skip to content

Commit e3f623d

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 e3f623d

8 files changed

Lines changed: 1226 additions & 73 deletions

File tree

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

0 commit comments

Comments
 (0)