236 lines
8.0 KiB
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)
|
|
}
|
|
}
|