mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
fix/task search pagination (#2041)
* fix: send task search page token in query * test: assert task search dry-run pagination contract
This commit is contained in:
@@ -24,6 +24,14 @@ func splitAndTrimCSV(input string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildSearchPageParams(pageToken string) map[string]interface{} {
|
||||
params := map[string]interface{}{}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
|
||||
@@ -37,6 +37,31 @@ func TestSplitAndTrimCSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSearchPageParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pageToken string
|
||||
wantToken string
|
||||
wantKey bool
|
||||
}{
|
||||
{name: "first page omits token"},
|
||||
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := buildSearchPageParams(tt.pageToken)
|
||||
got, present := params["page_token"]
|
||||
if present != tt.wantKey {
|
||||
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
|
||||
}
|
||||
if tt.wantKey && got != tt.wantToken {
|
||||
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -44,8 +44,10 @@ var SearchTask = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -74,9 +76,9 @@ var SearchTask = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,7 +92,7 @@ var SearchTask = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -183,9 +185,6 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
129
shortcuts/task/task_search_pagination_test.go
Normal file
129
shortcuts/task/task_search_pagination_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSearchPaginationUsesQueryToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
command string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "tasks",
|
||||
shortcut: SearchTask,
|
||||
command: "+search",
|
||||
url: "/open-apis/task/v2/tasks/search",
|
||||
},
|
||||
{
|
||||
name: "tasklists",
|
||||
shortcut: SearchTasklist,
|
||||
command: "+tasklist-search",
|
||||
url: "/open-apis/task/v2/tasklists/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
var pageTokens []string
|
||||
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
|
||||
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
|
||||
|
||||
shortcut := tt.shortcut
|
||||
shortcut.AuthTypes = []string{"bot", "user"}
|
||||
err := runMountedTaskShortcut(t, shortcut, []string{
|
||||
tt.command,
|
||||
"--query", "pagination",
|
||||
"--page-token", "initial_pt",
|
||||
"--page-limit", "2",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("search command failed: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"initial_pt", "next_pt"}
|
||||
if !reflect.DeepEqual(pageTokens, want) {
|
||||
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
|
||||
t.Helper()
|
||||
|
||||
data, err := preview.MarshalJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal search dry-run preview: %v", err)
|
||||
}
|
||||
var envelope struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatalf("decode search dry-run preview: %v", err)
|
||||
}
|
||||
if len(envelope.API) != 1 {
|
||||
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
|
||||
}
|
||||
call := envelope.API[0]
|
||||
if got, _ := call.Params["page_token"].(string); got != want {
|
||||
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
|
||||
}
|
||||
if _, present := call.Body["page_token"]; present {
|
||||
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
|
||||
t.Helper()
|
||||
return &httpmock.Stub{
|
||||
Method: http.MethodPost,
|
||||
URL: endpoint,
|
||||
OnMatch: func(req *http.Request) {
|
||||
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
|
||||
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Errorf("read search request body: %v", err)
|
||||
return
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Errorf("decode search request body: %v", err)
|
||||
return
|
||||
}
|
||||
if _, present := payload["page_token"]; present {
|
||||
t.Errorf("search request body unexpectedly contains page_token: %s", body)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": hasMore,
|
||||
"page_token": responseToken,
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,12 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
dueTime := filter["due_time"].(map[string]interface{})
|
||||
if body["query"] != "release" || body["page_token"] != "pt_123" {
|
||||
if body["query"] != "release" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
}
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
}
|
||||
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
@@ -104,9 +107,10 @@ func TestBuildTaskSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -114,7 +118,8 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "demo")
|
||||
_ = cmd.Flags().Set("page-token", "pt_demo")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
||||
wantPageToken: "pt_demo",
|
||||
wantParts: []string{`"query":"demo"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid due",
|
||||
@@ -143,7 +148,11 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTask.DryRun(nil, runtime).Format()
|
||||
preview := SearchTask.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
@@ -41,8 +41,10 @@ var SearchTasklist = common.Shortcut{
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
@@ -71,9 +73,9 @@ var SearchTasklist = common.Shortcut{
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
params := buildSearchPageParams(runtime.Str("page-token"))
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,7 +89,7 @@ var SearchTasklist = common.Shortcut{
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
@@ -170,9 +172,6 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
check: func(t *testing.T, body map[string]interface{}) {
|
||||
filter := body["filter"].(map[string]interface{})
|
||||
createTime := filter["create_time"].(map[string]interface{})
|
||||
if body["page_token"] != "pt_tl" {
|
||||
t.Fatalf("unexpected body: %#v", body)
|
||||
if _, present := body["page_token"]; present {
|
||||
t.Fatalf("body unexpectedly contains page_token: %#v", body)
|
||||
}
|
||||
if filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
@@ -80,9 +80,10 @@ func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
|
||||
func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantPageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "valid dry run",
|
||||
@@ -90,7 +91,8 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
_ = cmd.Flags().Set("query", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
wantPageToken: "pt_tl",
|
||||
wantParts: []string{`"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
@@ -116,7 +118,11 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
preview := SearchTasklist.DryRun(nil, runtime)
|
||||
if tt.wantPageToken != "" {
|
||||
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
|
||||
}
|
||||
out := preview.Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
|
||||
63
tests/cli_e2e/task/task_search_pagination_dryrun_test.go
Normal file
63
tests/cli_e2e/task/task_search_pagination_dryrun_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTask_SearchPaginationDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "task_search_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_search_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
url string
|
||||
}{
|
||||
{
|
||||
name: "tasks",
|
||||
command: "+search",
|
||||
url: "/open-apis/task/v2/tasks/search",
|
||||
},
|
||||
{
|
||||
name: "tasklists",
|
||||
command: "+tasklist-search",
|
||||
url: "/open-apis/task/v2/tasklists/search",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"task", tt.command,
|
||||
"--query", "pagination",
|
||||
"--page-token", "initial_pt",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
|
||||
require.Equal(t, tt.url, clie2e.DryRunGet(out, "api.0.url").String(), out)
|
||||
require.Equal(t, "initial_pt", clie2e.DryRunGet(out, "api.0.params.page_token").String(), out)
|
||||
require.Equal(t, "pagination", clie2e.DryRunGet(out, "api.0.body.query").String(), out)
|
||||
require.False(t, clie2e.DryRunGet(out, "api.0.body.page_token").Exists(), out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user