Skip to content

Commit 961ff04

Browse files
committed
Add unit tests for pkg/utils/pager
1 parent d2ab642 commit 961ff04

File tree

4 files changed

+316
-2
lines changed

4 files changed

+316
-2
lines changed

pkg/utils/pager/pager.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,13 @@ const (
3232
defaultPageBufferSize = 10
3333
)
3434

35+
// lister is the subset of client.Reader that ListPager uses.
36+
type lister interface {
37+
List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error
38+
}
39+
3540
// New creates a new pager from the provided reader using the default options.
36-
func New(reader client.Reader) *ListPager {
41+
func New(reader lister) *ListPager {
3742
return &ListPager{
3843
Reader: reader,
3944
PageSize: defaultPageSize,
@@ -48,7 +53,7 @@ func New(reader client.Reader) *ListPager {
4853
// Exception: this ListPager also fixes the `specifying resource version is not allowed when using continue` error
4954
// in EachListItem and EachListItemWithAlloc.
5055
type ListPager struct {
51-
Reader client.Reader
56+
Reader lister
5257

5358
// PageSize is the maximum number of objects to retrieve in individual list calls.
5459
// If a client.Limit option is passed, the pager uses the option's value instead.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package pager_test
18+
19+
import (
20+
"testing"
21+
22+
. "github.com/onsi/ginkgo/v2"
23+
. "github.com/onsi/gomega"
24+
)
25+
26+
func TestPager(t *testing.T) {
27+
RegisterFailHandler(Fail)
28+
RunSpecs(t, "Pager Suite")
29+
}

pkg/utils/pager/pager_test.go

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/*
2+
Copyright 2025 Tim Ebert.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package pager_test
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strconv"
23+
"time"
24+
25+
. "github.com/onsi/ginkgo/v2"
26+
. "github.com/onsi/gomega"
27+
gomegatypes "github.com/onsi/gomega/types"
28+
corev1 "k8s.io/api/core/v1"
29+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
30+
"sigs.k8s.io/controller-runtime/pkg/client"
31+
32+
. "github.com/timebertt/kubernetes-controller-sharding/pkg/utils/pager"
33+
. "github.com/timebertt/kubernetes-controller-sharding/pkg/utils/test/matchers"
34+
)
35+
36+
var _ = Describe("ListPager", func() {
37+
const (
38+
pageSize = 2
39+
pageCount = 5
40+
objectCount = 9
41+
)
42+
43+
var (
44+
ctx context.Context
45+
46+
allPods []corev1.Pod
47+
48+
reader *lister
49+
pager *ListPager
50+
)
51+
52+
BeforeEach(func() {
53+
ctx = context.Background()
54+
55+
allPods = podSlice(0, objectCount)
56+
57+
reader = &lister{allPods: allPods}
58+
59+
pager = New(reader)
60+
pager.PageSize = pageSize
61+
pager.PageBufferSize = 2
62+
})
63+
64+
It("should page all objects", func() {
65+
var pods []*corev1.Pod
66+
67+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
68+
pods = append(pods, obj.(*corev1.Pod))
69+
return nil
70+
})).To(Succeed())
71+
72+
Expect(pods).To(havePods(1, objectCount))
73+
74+
Expect(reader.calls).To(Equal(pageCount))
75+
})
76+
77+
It("should page objects with the custom limit", func() {
78+
var pods []client.Object
79+
80+
i := 0
81+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
82+
if obj != &allPods[i] {
83+
return fmt.Errorf("the pager should reuse the object")
84+
}
85+
i++
86+
pods = append(pods, obj)
87+
return nil
88+
}, client.Limit(objectCount+1))).To(Succeed())
89+
90+
Expect(pods).To(havePods(1, objectCount))
91+
92+
Expect(reader.calls).To(Equal(1))
93+
})
94+
95+
It("should fail due to negative page size", func() {
96+
pager.PageSize = -1
97+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
98+
return nil
99+
})).To(MatchError(ContainSubstring("PageSize must be >= 0")))
100+
})
101+
102+
It("should fail due to negative page buffer size", func() {
103+
pager.PageBufferSize = -1
104+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
105+
return nil
106+
})).To(MatchError(ContainSubstring("PageBufferSize must be >= 0")))
107+
})
108+
109+
It("should return the lister error", func() {
110+
Expect(pager.EachListItem(ctx, &corev1.ConfigMapList{}, func(obj client.Object) error {
111+
return nil
112+
})).To(MatchError("expected *corev1.PodList, got *v1.ConfigMapList"))
113+
})
114+
115+
It("should return the iterator error", func() {
116+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
117+
if obj.GetName() == "pod-5" {
118+
return fmt.Errorf("foo")
119+
}
120+
return nil
121+
})).To(MatchError("foo"))
122+
})
123+
124+
It("should cancel the operation when the context is canceled", func(specCtx SpecContext) {
125+
done := make(chan struct{})
126+
127+
blockConsumer := make(chan struct{})
128+
defer close(blockConsumer)
129+
130+
ctx, cancel := context.WithCancel(specCtx)
131+
go func() {
132+
defer GinkgoRecover()
133+
134+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
135+
<-blockConsumer
136+
return nil
137+
})).To(MatchError(context.Canceled))
138+
139+
close(done)
140+
}()
141+
142+
cancel()
143+
Eventually(specCtx, done).Should(BeClosed())
144+
}, SpecTimeout(time.Second))
145+
146+
It("should buffer the configured number of pages", func(specCtx SpecContext) {
147+
done := make(chan struct{})
148+
149+
ctx, cancel := context.WithCancel(specCtx)
150+
go func() {
151+
defer GinkgoRecover()
152+
153+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
154+
<-ctx.Done()
155+
return nil
156+
})).To(MatchError(context.Canceled))
157+
158+
close(done)
159+
}()
160+
161+
Eventually(specCtx, func() int {
162+
return reader.calls
163+
}).Should(BeEquivalentTo(pager.PageBufferSize + 2)) // consumer takes one chunk out and one chunk is produced but blocked
164+
165+
cancel()
166+
Eventually(specCtx, done).Should(BeClosed())
167+
}, SpecTimeout(time.Second))
168+
169+
It("should correctly handle the resourceVersion fields", func() {
170+
Expect(pager.EachListItem(ctx, &corev1.PodList{}, func(obj client.Object) error {
171+
return nil
172+
}, &client.ListOptions{Raw: &metav1.ListOptions{
173+
ResourceVersion: "0",
174+
ResourceVersionMatch: "NotOlderThan",
175+
}})).To(Succeed())
176+
})
177+
178+
Describe("#EachListItemWithAlloc", func() {
179+
It("should page all objects", func() {
180+
var pods []client.Object
181+
182+
i := 0
183+
Expect(pager.EachListItemWithAlloc(ctx, &corev1.PodList{}, func(obj client.Object) error {
184+
if obj == &allPods[i] {
185+
return fmt.Errorf("the pager should copy the object")
186+
}
187+
i++
188+
pods = append(pods, obj)
189+
return nil
190+
})).To(Succeed())
191+
192+
Expect(pods).To(havePods(1, objectCount))
193+
194+
Expect(reader.calls).To(Equal(pageCount))
195+
})
196+
})
197+
})
198+
199+
type lister struct {
200+
allPods []corev1.Pod
201+
calls int
202+
203+
previousList client.ObjectList
204+
}
205+
206+
func (l *lister) List(_ context.Context, list client.ObjectList, opts ...client.ListOption) error {
207+
l.calls++
208+
209+
if list == l.previousList {
210+
return fmt.Errorf("the pager should not reuse the list for multiple calls")
211+
}
212+
l.previousList = list
213+
214+
podList, ok := list.(*corev1.PodList)
215+
if !ok {
216+
return fmt.Errorf("expected *corev1.PodList, got %T", list)
217+
}
218+
219+
listOptions := &client.ListOptions{}
220+
listOptions.ApplyOptions(opts)
221+
222+
if l.calls > 1 {
223+
if listOptions.Raw.ResourceVersion != "" {
224+
return fmt.Errorf("the pager should reset the resourceVersion field for consecutive calls")
225+
}
226+
if listOptions.Raw.ResourceVersionMatch != "" {
227+
return fmt.Errorf("the pager should reset the resourceVersionMatch field for consecutive calls")
228+
}
229+
}
230+
231+
limit := listOptions.Limit
232+
if limit == 0 {
233+
return fmt.Errorf("the pager should set limit")
234+
}
235+
236+
var offset int64
237+
if listOptions.Continue != "" {
238+
var err error
239+
offset, err = strconv.ParseInt(listOptions.Continue, 10, 64)
240+
if err != nil {
241+
return err
242+
}
243+
}
244+
245+
defer func() {
246+
if offset+limit >= int64(len(l.allPods)) {
247+
podList.Continue = ""
248+
} else {
249+
podList.Continue = strconv.FormatInt(offset+int64(len(podList.Items)), 10)
250+
}
251+
}()
252+
253+
podList.Items = l.allPods[offset:min(int64(len(l.allPods)), offset+limit)]
254+
return nil
255+
}
256+
257+
func podSlice(offset, n int64) []corev1.Pod {
258+
pods := make([]corev1.Pod, n)
259+
260+
for i := int64(0); i < n; i++ {
261+
pods[i] = corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "pod-" + strconv.FormatInt(offset+i+1, 10)}}
262+
}
263+
264+
return pods
265+
}
266+
267+
func havePods(i, j int) gomegatypes.GomegaMatcher {
268+
var matchers []any
269+
270+
for ; i <= j; i++ {
271+
matchers = append(matchers, HaveName("pod-"+strconv.Itoa(i)))
272+
}
273+
274+
return HaveExactElements(matchers...)
275+
}

pkg/utils/test/matchers/object.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ import (
2121
gomegatypes "github.com/onsi/gomega/types"
2222
)
2323

24+
// HaveName succeeds if the actual object has a matching name.
25+
func HaveName(name interface{}) gomegatypes.GomegaMatcher {
26+
return HaveField("ObjectMeta.Name", name)
27+
}
28+
2429
// HaveLabel succeeds if the actual object has a label with a matching key.
2530
func HaveLabel(key interface{}) gomegatypes.GomegaMatcher {
2631
return HaveField("ObjectMeta.Labels", HaveKey(key))

0 commit comments

Comments
 (0)