From d2bb36591ffb043e6bbc64220c413e7ca5cbb20a Mon Sep 17 00:00:00 2001 From: ILUO <2323221725@qq.com> Date: Fri, 24 Jul 2026 14:28:54 +0800 Subject: [PATCH] fix/task search pagination (#2041) * fix: send task search page token in query * test: assert task search dry-run pagination contract --- shortcuts/task/task_query_helpers.go | 8 ++ shortcuts/task/task_query_helpers_test.go | 25 ++++ shortcuts/task/task_search.go | 11 +- shortcuts/task/task_search_pagination_test.go | 129 ++++++++++++++++++ shortcuts/task/task_search_test.go | 21 ++- shortcuts/task/task_tasklist_search.go | 11 +- shortcuts/task/task_tasklist_search_test.go | 20 ++- .../task_search_pagination_dryrun_test.go | 63 +++++++++ 8 files changed, 263 insertions(+), 25 deletions(-) create mode 100644 shortcuts/task/task_search_pagination_test.go create mode 100644 tests/cli_e2e/task/task_search_pagination_dryrun_test.go diff --git a/shortcuts/task/task_query_helpers.go b/shortcuts/task/task_query_helpers.go index affdd1d89..eba2906ef 100644 --- a/shortcuts/task/task_query_helpers.go +++ b/shortcuts/task/task_query_helpers.go @@ -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 diff --git a/shortcuts/task/task_query_helpers_test.go b/shortcuts/task/task_query_helpers_test.go index 50a199860..09e00ae9d 100644 --- a/shortcuts/task/task_query_helpers_test.go +++ b/shortcuts/task/task_query_helpers_test.go @@ -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 diff --git a/shortcuts/task/task_search.go b/shortcuts/task/task_search.go index 6d22b9efb..e017f6ba1 100644 --- a/shortcuts/task/task_search.go +++ b/shortcuts/task/task_search.go @@ -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 } diff --git a/shortcuts/task/task_search_pagination_test.go b/shortcuts/task/task_search_pagination_test.go new file mode 100644 index 000000000..dca34bc91 --- /dev/null +++ b/shortcuts/task/task_search_pagination_test.go @@ -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{}{}, + }, + }, + } +} diff --git a/shortcuts/task/task_search_test.go b/shortcuts/task/task_search_test.go index 9ae559116..7534c13e5 100644 --- a/shortcuts/task/task_search_test.go +++ b/shortcuts/task/task_search_test.go @@ -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) diff --git a/shortcuts/task/task_tasklist_search.go b/shortcuts/task/task_tasklist_search.go index e3ca6899f..e9291dcb2 100644 --- a/shortcuts/task/task_tasklist_search.go +++ b/shortcuts/task/task_tasklist_search.go @@ -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 } diff --git a/shortcuts/task/task_tasklist_search_test.go b/shortcuts/task/task_tasklist_search_test.go index 6ae11e4ef..82f1c3173 100644 --- a/shortcuts/task/task_tasklist_search_test.go +++ b/shortcuts/task/task_tasklist_search_test.go @@ -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) diff --git a/tests/cli_e2e/task/task_search_pagination_dryrun_test.go b/tests/cli_e2e/task/task_search_pagination_dryrun_test.go new file mode 100644 index 000000000..9fa2c5972 --- /dev/null +++ b/tests/cli_e2e/task/task_search_pagination_dryrun_test.go @@ -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) + }) + } +}