mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
2a1613484a
* feat: add framework flag aliases and unified IM pagination Introduce declarative exact-name flag aliases at the shortcut framework boundary while keeping semantic compatibility domain-owned. Add a shared, format-aware IM pagination pipeline with consistent flags, metadata, safety bounds, resumable cursors, and request throttling. * fix: align alias attribution and pagination contracts * fix: align alias contracts and documentation * test: remove environment-dependent contact bot e2e * test: restore contact bot e2e * docs: reduce IM pagination guidance noise --------- Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
458 lines
16 KiB
Go
458 lines
16 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type paginateIntoTestPage struct {
|
|
Items []string `json:"items"`
|
|
HasMore bool `json:"has_more"`
|
|
PageToken string `json:"page_token"`
|
|
}
|
|
|
|
type paginateIntoTestResult struct {
|
|
items []string
|
|
hasMore bool
|
|
pageToken string
|
|
pages int
|
|
}
|
|
|
|
func (result *paginateIntoTestResult) AddPage(page paginateIntoTestPage) error {
|
|
result.items = append(result.items, page.Items...)
|
|
result.hasMore = page.HasMore
|
|
result.pageToken = page.PageToken
|
|
result.pages++
|
|
return nil
|
|
}
|
|
|
|
func newPaginateIntoTestRuntime(t *testing.T, flags map[string]string) (*RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
|
|
t.Helper()
|
|
config := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
|
|
factory, _, stderr, registry := cmdutil.TestFactory(t, config)
|
|
cmd := &cobra.Command{Use: "+list"}
|
|
cmd.Flags().Bool("page-all", false, "")
|
|
cmd.Flags().Int("page-limit", 10, "")
|
|
cmd.Flags().Int("page-delay", pageDelayDefault, "")
|
|
for name, value := range flags {
|
|
if err := cmd.Flags().Set(name, value); err != nil {
|
|
t.Fatalf("set --%s=%s: %v", name, value, err)
|
|
}
|
|
}
|
|
runtime := TestNewRuntimeContextForAPI(context.Background(), cmd, config, factory, core.AsUser)
|
|
return runtime, stderr, registry
|
|
}
|
|
|
|
func TestPageAllFlagsContract(t *testing.T) {
|
|
flags := PageAllFlags()
|
|
if len(flags) != 3 {
|
|
t.Fatalf("PageAllFlags() returned %d flags, want 3", len(flags))
|
|
}
|
|
if got := flags[0]; got.Name != PageAllFlagName || got.Type != "bool" || got.Default != "" {
|
|
t.Fatalf("page-all flag = %#v", got)
|
|
}
|
|
if got := flags[1]; got.Name != pageLimitFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageLimitDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageLimitMaximum)) {
|
|
t.Fatalf("page-limit flag = %#v", got)
|
|
}
|
|
if got := flags[2]; got.Name != pageDelayFlagName || got.Type != "int" || got.Default != strconv.Itoa(pageDelayDefault) || !strings.Contains(got.Desc, strconv.Itoa(pageDelayMaximum)) {
|
|
t.Fatalf("page-delay flag = %#v", got)
|
|
}
|
|
|
|
flags[0].Desc = "mutated"
|
|
if PageAllFlags()[0].Desc == "mutated" {
|
|
t.Fatal("PageAllFlags() reused mutable definitions")
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoDecodesAndAccumulatesPages(t *testing.T) {
|
|
runtime, stderr, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
|
runtime.IO().StderrIsTerminal = true
|
|
var requestTokens []string
|
|
for _, data := range []map[string]interface{}{
|
|
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
|
{"items": []string{"second"}, "has_more": false, "page_token": "final"},
|
|
} {
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{"code": 0, "data": data},
|
|
OnMatch: func(request *http.Request) {
|
|
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
|
},
|
|
})
|
|
}
|
|
|
|
params := map[string]interface{}{"page_size": 20}
|
|
result := &paginateIntoTestResult{}
|
|
meta, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
Params: params,
|
|
}, result)
|
|
if err != nil {
|
|
t.Fatalf("PaginateInto() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(result.items, []string{"first", "second"}) {
|
|
t.Fatalf("items = %v, want [first second]", result.items)
|
|
}
|
|
if result.pages != 2 || result.hasMore || result.pageToken != "final" {
|
|
t.Fatalf("result meta = pages:%d has_more:%v page_token:%q", result.pages, result.hasMore, result.pageToken)
|
|
}
|
|
if !meta.Complete || meta.Pages != 2 || meta.NextToken != "" {
|
|
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
|
}
|
|
if !reflect.DeepEqual(requestTokens, []string{"", "next"}) {
|
|
t.Fatalf("request page tokens = %v, want [\"\" \"next\"]", requestTokens)
|
|
}
|
|
if _, mutated := params["page_token"]; mutated {
|
|
t.Fatalf("PaginateInto mutated caller params: %#v", params)
|
|
}
|
|
for _, want := range []string{"[page 1] fetching...", "[page 2] fetching..."} {
|
|
if !strings.Contains(stderr.String(), want) {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPaginationProgressContract(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
format string
|
|
jq string
|
|
terminal bool
|
|
want bool
|
|
}{
|
|
{name: "non-terminal json", format: "json", want: false},
|
|
{name: "terminal json", format: "json", terminal: true, want: true},
|
|
{name: "terminal pretty", format: "pretty", terminal: true, want: true},
|
|
{name: "terminal table", format: "table", terminal: true, want: true},
|
|
{name: "terminal csv", format: "csv", terminal: true, want: false},
|
|
{name: "terminal ndjson", format: "ndjson", terminal: true, want: false},
|
|
{name: "jq overrides record format", format: "csv", jq: ".data", terminal: true, want: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
|
runtime.Format = test.format
|
|
runtime.JqExpr = test.jq
|
|
runtime.IO().StderrIsTerminal = test.terminal
|
|
policy, err := resolvePaginationPolicy(runtime)
|
|
if err != nil {
|
|
t.Fatalf("resolvePaginationPolicy() error = %v", err)
|
|
}
|
|
if policy.showProgress != test.want {
|
|
t.Fatalf("showProgress = %v, want %v", policy.showProgress, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoWaitsOnlyBetweenPages(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
|
for _, data := range []map[string]interface{}{
|
|
{"items": []string{"first"}, "has_more": true, "page_token": "next"},
|
|
{"items": []string{"second"}, "has_more": false},
|
|
} {
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{"code": 0, "data": data},
|
|
})
|
|
}
|
|
|
|
var waits []time.Duration
|
|
meta, err := paginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, &paginateIntoTestResult{}, func(_ context.Context, delay time.Duration) error {
|
|
waits = append(waits, delay)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("paginateInto() error = %v", err)
|
|
}
|
|
if !meta.Complete || meta.Pages != 2 {
|
|
t.Fatalf("pagination meta = %+v, want complete two-page run", meta)
|
|
}
|
|
if !reflect.DeepEqual(waits, []time.Duration{pageDelayDefault * time.Millisecond}) {
|
|
t.Fatalf("page waits = %v, want one %s wait", waits, pageDelayDefault*time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoDelayCancellationIsTypedAndResumable(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true"})
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"items": []string{"first"},
|
|
"has_more": true,
|
|
"page_token": "resume",
|
|
},
|
|
},
|
|
})
|
|
|
|
meta, err := paginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, &paginateIntoTestResult{}, func(_ context.Context, _ time.Duration) error {
|
|
return context.Canceled
|
|
})
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("paginateInto() error = %v, want context.Canceled cause", err)
|
|
}
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
|
t.Fatalf("pagination cancellation problem = %#v, %v; want network/transport", problem, ok)
|
|
}
|
|
if meta.Pages != 1 || meta.Complete || meta.NextToken != "resume" {
|
|
t.Fatalf("pagination meta = %+v, want resumable first page", meta)
|
|
}
|
|
}
|
|
|
|
func TestWaitPageDelayHonorsCanceledContextWithoutSleeping(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := waitPageDelay(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("waitPageDelay() error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoUsesOnePagePolicyByDefault(t *testing.T) {
|
|
runtime, stderr, registry := newPaginateIntoTestRuntime(t, nil)
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"items": []string{"first"},
|
|
"has_more": true,
|
|
"page_token": "next",
|
|
},
|
|
},
|
|
})
|
|
|
|
result := &paginateIntoTestResult{}
|
|
meta, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, result)
|
|
if err != nil {
|
|
t.Fatalf("PaginateInto() error = %v", err)
|
|
}
|
|
if result.pages != 1 || !reflect.DeepEqual(result.items, []string{"first"}) {
|
|
t.Fatalf("result = %+v, want one accumulated page", result)
|
|
}
|
|
if meta.Complete || meta.Pages != 1 || meta.NextToken != "next" {
|
|
t.Fatalf("pagination meta = %+v, want incomplete one-page run", meta)
|
|
}
|
|
if stderr.Len() != 0 {
|
|
t.Fatalf("default one-page run wrote progress to stderr: %q", stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoStopsAtConfiguredPageLimit(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{
|
|
"page-all": "true",
|
|
"page-limit": "2",
|
|
"page-delay": "0",
|
|
})
|
|
var requestTokens []string
|
|
for _, data := range []map[string]interface{}{
|
|
{"items": []string{"first"}, "has_more": true, "page_token": "next_1"},
|
|
{"items": []string{"second"}, "has_more": true, "page_token": "next_2"},
|
|
} {
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{"code": 0, "data": data},
|
|
OnMatch: func(request *http.Request) {
|
|
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
|
},
|
|
})
|
|
}
|
|
|
|
result := &paginateIntoTestResult{}
|
|
meta, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
Params: map[string]interface{}{"page_token": []string{"resume"}},
|
|
}, result)
|
|
if err != nil {
|
|
t.Fatalf("PaginateInto() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestTokens, []string{"resume", "next_1"}) || result.pages != 2 {
|
|
t.Fatalf("page tokens = %v, accumulated pages = %d; want [resume next_1] and hard stop at 2", requestTokens, result.pages)
|
|
}
|
|
if meta.Complete || meta.Pages != 2 || meta.NextToken != "next_2" {
|
|
t.Fatalf("pagination meta = %+v, want incomplete result resumable at %q", meta, "next_2")
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoContinuesFromExplicitCursorWithPageAll(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, map[string]string{"page-all": "true", "page-delay": "0"})
|
|
var requestTokens []string
|
|
for _, data := range []map[string]interface{}{
|
|
{"items": []string{"from-resume"}, "has_more": true, "page_token": "next_1"},
|
|
{"items": []string{"after-resume-1"}, "has_more": true, "page_token": "next_2"},
|
|
{"items": []string{"after-resume-2"}, "has_more": false},
|
|
} {
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{"code": 0, "data": data},
|
|
OnMatch: func(request *http.Request) {
|
|
requestTokens = append(requestTokens, request.URL.Query().Get("page_token"))
|
|
},
|
|
})
|
|
}
|
|
|
|
result := &paginateIntoTestResult{}
|
|
meta, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
// SDK request builders represent query values as []string. Pin that
|
|
// representation here so an explicit resume cursor remains compatible
|
|
// with both SDK-built and raw map requests.
|
|
Params: map[string]interface{}{"page_token": []string{"resume"}},
|
|
}, result)
|
|
if err != nil {
|
|
t.Fatalf("PaginateInto() error = %v", err)
|
|
}
|
|
if !reflect.DeepEqual(requestTokens, []string{"resume", "next_1", "next_2"}) {
|
|
t.Fatalf("request page tokens = %v, want [resume next_1 next_2]", requestTokens)
|
|
}
|
|
if !reflect.DeepEqual(result.items, []string{"from-resume", "after-resume-1", "after-resume-2"}) || !meta.Complete || meta.Pages != 3 || meta.NextToken != "" {
|
|
t.Fatalf("result = %+v meta = %+v", result, meta)
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoRejectsStartingCursorRepeatedByServer(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"items": []string{"item"},
|
|
"has_more": true,
|
|
"page_token": "resume",
|
|
},
|
|
},
|
|
OnMatch: func(request *http.Request) {
|
|
if token := request.URL.Query().Get("page_token"); token != "resume" {
|
|
t.Errorf("request page_token = %q, want resume", token)
|
|
}
|
|
},
|
|
})
|
|
|
|
_, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
Params: map[string]interface{}{"page_token": "resume"},
|
|
}, &paginateIntoTestResult{})
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
|
}
|
|
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
|
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
|
}
|
|
if !strings.Contains(problem.Message, "repeated page token") {
|
|
t.Fatalf("problem message = %q, want repeated-token diagnosis", problem.Message)
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoRejectsPageOutsideTypedContract(t *testing.T) {
|
|
runtime, _, registry := newPaginateIntoTestRuntime(t, nil)
|
|
registry.Register(&httpmock.Stub{
|
|
Method: http.MethodGet,
|
|
URL: "/open-apis/test/v1/items",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"unexpected": true}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
_, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, &paginateIntoTestResult{})
|
|
problem, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("PaginateInto() error = %v, want typed error", err)
|
|
}
|
|
if problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Fatalf("problem = (%q, %q), want (%q, %q)",
|
|
problem.Category, problem.Subtype, errs.CategoryInternal, errs.SubtypeInvalidResponse)
|
|
}
|
|
if !strings.Contains(problem.Message, "decode pagination page 1") {
|
|
t.Fatalf("problem message = %q, want page-specific decode diagnosis", problem.Message)
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoRejectsPageLimitOutsideSharedBounds(t *testing.T) {
|
|
for _, limit := range []string{"-1", "0", strconv.Itoa(pageLimitMaximum + 1)} {
|
|
t.Run(limit, func(t *testing.T) {
|
|
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
|
PageAllFlagName: "true",
|
|
pageLimitFlagName: limit,
|
|
})
|
|
|
|
_, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, &paginateIntoTestResult{})
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-limit" {
|
|
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-limit", problem, ok)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPaginateIntoRejectsPageDelayOutsideSharedBounds(t *testing.T) {
|
|
for _, delay := range []string{"-1", strconv.Itoa(pageDelayMaximum + 1)} {
|
|
t.Run(delay, func(t *testing.T) {
|
|
runtime, _, _ := newPaginateIntoTestRuntime(t, map[string]string{
|
|
pageDelayFlagName: delay,
|
|
})
|
|
|
|
_, err := PaginateInto(runtime, PageRequest{
|
|
Method: http.MethodGet,
|
|
Path: "/open-apis/test/v1/items",
|
|
}, &paginateIntoTestResult{})
|
|
problem, ok := errs.ProblemOf(err)
|
|
var validationErr *errs.ValidationError
|
|
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument || !errors.As(err, &validationErr) || validationErr.Param != "--page-delay" {
|
|
t.Fatalf("PaginateInto() problem = %#v, %v; want invalid --page-delay", problem, ok)
|
|
}
|
|
})
|
|
}
|
|
}
|