Skip to content

Commit aa48bf5

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

File tree

4 files changed

+328
-2
lines changed

4 files changed

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

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)