Files
dingtalk-workspace-cli/pkg/paging/paging_test.go

236 lines
8.0 KiB
Go

// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package paging
import (
"context"
"errors"
"fmt"
"testing"
"time"
)
// stubFetcher 用一个预设的 page 序列模拟翻页响应。
type stubFetcher struct {
pages []Page
pageErr map[int]error // 第几次调用要返回 err(从 0 开始)
calls int
lastInput string
}
func (s *stubFetcher) Fetch(ctx context.Context, cursor string) (Page, error) {
s.lastInput = cursor
idx := s.calls
s.calls++
if err, ok := s.pageErr[idx]; ok {
return Page{}, err
}
if idx >= len(s.pages) {
// 模拟服务端没数据:空 page + 空 cursor
return Page{}, nil
}
return s.pages[idx], nil
}
func TestCrossPlatformCoverageFetchAllSinglePage(t *testing.T) {
s := &stubFetcher{
pages: []Page{
{Records: []any{"a", "b", "c"}, NextCursor: ""},
},
}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: 1 * time.Millisecond})
if got.HasMore || !got.Complete || got.StopReason != StopComplete || got.Pages != 1 || got.Attempts != 1 || len(got.Records) != 3 {
t.Fatalf("single page: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllMultiPage(t *testing.T) {
s := &stubFetcher{
pages: []Page{
{Records: []any{1, 2}, NextCursor: "c1"},
{Records: []any{3, 4}, NextCursor: "c2"},
{Records: []any{5}, NextCursor: ""},
},
}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: 1 * time.Millisecond})
if got.HasMore || !got.Complete || got.Pages != 3 || got.Attempts != 3 || len(got.Records) != 5 {
t.Fatalf("multi page: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllPageLimit(t *testing.T) {
s := &stubFetcher{
pages: []Page{
{Records: []any{1}, NextCursor: "c1"},
{Records: []any{2}, NextCursor: "c2"},
{Records: []any{3}, NextCursor: "c3"},
{Records: []any{4}, NextCursor: "c4"},
},
}
got := FetchAll(context.Background(), s.Fetch, Options{
PageLimit: 2,
InterPageDelay: 1 * time.Millisecond,
})
if !got.HasMore || got.Pages != 2 || got.LastCursor != "c2" {
t.Fatalf("page limit: got=%+v", got)
}
if !errors.Is(got.Err, ErrPageLimitReached) {
t.Fatalf("expected ErrPageLimitReached, got %v", got.Err)
}
if len(got.Records) != 2 {
t.Fatalf("page limit records: got=%v", got.Records)
}
if got.Complete || got.Partial || got.StopReason != StopPageLimit {
t.Fatalf("page limit completeness: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllMidErrorPartial(t *testing.T) {
upstreamErr := errors.New("502 bad gateway")
s := &stubFetcher{
pages: []Page{
{Records: []any{1}, NextCursor: "c1"},
},
pageErr: map[int]error{1: upstreamErr}, // 第二次调用挂
}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: 1 * time.Millisecond})
if !got.Partial || !errors.Is(got.Err, upstreamErr) {
t.Fatalf("partial: got=%+v", got)
}
if len(got.Records) != 1 || got.LastCursor != "c1" {
t.Fatalf("partial preserves data + cursor: got=%+v", got)
}
if !got.HasMore {
t.Fatalf("partial should signal HasMore=true so caller can retry")
}
if got.Pages != 1 || got.Attempts != 2 || got.StopReason != StopFetchError {
t.Fatalf("partial counters: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllInitialCursorResume(t *testing.T) {
s := &stubFetcher{
pages: []Page{
{Records: []any{"resumed"}, NextCursor: ""},
},
}
got := FetchAll(context.Background(), s.Fetch, Options{InitialCursor: "saved-from-last-time"})
if s.lastInput != "saved-from-last-time" {
t.Fatalf("InitialCursor not propagated: got %q", s.lastInput)
}
if len(got.Records) != 1 || got.HasMore {
t.Fatalf("resume: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
s := &stubFetcher{pages: []Page{{Records: []any{1}, NextCursor: "c1"}}}
got := FetchAll(ctx, s.Fetch, Options{InterPageDelay: 1 * time.Millisecond})
if !errors.Is(got.Err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", got.Err)
}
if !got.Partial {
t.Fatalf("partial on cancel: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllUnlimitedPageLimit(t *testing.T) {
// 无限分页必须由哨兵值显式选择;用超过默认安全阀的页数证明它生效。
pages := make([]Page, DefaultPageLimit+1)
for i := range pages {
next := ""
if i < len(pages)-1 {
next = "c" + string(rune('0'+i))
}
pages[i] = Page{Records: []any{i}, NextCursor: next}
}
s := &stubFetcher{pages: pages}
got := FetchAll(context.Background(), s.Fetch, Options{
PageLimit: UnlimitedPageLimit,
InterPageDelay: 1 * time.Millisecond,
})
if got.HasMore || !got.Complete || got.Pages != len(pages) || len(got.Records) != len(pages) {
t.Fatalf("unlimited: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllZeroValueUsesDefaultPageLimit(t *testing.T) {
pages := make([]Page, DefaultPageLimit+1)
for i := range pages {
pages[i] = Page{Records: []any{i}, NextCursor: fmt.Sprintf("cursor-%d", i+1)}
}
s := &stubFetcher{pages: pages}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Nanosecond})
if got.Pages != DefaultPageLimit || got.Attempts != DefaultPageLimit || len(got.Records) != DefaultPageLimit {
t.Fatalf("zero-value page limit = %+v", got)
}
if !got.HasMore || got.Complete || got.Partial || got.StopReason != StopPageLimit || !errors.Is(got.Err, ErrPageLimitReached) {
t.Fatalf("zero-value safety result = %+v", got)
}
if s.calls != DefaultPageLimit {
t.Fatalf("zero-value fetch calls = %d, want %d", s.calls, DefaultPageLimit)
}
}
func TestCrossPlatformCoverageFetchAllUnsupportedNegativeUsesDefaultPageLimit(t *testing.T) {
calls := 0
got := FetchAll(context.Background(), func(context.Context, string) (Page, error) {
calls++
return Page{NextCursor: fmt.Sprintf("cursor-%d", calls)}, nil
}, Options{PageLimit: -2, InterPageDelay: time.Nanosecond})
if calls != DefaultPageLimit || got.StopReason != StopPageLimit {
t.Fatalf("unsupported negative page limit = %+v calls:%d", got, calls)
}
}
func TestCrossPlatformCoverageFetchAllPreservesServerTotalCount(t *testing.T) {
total := 123
s := &stubFetcher{pages: []Page{{Records: []any{1, 2}, TotalCount: &total}}}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond})
if got.TotalCount == nil || *got.TotalCount != total {
t.Fatalf("server total count lost: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllStopsOnCursorCycle(t *testing.T) {
s := &stubFetcher{pages: []Page{
{Records: []any{1}, NextCursor: "c1"},
{Records: []any{2}, NextCursor: "c1"},
}}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond})
if !got.Partial || got.Complete || got.StopReason != StopCursorCycle || !errors.Is(got.Err, ErrCursorCycle) {
t.Fatalf("cursor cycle: got=%+v", got)
}
if got.Pages != 2 || got.Attempts != 2 || got.LastCursor != "c1" || len(got.Records) != 2 {
t.Fatalf("cursor cycle progress: got=%+v", got)
}
}
func TestCrossPlatformCoverageFetchAllCancellationDuringDelay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
got := FetchAll(ctx, func(context.Context, string) (Page, error) {
calls++
time.AfterFunc(10*time.Millisecond, cancel)
return Page{Records: []any{"first"}, NextCursor: "next"}, nil
}, Options{InterPageDelay: time.Second})
if calls != 1 || got.StopReason != StopCanceled || !errors.Is(got.Err, context.Canceled) || got.LastCursor != "next" {
t.Fatalf("delay cancellation = %+v calls:%d", got, calls)
}
}
func TestCrossPlatformCoverageFetchAllStopsOnPriorCursorCycle(t *testing.T) {
s := &stubFetcher{pages: []Page{
{Records: []any{1}, NextCursor: "c1"},
{Records: []any{2}, NextCursor: "c2"},
{Records: []any{3}, NextCursor: "c1"},
}}
got := FetchAll(context.Background(), s.Fetch, Options{InterPageDelay: time.Millisecond})
if got.StopReason != StopCursorCycle || got.Pages != 3 || got.LastCursor != "c1" || !errors.Is(got.Err, ErrCursorCycle) {
t.Fatalf("prior cursor cycle = %+v", got)
}
}