Skip to content

Commit 191fc9b

Browse files
zhujian7claude
andcommitted
Add tests for pkg/watcher
Tests cover: - hashConfigMapData: nil/empty, single/multi-key sorted output, order independence, collision resistance, different values - NewConfigMapWatcher: field and initialHash initialization - Start AddFunc: no initData sets baseline without trigger, matching initData no-ops, differing initData triggers - Start UpdateFunc: same data no-ops, changed data triggers - Start DeleteFunc: always triggers - Wrong CM name is ignored - cancelFunc is called when no onChangeFunc is set - Pre-cancelled context returns error Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: zhujian <jiazhu@redhat.com>
1 parent 0073a99 commit 191fc9b

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

pkg/watcher/watcher_test.go

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
1+
package watcher
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
corev1 "k8s.io/api/core/v1"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/client-go/kubernetes/fake"
11+
)
12+
13+
const (
14+
testNamespace = "test-ns"
15+
testConfigMapName = "test-cm"
16+
)
17+
18+
func newCM(data map[string]string) *corev1.ConfigMap {
19+
return &corev1.ConfigMap{
20+
ObjectMeta: metav1.ObjectMeta{
21+
Name: testConfigMapName,
22+
Namespace: testNamespace,
23+
},
24+
Data: data,
25+
}
26+
}
27+
28+
// --- hashConfigMapData ---
29+
30+
func TestHashConfigMapData(t *testing.T) {
31+
tests := []struct {
32+
name string
33+
data map[string]string
34+
wantHash string
35+
wantSame string // another data map that must produce the same hash (optional)
36+
}{
37+
{
38+
name: "nil map returns empty string",
39+
data: nil,
40+
wantHash: "",
41+
},
42+
{
43+
name: "empty map returns empty string",
44+
data: map[string]string{},
45+
wantHash: "",
46+
},
47+
{
48+
name: "single entry",
49+
data: map[string]string{"key": "value"},
50+
wantHash: `"key"="value"`,
51+
},
52+
{
53+
name: "multiple entries are sorted by key",
54+
data: map[string]string{"b": "2", "a": "1"},
55+
wantHash: `"a"="1"|"b"="2"`,
56+
},
57+
}
58+
for _, tt := range tests {
59+
t.Run(tt.name, func(t *testing.T) {
60+
got := hashConfigMapData(tt.data)
61+
if got != tt.wantHash {
62+
t.Errorf("hashConfigMapData() = %q, want %q", got, tt.wantHash)
63+
}
64+
})
65+
}
66+
}
67+
68+
func TestHashConfigMapData_OrderIndependent(t *testing.T) {
69+
// Inserting keys in different iteration orders must yield the same hash.
70+
a := hashConfigMapData(map[string]string{"x": "1", "y": "2", "z": "3"})
71+
b := hashConfigMapData(map[string]string{"z": "3", "x": "1", "y": "2"})
72+
if a != b {
73+
t.Errorf("hash not order-independent: %q vs %q", a, b)
74+
}
75+
}
76+
77+
func TestHashConfigMapData_CollisionResistance(t *testing.T) {
78+
// A value that contains separators must not collide with a split across two keys.
79+
h1 := hashConfigMapData(map[string]string{"a": "b|c=d"})
80+
h2 := hashConfigMapData(map[string]string{"a": "b", "c": "d"})
81+
if h1 == h2 {
82+
t.Errorf("hash collision detected: %q == %q", h1, h2)
83+
}
84+
}
85+
86+
func TestHashConfigMapData_DifferentValues(t *testing.T) {
87+
h1 := hashConfigMapData(map[string]string{"k": "v1"})
88+
h2 := hashConfigMapData(map[string]string{"k": "v2"})
89+
if h1 == h2 {
90+
t.Errorf("different values produced the same hash")
91+
}
92+
}
93+
94+
// --- NewConfigMapWatcher ---
95+
96+
func TestNewConfigMapWatcher(t *testing.T) {
97+
client := fake.NewClientset()
98+
initData := map[string]string{"minTLSVersion": "VersionTLS12"}
99+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, initData)
100+
101+
if w.namespace != testNamespace {
102+
t.Errorf("namespace = %q, want %q", w.namespace, testNamespace)
103+
}
104+
if w.configMapName != testConfigMapName {
105+
t.Errorf("configMapName = %q, want %q", w.configMapName, testConfigMapName)
106+
}
107+
want := hashConfigMapData(initData)
108+
if w.initialHash != want {
109+
t.Errorf("initialHash = %q, want %q", w.initialHash, want)
110+
}
111+
}
112+
113+
func TestNewConfigMapWatcher_NilInitData(t *testing.T) {
114+
client := fake.NewClientset()
115+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, nil)
116+
if w.initialHash != "" {
117+
t.Errorf("expected empty initialHash for nil initData, got %q", w.initialHash)
118+
}
119+
}
120+
121+
// --- Start: AddFunc ---
122+
123+
func TestStart_AddFunc_NoInitData_SetsBaseline(t *testing.T) {
124+
// When there is no initData (hash=""), the first Add should set the baseline
125+
// and NOT trigger a restart.
126+
cm := newCM(map[string]string{"k": "v"})
127+
client := fake.NewClientset(cm)
128+
129+
triggered := make(chan struct{}, 1)
130+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, nil)
131+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
132+
133+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
134+
defer cancel()
135+
136+
if err := w.Start(ctx); err != nil {
137+
t.Fatalf("Start returned error: %v", err)
138+
}
139+
140+
select {
141+
case <-triggered:
142+
t.Error("onChangeFunc triggered unexpectedly when no initData was provided")
143+
case <-time.After(200 * time.Millisecond):
144+
// expected: no trigger
145+
}
146+
}
147+
148+
func TestStart_AddFunc_MatchingInitData_NoTrigger(t *testing.T) {
149+
// When initData matches the CM content, Add should NOT trigger a restart.
150+
data := map[string]string{"minTLSVersion": "VersionTLS12"}
151+
cm := newCM(data)
152+
client := fake.NewClientset(cm)
153+
154+
triggered := make(chan struct{}, 1)
155+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, data)
156+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
157+
158+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
159+
defer cancel()
160+
161+
if err := w.Start(ctx); err != nil {
162+
t.Fatalf("Start returned error: %v", err)
163+
}
164+
165+
select {
166+
case <-triggered:
167+
t.Error("onChangeFunc triggered unexpectedly when initData matched CM")
168+
case <-time.After(200 * time.Millisecond):
169+
// expected: no trigger
170+
}
171+
}
172+
173+
func TestStart_AddFunc_DifferingInitData_Triggers(t *testing.T) {
174+
// When initData differs from the CM content, Add should trigger a restart.
175+
cmData := map[string]string{"minTLSVersion": "VersionTLS13"}
176+
initData := map[string]string{"minTLSVersion": "VersionTLS12"}
177+
cm := newCM(cmData)
178+
client := fake.NewClientset(cm)
179+
180+
triggered := make(chan struct{}, 1)
181+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, initData)
182+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
183+
184+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
185+
defer cancel()
186+
187+
if err := w.Start(ctx); err != nil {
188+
t.Fatalf("Start returned error: %v", err)
189+
}
190+
191+
select {
192+
case <-triggered:
193+
// expected
194+
case <-ctx.Done():
195+
t.Error("timed out waiting for onChangeFunc to be triggered")
196+
}
197+
}
198+
199+
// --- Start: UpdateFunc ---
200+
201+
func TestStart_UpdateFunc_SameData_NoTrigger(t *testing.T) {
202+
data := map[string]string{"k": "v"}
203+
cm := newCM(data)
204+
client := fake.NewClientset(cm)
205+
206+
triggered := make(chan struct{}, 1)
207+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, data)
208+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
209+
210+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
211+
defer cancel()
212+
213+
if err := w.Start(ctx); err != nil {
214+
t.Fatalf("Start returned error: %v", err)
215+
}
216+
217+
// Update with identical data.
218+
_, err := client.CoreV1().ConfigMaps(testNamespace).Update(ctx, newCM(data), metav1.UpdateOptions{})
219+
if err != nil {
220+
t.Fatalf("Update failed: %v", err)
221+
}
222+
223+
select {
224+
case <-triggered:
225+
t.Error("onChangeFunc triggered unexpectedly on no-op update")
226+
case <-time.After(200 * time.Millisecond):
227+
// expected
228+
}
229+
}
230+
231+
func TestStart_UpdateFunc_ChangedData_Triggers(t *testing.T) {
232+
original := map[string]string{"minTLSVersion": "VersionTLS12"}
233+
cm := newCM(original)
234+
client := fake.NewClientset(cm)
235+
236+
triggered := make(chan struct{}, 1)
237+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, original)
238+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
239+
240+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
241+
defer cancel()
242+
243+
if err := w.Start(ctx); err != nil {
244+
t.Fatalf("Start returned error: %v", err)
245+
}
246+
247+
updated := newCM(map[string]string{"minTLSVersion": "VersionTLS13"})
248+
_, err := client.CoreV1().ConfigMaps(testNamespace).Update(ctx, updated, metav1.UpdateOptions{})
249+
if err != nil {
250+
t.Fatalf("Update failed: %v", err)
251+
}
252+
253+
select {
254+
case <-triggered:
255+
// expected
256+
case <-ctx.Done():
257+
t.Error("timed out waiting for onChangeFunc to be triggered on update")
258+
}
259+
}
260+
261+
// --- Start: DeleteFunc ---
262+
263+
func TestStart_DeleteFunc_Triggers(t *testing.T) {
264+
data := map[string]string{"minTLSVersion": "VersionTLS12"}
265+
cm := newCM(data)
266+
client := fake.NewClientset(cm)
267+
268+
triggered := make(chan struct{}, 1)
269+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, data)
270+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
271+
272+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
273+
defer cancel()
274+
275+
if err := w.Start(ctx); err != nil {
276+
t.Fatalf("Start returned error: %v", err)
277+
}
278+
279+
err := client.CoreV1().ConfigMaps(testNamespace).Delete(ctx, testConfigMapName, metav1.DeleteOptions{})
280+
if err != nil {
281+
t.Fatalf("Delete failed: %v", err)
282+
}
283+
284+
select {
285+
case <-triggered:
286+
// expected
287+
case <-ctx.Done():
288+
t.Error("timed out waiting for onChangeFunc to be triggered on delete")
289+
}
290+
}
291+
292+
// --- Start: name filtering ---
293+
294+
func TestStart_WrongConfigMapName_Ignored(t *testing.T) {
295+
// A CM with a different name must not trigger the callback.
296+
otherCM := &corev1.ConfigMap{
297+
ObjectMeta: metav1.ObjectMeta{Name: "other-cm", Namespace: testNamespace},
298+
Data: map[string]string{"k": "v"},
299+
}
300+
client := fake.NewClientset()
301+
302+
triggered := make(chan struct{}, 1)
303+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, nil)
304+
w.SetOnChangeFunc(func() { triggered <- struct{}{} })
305+
306+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
307+
defer cancel()
308+
309+
if err := w.Start(ctx); err != nil {
310+
t.Fatalf("Start returned error: %v", err)
311+
}
312+
313+
_, err := client.CoreV1().ConfigMaps(testNamespace).Create(ctx, otherCM, metav1.CreateOptions{})
314+
if err != nil {
315+
t.Fatalf("Create failed: %v", err)
316+
}
317+
318+
select {
319+
case <-triggered:
320+
t.Error("onChangeFunc triggered for a CM with a different name")
321+
case <-time.After(200 * time.Millisecond):
322+
// expected
323+
}
324+
}
325+
326+
// --- Start: cancelFunc fallback ---
327+
328+
func TestStart_CancelFuncCalledWhenNoOnChangeFunc(t *testing.T) {
329+
data := map[string]string{"k": "v"}
330+
cm := newCM(data)
331+
client := fake.NewClientset(cm)
332+
333+
cancelCalled := make(chan struct{}, 1)
334+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
335+
defer cancel()
336+
cancelWrapper := func() { cancelCalled <- struct{}{} }
337+
338+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, cancelWrapper, data)
339+
// Do NOT call SetOnChangeFunc — cancelFunc should be the fallback.
340+
341+
if err := w.Start(ctx); err != nil {
342+
t.Fatalf("Start returned error: %v", err)
343+
}
344+
345+
updated := newCM(map[string]string{"k": "new-value"})
346+
_, err := client.CoreV1().ConfigMaps(testNamespace).Update(ctx, updated, metav1.UpdateOptions{})
347+
if err != nil {
348+
t.Fatalf("Update failed: %v", err)
349+
}
350+
351+
select {
352+
case <-cancelCalled:
353+
// expected
354+
case <-ctx.Done():
355+
t.Error("timed out waiting for cancelFunc to be called")
356+
}
357+
}
358+
359+
// --- Start: context cancellation ---
360+
361+
func TestStart_ContextCancelledBeforeSync(t *testing.T) {
362+
client := fake.NewClientset()
363+
w := NewConfigMapWatcher(client, testNamespace, testConfigMapName, nil, nil)
364+
365+
ctx, cancel := context.WithCancel(context.Background())
366+
cancel() // cancel immediately
367+
368+
err := w.Start(ctx)
369+
if err == nil {
370+
t.Error("expected error when context is already cancelled, got nil")
371+
}
372+
}

0 commit comments

Comments
 (0)