mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* feat: surface search API notices sa: safe doc: none cfg: none test: unit test * fix: surface search notices in default output * docs: add search notice doc comments * docs: expand search notice doc comments
434 lines
14 KiB
Go
434 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package task
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func TestBuildTaskSearchBody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*cobra.Command)
|
|
wantErr bool
|
|
check func(*testing.T, map[string]interface{})
|
|
}{
|
|
{
|
|
name: "query creator due and page token",
|
|
setup: func(cmd *cobra.Command) {
|
|
_ = cmd.Flags().Set("query", "release")
|
|
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
|
|
_ = cmd.Flags().Set("completed", "true")
|
|
_ = cmd.Flags().Set("due", "-1d,+1d")
|
|
_ = cmd.Flags().Set("page-token", "pt_123")
|
|
},
|
|
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" {
|
|
t.Fatalf("unexpected body: %#v", body)
|
|
}
|
|
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
|
|
t.Fatalf("unexpected filter: %#v", filter)
|
|
}
|
|
startTime, _ := dueTime["start_time"].(string)
|
|
endTime, _ := dueTime["end_time"].(string)
|
|
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
|
t.Fatalf("unexpected due_time: %#v", dueTime)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "requires query or filter",
|
|
setup: func(cmd *cobra.Command) {},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "assignee follower and incomplete",
|
|
setup: func(cmd *cobra.Command) {
|
|
_ = cmd.Flags().Set("assignee", "ou_assignee")
|
|
_ = cmd.Flags().Set("follower", "ou_follower")
|
|
_ = cmd.Flags().Set("completed", "false")
|
|
},
|
|
check: func(t *testing.T, body map[string]interface{}) {
|
|
filter := body["filter"].(map[string]interface{})
|
|
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
|
|
t.Fatalf("unexpected filter: %#v", filter)
|
|
}
|
|
if filter["is_completed"] != false {
|
|
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("query", "", "")
|
|
cmd.Flags().String("creator", "", "")
|
|
cmd.Flags().String("assignee", "", "")
|
|
cmd.Flags().String("follower", "", "")
|
|
cmd.Flags().Bool("completed", false, "")
|
|
cmd.Flags().String("due", "", "")
|
|
cmd.Flags().String("page-token", "", "")
|
|
tt.setup(cmd)
|
|
|
|
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
|
body, err := buildTaskSearchBody(runtime)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("buildTaskSearchBody() error = %v", err)
|
|
}
|
|
tt.check(t, body)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSearchTask_DryRun(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
setup func(*cobra.Command)
|
|
wantParts []string
|
|
}{
|
|
{
|
|
name: "valid dry run",
|
|
setup: func(cmd *cobra.Command) {
|
|
_ = cmd.Flags().Set("query", "demo")
|
|
_ = cmd.Flags().Set("page-token", "pt_demo")
|
|
},
|
|
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
|
|
},
|
|
{
|
|
name: "dry run error on invalid due",
|
|
setup: func(cmd *cobra.Command) {
|
|
_ = cmd.Flags().Set("due", "bad-time")
|
|
},
|
|
wantParts: []string{"error:"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().String("query", "", "")
|
|
cmd.Flags().String("creator", "", "")
|
|
cmd.Flags().String("assignee", "", "")
|
|
cmd.Flags().String("follower", "", "")
|
|
cmd.Flags().Bool("completed", false, "")
|
|
cmd.Flags().String("due", "", "")
|
|
cmd.Flags().String("page-token", "", "")
|
|
tt.setup(cmd)
|
|
|
|
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
|
if !strings.Contains(tt.name, "error") {
|
|
if err := SearchTask.Validate(nil, runtime); err != nil {
|
|
t.Fatalf("Validate() error = %v", err)
|
|
}
|
|
}
|
|
out := SearchTask.DryRun(nil, runtime).Format()
|
|
for _, want := range tt.wantParts {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("dry run output missing %q: %s", want, out)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchTask_Execute verifies task search output, enrichment, and notices.
|
|
func TestSearchTask_Execute(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
register func(*httpmock.Registry)
|
|
wantParts []string
|
|
}{
|
|
{
|
|
name: "json success",
|
|
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
|
|
register: func(reg *httpmock.Registry) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
|
"has_more": false,
|
|
"page_token": "",
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/task/v2/tasks/task-123",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
|
|
},
|
|
},
|
|
})
|
|
},
|
|
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
|
},
|
|
{
|
|
name: "fallback to app link",
|
|
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
|
register: func(reg *httpmock.Registry) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"has_more": false,
|
|
"page_token": "",
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/task/v2/tasks/task-999",
|
|
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
|
})
|
|
},
|
|
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
|
|
},
|
|
{
|
|
name: "empty pretty with pagination",
|
|
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
|
|
register: func(reg *httpmock.Registry) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
|
},
|
|
})
|
|
},
|
|
wantParts: []string{"No tasks found."},
|
|
},
|
|
{
|
|
name: "pretty with next page token",
|
|
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
|
|
register: func(reg *httpmock.Registry) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"has_more": true,
|
|
"page_token": "pt_next",
|
|
"items": []interface{}{
|
|
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/task/v2/tasks/task-321",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
|
|
},
|
|
},
|
|
})
|
|
},
|
|
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f, stdout, _, reg := taskShortcutTestFactory(t)
|
|
warmTenantToken(t, f, reg)
|
|
tt.register(reg)
|
|
|
|
s := SearchTask
|
|
s.AuthTypes = []string{"bot", "user"}
|
|
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
|
for _, want := range tt.wantParts {
|
|
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
|
t.Fatalf("output missing %q: %s", want, out)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchTask_InvalidDue_Validation drives the --due validation arm through
|
|
// the mounted command. buildTaskSearchBody runs before any API call, so a
|
|
// malformed range deterministically surfaces a typed *errs.ValidationError
|
|
// (invalid_argument, exit 2) carrying the --due param.
|
|
func TestSearchTask_InvalidDue_Validation(t *testing.T) {
|
|
f, stdout, _, _ := taskShortcutTestFactory(t)
|
|
|
|
s := SearchTask
|
|
s.AuthTypes = []string{"bot", "user"}
|
|
|
|
args := []string{"+search", "--query", "release", "--due", "not-a-time", "--as", "user"}
|
|
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for malformed --due, got nil")
|
|
}
|
|
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
|
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
|
}
|
|
if ve.Param != "--due" {
|
|
t.Errorf("param = %q, want %q", ve.Param, "--due")
|
|
}
|
|
}
|
|
|
|
// TestSearchTask_MalformedSearchResponse covers the search raw-body parse arm:
|
|
// the SDK returns a 200 with a non-JSON body and nil error, so the shortcut's
|
|
// own json.Unmarshal fails and must surface a typed *errs.InternalError
|
|
// (invalid_response, exit 5).
|
|
func TestSearchTask_MalformedSearchResponse(t *testing.T) {
|
|
f, stdout, _, reg := taskShortcutTestFactory(t)
|
|
warmTenantToken(t, f, reg)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/task/v2/tasks/search",
|
|
RawBody: []byte("{not-json"),
|
|
})
|
|
|
|
s := SearchTask
|
|
s.AuthTypes = []string{"bot", "user"}
|
|
|
|
args := []string{"+search", "--query", "release", "--as", "bot", "--format", "json"}
|
|
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected internal error for malformed response, got nil")
|
|
}
|
|
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
|
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
|
}
|
|
}
|
|
|
|
// TestGetTaskDetail_MalformedResponse exercises getTaskDetail directly. In the
|
|
// +search Execute loop a detail-fetch error is intentionally swallowed (the hit
|
|
// falls back to its app_link), so the only way to lock the helper's two
|
|
// internal arms — a non-JSON body and a code-0 response missing the task object
|
|
// — is to call it directly. Both must surface a typed *errs.InternalError
|
|
// (invalid_response, exit 5).
|
|
func TestGetTaskDetail_MalformedResponse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
stub *httpmock.Stub
|
|
}{
|
|
{
|
|
name: "body not json",
|
|
stub: &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/task/v2/tasks/task-123",
|
|
RawBody: []byte("{not-json"),
|
|
},
|
|
},
|
|
{
|
|
name: "missing task object",
|
|
stub: &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/task/v2/tasks/task-123",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
f, _, _, reg := taskShortcutTestFactory(t)
|
|
warmTenantToken(t, f, reg)
|
|
reg.Register(tt.stub)
|
|
|
|
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, taskTestConfig(t), f, core.AsBot)
|
|
|
|
_, err := getTaskDetail(runtime, "task-123")
|
|
if err == nil {
|
|
t.Fatal("expected internal error, got nil")
|
|
}
|
|
|
|
var ie *errs.InternalError
|
|
if !errors.As(err, &ie) {
|
|
t.Fatalf("error type = %T, want *errs.InternalError; error = %v", err, err)
|
|
}
|
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
|
}
|
|
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
|
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
|
}
|
|
})
|
|
}
|
|
}
|