chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func inferTaskMemberType(id string) string {
|
||||
if strings.HasPrefix(strings.TrimSpace(id), "cli_") {
|
||||
return "app"
|
||||
}
|
||||
return "user"
|
||||
}
|
||||
|
||||
func buildTaskMember(id, role string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"role": role,
|
||||
"type": inferTaskMemberType(id),
|
||||
}
|
||||
}
|
||||
|
||||
// parseTaskTime converts a flexible time string into the Task API due/start object format.
|
||||
func parseTaskTime(timeStr string) (map[string]interface{}, error) {
|
||||
var msTs string
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
|
||||
// snapDay aligns to start-of-day or end-of-day based on hint.
|
||||
snapDay := func(t time.Time) time.Time {
|
||||
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
|
||||
if isRelativeTime(timeStr) {
|
||||
t, err := parseRelativeTime(timeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.HasSuffix(timeStr, "d") || strings.HasSuffix(timeStr, "w") {
|
||||
msTs = fmt.Sprintf("%d", snapDay(t).Unix()*1000)
|
||||
} else {
|
||||
msTs = fmt.Sprintf("%d", t.Unix()*1000)
|
||||
}
|
||||
} else {
|
||||
parsedTs, err := common.ParseTime(timeStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sec int64
|
||||
fmt.Sscanf(parsedTs, "%d", &sec)
|
||||
msTs = fmt.Sprintf("%d", sec*1000)
|
||||
}
|
||||
|
||||
// Determine if it's an all-day event based on the input format
|
||||
isAllDay := false
|
||||
// YYYY-MM-DD or relative like +2d typically mean all-day
|
||||
if len(timeStr) == 10 && strings.Count(timeStr, "-") == 2 {
|
||||
isAllDay = true
|
||||
} else if strings.HasPrefix(timeStr, "+") && (strings.HasSuffix(timeStr, "d") || strings.HasSuffix(timeStr, "w")) {
|
||||
isAllDay = true
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"timestamp": msTs,
|
||||
"is_all_day": isAllDay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractTasklistGuid extracts the GUID from an applink URL or returns the string if it's already an ID.
|
||||
func extractTasklistGuid(input string) string {
|
||||
input = strings.TrimSpace(input)
|
||||
if strings.HasPrefix(input, "http") {
|
||||
u, err := url.Parse(input)
|
||||
if err == nil {
|
||||
guid := u.Query().Get("guid")
|
||||
if guid != "" {
|
||||
return guid
|
||||
}
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// extractTaskGuid extracts a task GUID from either a raw GUID or a Feishu task
|
||||
// applink URL (e.g. ".../client/todo/task?guid=..."). The URL query parameter
|
||||
// is always named "guid" for both tasks and tasklists, so we delegate to the
|
||||
// shared parsing logic.
|
||||
func extractTaskGuid(input string) string {
|
||||
return extractTasklistGuid(input)
|
||||
}
|
||||
|
||||
func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
body := make(map[string]interface{})
|
||||
|
||||
// Handle generic JSON payload if provided
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit flags override generic data
|
||||
if summary := runtime.Str("summary"); summary != "" {
|
||||
body["summary"] = summary
|
||||
}
|
||||
|
||||
if desc := runtime.Str("description"); desc != "" {
|
||||
body["description"] = desc
|
||||
}
|
||||
|
||||
var members []map[string]interface{}
|
||||
if assignee := runtime.Str("assignee"); assignee != "" {
|
||||
members = append(members, buildTaskMember(assignee, "assignee"))
|
||||
}
|
||||
if follower := runtime.Str("follower"); follower != "" {
|
||||
members = append(members, buildTaskMember(follower, "follower"))
|
||||
}
|
||||
if len(members) > 0 {
|
||||
body["members"] = members
|
||||
}
|
||||
|
||||
if tasklistId := runtime.Str("tasklist-id"); tasklistId != "" {
|
||||
guid := extractTasklistGuid(tasklistId)
|
||||
body["tasklists"] = []map[string]interface{}{
|
||||
{
|
||||
"tasklist_guid": guid,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if dueStr := runtime.Str("due"); dueStr != "" {
|
||||
dueObj, err := parseTaskTime(dueStr)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
|
||||
}
|
||||
body["due"] = dueObj
|
||||
}
|
||||
|
||||
if idempotencyKey := runtime.Str("idempotency-key"); idempotencyKey != "" {
|
||||
body["client_token"] = idempotencyKey
|
||||
}
|
||||
|
||||
summary, _ := body["summary"].(string)
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var CreateTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+create",
|
||||
Description: "create a task",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "assignee", Desc: "task assignee id added during create; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "follower", Desc: "task follower id added during create; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "tasklist-id", Desc: "tasklist id or applink URL"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency"},
|
||||
{Name: "data", Desc: "JSON payload for creating task"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskCreateBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskCreateBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task created successfully!\n")
|
||||
fmt.Fprintf(w, "Summary: %s\n", body["summary"])
|
||||
if guid != "" {
|
||||
fmt.Fprintf(w, "Task ID: %s\n", guid)
|
||||
}
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// Shortcuts returns all shortcuts for task and tasklist domain.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
CreateTask,
|
||||
UpdateTask,
|
||||
SetAncestorTask,
|
||||
CommentTask,
|
||||
CompleteTask,
|
||||
ReopenTask,
|
||||
AssignTask,
|
||||
FollowersTask,
|
||||
ReminderTask,
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
UploadAttachmentTask,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
AddTaskToTasklist,
|
||||
MembersTasklist,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var AssignTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+assign",
|
||||
Description: "assign or remove task members",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "add", Desc: "comma-separated assignee IDs to add; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "remove", Desc: "comma-separated assignee IDs to remove; use open_id (ou_xxx) when assignee is user, use app id (cli_xxx) when assignee is app"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildMembersBody(removeStr, "assignee", "")
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
return d
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var lastData map[string]interface{}
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastData = data
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildMembersBody(removeStr, "assignee", "")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastData = data
|
||||
}
|
||||
|
||||
task, _ := lastData["task"].(map[string]interface{})
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": taskId,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task assignes updated successfully!\n")
|
||||
fmt.Fprintf(w, "Task ID: %s\n", taskId)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
fmt.Fprintf(w, "Current Assignes: %d\n", len(members))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildMembersBody(idsStr, role, clientToken string) map[string]interface{} {
|
||||
ids := strings.Split(idsStr, ",")
|
||||
var members []map[string]interface{}
|
||||
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, buildTaskMember(id, role))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"members": members,
|
||||
}
|
||||
|
||||
if clientToken != "" {
|
||||
body["client_token"] = clientToken
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
|
||||
// nor --remove yields a typed validation error (exit 2) before any API call.
|
||||
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
|
||||
// 200 with an unparseable body surfaces a typed internal invalid_response
|
||||
// error (exit 5).
|
||||
func TestAssignTask_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/add_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
|
||||
// parse arm: with only --remove set, the add arm is skipped and the
|
||||
// remove_members POST returns a 200 with an unparseable body, which must
|
||||
// surface a typed internal invalid_response error (exit 5).
|
||||
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := AssignTask
|
||||
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMembersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids and token", t, func() {
|
||||
body := buildMembersBody("u1, u2 , ", "assignee", "token1")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(body["client_token"], convey.ShouldEqual, "token1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers app assignee members from cli prefix", t, func() {
|
||||
body := buildMembersBody("cli_bot_1", "assignee", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 1)
|
||||
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "assignee")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers mixed member types in one list", t, func() {
|
||||
body := buildMembersBody("ou_user_1, cli_bot_1", "assignee", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
convey.So(members[1]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildTaskCreateBodySupportsAssigneeAndFollower(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("summary", "", "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().String("due", "", "")
|
||||
cmd.Flags().String("tasklist-id", "", "")
|
||||
cmd.Flags().String("idempotency-key", "", "")
|
||||
cmd.Flags().String("data", "", "")
|
||||
_ = cmd.Flags().Set("summary", "bot task")
|
||||
_ = cmd.Flags().Set("assignee", "cli_bot_xxx")
|
||||
_ = cmd.Flags().Set("follower", "ou_follower_xxx")
|
||||
|
||||
runtime := &common.RuntimeContext{Cmd: cmd}
|
||||
body, err := buildTaskCreateBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("buildTaskCreateBody() error = %v", err)
|
||||
}
|
||||
|
||||
members := body["members"].([]map[string]interface{})
|
||||
if len(members) != 2 {
|
||||
t.Fatalf("members len = %d, want 2", len(members))
|
||||
}
|
||||
if got := members[0]["type"]; got != "app" {
|
||||
t.Fatalf("member[0] type = %v, want app", got)
|
||||
}
|
||||
if got := members[0]["role"]; got != "assignee" {
|
||||
t.Fatalf("member[0] role = %v, want assignee", got)
|
||||
}
|
||||
if got := members[1]["type"]; got != "user" {
|
||||
t.Fatalf("member[1] type = %v, want user", got)
|
||||
}
|
||||
if got := members[1]["role"]; got != "follower" {
|
||||
t.Fatalf("member[1] role = %v, want follower", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTaskCreateBody_StructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
summary string
|
||||
due string
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "invalid JSON data returns validation error",
|
||||
data: "not-json",
|
||||
summary: "test",
|
||||
wantSubstr: "--data must be a valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "missing summary returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
wantSubstr: "task summary is required",
|
||||
},
|
||||
{
|
||||
name: "invalid due time returns validation error",
|
||||
data: "",
|
||||
summary: "test task",
|
||||
due: "not-a-valid-time",
|
||||
wantSubstr: "failed to parse due time",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("data", tt.data, "")
|
||||
cmd.Flags().String("summary", tt.summary, "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("assignee", "", "")
|
||||
cmd.Flags().String("follower", "", "")
|
||||
cmd.Flags().String("due", tt.due, "")
|
||||
cmd.Flags().String("tasklist-id", "", "")
|
||||
cmd.Flags().String("idempotency-key", "", "")
|
||||
|
||||
runtime := &common.RuntimeContext{Cmd: cmd}
|
||||
_, err := buildTaskCreateBody(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) returned !ok", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSubstr) {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTaskUpdateBody_StructuredErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
summary string
|
||||
due string
|
||||
wantSubstr string
|
||||
}{
|
||||
{
|
||||
name: "invalid JSON data returns validation error",
|
||||
data: "not-json",
|
||||
summary: "",
|
||||
due: "",
|
||||
wantSubstr: "--data must be a valid JSON object",
|
||||
},
|
||||
{
|
||||
name: "no fields to update returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
due: "",
|
||||
wantSubstr: "no fields to update",
|
||||
},
|
||||
{
|
||||
name: "invalid due time returns validation error",
|
||||
data: "",
|
||||
summary: "",
|
||||
due: "not-a-valid-time",
|
||||
wantSubstr: "failed to parse due time",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().String("data", tt.data, "")
|
||||
cmd.Flags().String("summary", tt.summary, "")
|
||||
cmd.Flags().String("description", "", "")
|
||||
cmd.Flags().String("due", tt.due, "")
|
||||
|
||||
runtime := &common.RuntimeContext{Cmd: cmd}
|
||||
_, err := buildTaskUpdateBody(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(%T) returned !ok", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantSubstr) {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), tt.wantSubstr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var CommentTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+comment",
|
||||
Description: "add a comment to a task",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:comment:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "content", Desc: "comment content", Required: true},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := map[string]interface{}{
|
||||
"content": runtime.Str("content"),
|
||||
"resource_id": runtime.Str("task-id"),
|
||||
"resource_type": "task",
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/comments").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := map[string]interface{}{
|
||||
"content": runtime.Str("content"),
|
||||
"resource_id": runtime.Str("task-id"),
|
||||
"resource_type": "task",
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/comments", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
comment, _ := data["comment"].(map[string]interface{})
|
||||
id, _ := comment["id"].(string)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"id": id,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Comment added successfully!\n")
|
||||
if id != "" {
|
||||
fmt.Fprintf(w, "Comment ID: %s\n", id)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// CompleteTask marks a task as complete and skips the PATCH call if already completed.
|
||||
var CompleteTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+complete",
|
||||
Description: "mark a task as complete",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildCompleteBody()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
Desc("get current task status").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Desc("complete task if not completed").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var data map[string]interface{}
|
||||
|
||||
// 1. Get current task status
|
||||
getData, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskData, _ := getData["task"].(map[string]interface{})
|
||||
completedAtStr, _ := taskData["completed_at"].(string)
|
||||
|
||||
// 2. If already completed, directly return success
|
||||
if completedAtStr != "" && completedAtStr != "0" {
|
||||
data = getData
|
||||
} else {
|
||||
// 3. Complete the task
|
||||
body := buildCompleteBody()
|
||||
data, err = callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
summary, _ := task["summary"].(string)
|
||||
fmt.Fprintf(w, "✅ Task completed successfully!\n")
|
||||
if guid != "" {
|
||||
fmt.Fprintf(w, "Task ID: %s\n", guid)
|
||||
}
|
||||
if summary != "" {
|
||||
fmt.Fprintf(w, "Summary: %s\n", summary)
|
||||
}
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildCompleteBody() map[string]interface{} {
|
||||
completedAt := fmt.Sprintf("%d", time.Now().Unix()*1000)
|
||||
return map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"completed_at": completedAt,
|
||||
},
|
||||
"update_fields": []string{"completed_at"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestCompleteTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskId string
|
||||
isCompleted bool
|
||||
formatFlag string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "task already completed",
|
||||
taskId: "task-123",
|
||||
isCompleted: true,
|
||||
formatFlag: "pretty",
|
||||
expectedOutput: []string{
|
||||
"✅ Task completed successfully!",
|
||||
"Task ID: task-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task not completed",
|
||||
taskId: "task-456",
|
||||
isCompleted: false,
|
||||
formatFlag: "pretty",
|
||||
expectedOutput: []string{
|
||||
"✅ Task completed successfully!",
|
||||
"Task ID: task-456",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "task not completed json format",
|
||||
taskId: "task-789",
|
||||
isCompleted: false,
|
||||
formatFlag: "json",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-789"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
completedAt := "0"
|
||||
if tt.isCompleted {
|
||||
completedAt = "1775174400000"
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks/" + tt.taskId,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": tt.taskId,
|
||||
"summary": "Test Task " + tt.taskId,
|
||||
"completed_at": completedAt,
|
||||
"url": "https://example.com/" + tt.taskId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !tt.isCompleted {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/task/v2/tasks/" + tt.taskId,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": tt.taskId,
|
||||
"summary": "Test Task " + tt.taskId,
|
||||
"completed_at": "1775174400000",
|
||||
"url": "https://example.com/" + tt.taskId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, CompleteTask, []string{"+complete", "--task-id", tt.taskId, "--format", tt.formatFlag, "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outNorm, expected) && !strings.Contains(out, expected) {
|
||||
t.Errorf("output missing expected string (%s), got: %s", expected, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
)
|
||||
|
||||
// wrapTaskNetworkErr returns err unchanged when it is already a typed errs.*
|
||||
// error (preserving its subtype / code / log_id from the runtime boundary),
|
||||
// and only wraps a raw, unclassified error as a transport-level network error.
|
||||
func wrapTaskNetworkErr(err error, format string, args ...any) error {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
|
||||
}
|
||||
|
||||
// taskInputStatError maps a FileIO.Stat/Open error for input file validation
|
||||
// to a typed validation error:
|
||||
// - Path validation failures → "unsafe file path: ..."
|
||||
// - Other errors → readMsg prefix (default "cannot read file")
|
||||
//
|
||||
// param names the input flag/path field that failed (for example "--file").
|
||||
// Pass an optional readMsg to override the non-path-validation message prefix,
|
||||
// mirroring the shared input-stat helper so call-site context is preserved.
|
||||
func taskInputStatError(err error, param string, readMsg ...string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
|
||||
if param != "" {
|
||||
validationErr = validationErr.WithParam(param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
msg := "cannot read file"
|
||||
if len(readMsg) > 0 && readMsg[0] != "" {
|
||||
msg = readMsg[0]
|
||||
}
|
||||
validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: %s", msg, err).WithCause(err)
|
||||
if param != "" {
|
||||
validationErr = validationErr.WithParam(param)
|
||||
}
|
||||
return validationErr
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestTaskInputStatError(t *testing.T) {
|
||||
t.Run("nil error returns nil", func(t *testing.T) {
|
||||
if err := taskInputStatError(nil, "--file"); err != nil {
|
||||
t.Errorf("taskInputStatError(nil) = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("path validation failure maps to unsafe file path", func(t *testing.T) {
|
||||
err := taskInputStatError(fmt.Errorf("bad: %w", fileio.ErrPathValidation), "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitValidation {
|
||||
t.Errorf("exit = %d, want %d", output.ExitCodeOf(err), output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsafe file path") {
|
||||
t.Errorf("message = %q, want 'unsafe file path'", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic error uses readMsg prefix", func(t *testing.T) {
|
||||
err := taskInputStatError(errors.New("permission denied"), "--file", "cannot access file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot access file") {
|
||||
t.Errorf("message = %q, want 'cannot access file' prefix", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default prefix when no readMsg", func(t *testing.T) {
|
||||
err := taskInputStatError(errors.New("boom"), "--file")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot read file") {
|
||||
t.Errorf("message = %q, want default 'cannot read file'", err.Error())
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Errorf("param = %q, want --file", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWrapTaskNetworkErr(t *testing.T) {
|
||||
// wrapTaskNetworkErr is only ever called inside an `if err != nil` guard
|
||||
// (DoAPIStream failure), mirroring drive's wrapDriveNetworkErr, so it does
|
||||
// not special-case a nil cause.
|
||||
t.Run("untyped cause becomes typed network error wrapping the cause", func(t *testing.T) {
|
||||
cause := errors.New("dial timeout")
|
||||
err := wrapTaskNetworkErr(cause, "upload failed")
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) {
|
||||
t.Fatalf("err = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Error("expected the original cause to be wrapped (errors.Is)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("already-typed cause is passed through unchanged", func(t *testing.T) {
|
||||
typed := errs.NewAPIError(errs.SubtypeNotFound, "missing")
|
||||
err := wrapTaskNetworkErr(typed, "upload failed")
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("err = %T, want the original *errs.APIError passed through", err)
|
||||
}
|
||||
if ae.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("subtype = %q, want %q (not re-wrapped as network)", ae.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var FollowersTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+followers",
|
||||
Description: "manage task followers",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "add", Desc: "comma-separated follower IDs to add; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "remove", Desc: "comma-separated follower IDs to remove; use open_id (ou_xxx) when follower is user, use app id (cli_xxx) when follower is app"},
|
||||
{Name: "idempotency-key", Desc: "client token for idempotency (used for add_members)"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/add_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildFollowersBody(removeStr, "")
|
||||
d.POST("/open-apis/task/v2/tasks/" + taskId + "/remove_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
return d
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
var lastData map[string]interface{}
|
||||
|
||||
if addStr := runtime.Str("add"); addStr != "" {
|
||||
body := buildFollowersBody(addStr, runtime.Str("idempotency-key"))
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastData = data
|
||||
}
|
||||
|
||||
if removeStr := runtime.Str("remove"); removeStr != "" {
|
||||
body := buildFollowersBody(removeStr, "")
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastData = data
|
||||
}
|
||||
|
||||
task, _ := lastData["task"].(map[string]interface{})
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": taskId,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task followers updated successfully!\n")
|
||||
fmt.Fprintf(w, "Task ID: %s\n", taskId)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildFollowersBody(idsStr string, clientToken string) map[string]interface{} {
|
||||
ids := strings.Split(idsStr, ",")
|
||||
var members []map[string]interface{}
|
||||
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, buildTaskMember(id, "follower"))
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"members": members,
|
||||
}
|
||||
|
||||
if clientToken != "" {
|
||||
body["client_token"] = clientToken
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
// TestFollowersTask_RequiresAddOrRemove covers the Validate guard: neither
|
||||
// --add nor --remove yields a typed validation error (exit 2) before any API
|
||||
// call.
|
||||
func TestFollowersTask_RequiresAddOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := FollowersTask
|
||||
args := []string{"+followers", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFollowersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids and token", t, func() {
|
||||
body := buildFollowersBody("u1, u2 , ", "token1")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(body["client_token"], convey.ShouldEqual, "token1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers app followers", t, func() {
|
||||
body := buildFollowersBody("cli_bot_1", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 1)
|
||||
convey.So(members[0]["id"], convey.ShouldEqual, "cli_bot_1")
|
||||
convey.So(members[0]["role"], convey.ShouldEqual, "follower")
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
|
||||
convey.Convey("Build infers mixed follower types in one list", t, func() {
|
||||
body := buildFollowersBody("ou_user_1, cli_bot_1", "")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
convey.So(members[0]["type"], convey.ShouldEqual, "user")
|
||||
convey.So(members[1]["type"], convey.ShouldEqual, "app")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// GetMyTasks lists tasks assigned to the current user.
|
||||
var GetMyTasks = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+get-my-tasks",
|
||||
Description: "List tasks assigned to me",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search for tasks by summary (exact match first, then partial match)"},
|
||||
{Name: "complete", Type: "bool", Desc: "if true, query completed tasks;if false, query incompleted tasks; if not provided, both completed and incompleted tasks are queried."},
|
||||
{Name: "created_at", Desc: "query tasks created after this time (date/relative/ms)"},
|
||||
{Name: "due-start", Desc: "query tasks with due date after this time (date/relative/ms)"},
|
||||
{Name: "due-end", Desc: "query tasks with due date before this time (date/relative/ms)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40 with --page-all)"},
|
||||
{Name: "page-token", Desc: "start from the specified page token"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
|
||||
params := map[string]interface{}{
|
||||
"type": "my_tasks",
|
||||
"user_id_type": "open_id",
|
||||
"page_size": 50,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("complete") {
|
||||
params["completed"] = runtime.Bool("complete")
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
return d.GET("/open-apis/task/v2/tasks").Params(params)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
startTime := time.Now()
|
||||
|
||||
params := map[string]interface{}{
|
||||
"type": "my_tasks",
|
||||
"user_id_type": "open_id",
|
||||
"page_size": 50,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("complete") {
|
||||
if runtime.Bool("complete") {
|
||||
params["completed"] = "true"
|
||||
} else {
|
||||
params["completed"] = "false"
|
||||
}
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
// parse time flags to ms timestamp if provided
|
||||
var createdAfterMs, dueStartMs, dueEndMs int64
|
||||
if createdStr := runtime.Str("created_at"); createdStr != "" {
|
||||
tStr, err := parseTimeFlagSec(createdStr, "start")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid created_at: %v", err).WithParam("--created_at")
|
||||
}
|
||||
createdAfterMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
createdAfterMs *= 1000 // Convert sec to ms
|
||||
}
|
||||
|
||||
if dueStartStr := runtime.Str("due-start"); dueStartStr != "" {
|
||||
tStr, err := parseTimeFlagSec(dueStartStr, "start")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-start: %v", err).WithParam("--due-start")
|
||||
}
|
||||
dueStartMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
dueStartMs *= 1000
|
||||
}
|
||||
|
||||
if dueEndStr := runtime.Str("due-end"); dueEndStr != "" {
|
||||
tStr, err := parseTimeFlagSec(dueEndStr, "end")
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due-end: %v", err).WithParam("--due-end")
|
||||
}
|
||||
dueEndMs, _ = strconv.ParseInt(tStr, 10, 64)
|
||||
dueEndMs *= 1000
|
||||
}
|
||||
|
||||
var allItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
pageCount := 0
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = 40
|
||||
}
|
||||
|
||||
for {
|
||||
pageCount++
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
allItems = append(allItems, itemsRaw...)
|
||||
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
lastHasMore = hasMore
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
|
||||
if !hasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
|
||||
if pageCount >= pageLimit {
|
||||
break
|
||||
}
|
||||
|
||||
// Set page_token for next iteration
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
var filteredItems []map[string]interface{}
|
||||
|
||||
for _, itemRaw := range allItems {
|
||||
item, ok := itemRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply client-side filtering for created_at, due-start, due-end
|
||||
// because the API might not support these filters natively in GET /v2/tasks
|
||||
if createdAfterMs > 0 {
|
||||
createdAtStr, _ := item["created_at"].(string)
|
||||
createdAtMs, _ := strconv.ParseInt(createdAtStr, 10, 64)
|
||||
if createdAtMs < createdAfterMs {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if dueStartMs > 0 || dueEndMs > 0 {
|
||||
dueObj, _ := item["due"].(map[string]interface{})
|
||||
if dueObj == nil {
|
||||
// If due filtering is requested but task has no due date, filter it out
|
||||
continue
|
||||
}
|
||||
dueTimeStr, _ := dueObj["timestamp"].(string)
|
||||
dueTimeMs, _ := strconv.ParseInt(dueTimeStr, 10, 64)
|
||||
|
||||
if dueStartMs > 0 && dueTimeMs < dueStartMs {
|
||||
continue
|
||||
}
|
||||
if dueEndMs > 0 && dueTimeMs > dueEndMs {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
|
||||
// Apply query filtering if provided
|
||||
if query := runtime.Str("query"); query != "" {
|
||||
var exactMatches []map[string]interface{}
|
||||
var partialMatches []map[string]interface{}
|
||||
for _, item := range filteredItems {
|
||||
summary, _ := item["summary"].(string)
|
||||
if summary == query {
|
||||
exactMatches = append(exactMatches, item)
|
||||
} else if strings.Contains(summary, query) {
|
||||
partialMatches = append(partialMatches, item)
|
||||
}
|
||||
}
|
||||
|
||||
if len(exactMatches) > 0 {
|
||||
filteredItems = exactMatches
|
||||
} else {
|
||||
filteredItems = partialMatches
|
||||
}
|
||||
}
|
||||
|
||||
var outputItems []interface{}
|
||||
for _, item := range filteredItems {
|
||||
urlVal, _ := item["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
completed, completedAt := taskCompletionState(item)
|
||||
outputItem := map[string]interface{}{
|
||||
"guid": item["guid"],
|
||||
"summary": item["summary"],
|
||||
"url": urlVal,
|
||||
"completed": completed,
|
||||
}
|
||||
if createdAtStr, ok := item["created_at"].(string); ok {
|
||||
if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil {
|
||||
outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if !completedAt.IsZero() {
|
||||
outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339)
|
||||
}
|
||||
if dueObj, ok := item["due"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := dueObj["timestamp"].(string); ok {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
outputItem["due_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
outputItems = append(outputItems, outputItem)
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": outputItems,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(filteredItems) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
return
|
||||
}
|
||||
|
||||
for i, item := range filteredItems {
|
||||
guid, _ := item["guid"].(string)
|
||||
summary, _ := item["summary"].(string)
|
||||
urlVal, _ := item["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
completed, completedAt := taskCompletionState(item)
|
||||
|
||||
var dueTimeStr string
|
||||
if dueObj, ok := item["due"].(map[string]interface{}); ok {
|
||||
if tsStr, ok := dueObj["timestamp"].(string); ok {
|
||||
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
|
||||
dueTimeStr = time.UnixMilli(ts).Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var createdDateStr string
|
||||
if createdStr, ok := item["created_at"].(string); ok {
|
||||
if ts, err := strconv.ParseInt(createdStr, 10, 64); err == nil {
|
||||
createdDateStr = time.UnixMilli(ts).Local().Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, summary)
|
||||
fmt.Fprintf(w, " ID: %s\n", guid)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintf(w, " Completed: %t\n", completed)
|
||||
if !completedAt.IsZero() {
|
||||
fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
if dueTimeStr != "" {
|
||||
fmt.Fprintf(w, " Due: %s\n", dueTimeStr)
|
||||
}
|
||||
if createdDateStr != "" {
|
||||
fmt.Fprintf(w, " Created: %s\n", createdDateStr)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
if lastHasMore && lastPageToken != "" && !runtime.Cmd.Flags().Changed("page-limit") && !runtime.Cmd.Flags().Changed("page-all") {
|
||||
fmt.Fprintf(w, "\n[Warning] Too many tasks! Stopped after fetching %d pages.\n", pageLimit)
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\nTotal execution time: %v\n", time.Since(startTime))
|
||||
})
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func taskCompletionState(item map[string]interface{}) (bool, time.Time) {
|
||||
completedAtStr, _ := item["completed_at"].(string)
|
||||
if completedAtStr == "" || completedAtStr == "0" {
|
||||
return false, time.Time{}
|
||||
}
|
||||
ts, err := strconv.ParseInt(completedAtStr, 10, 64)
|
||||
if err != nil {
|
||||
return false, time.Time{}
|
||||
}
|
||||
return true, time.UnixMilli(ts)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestGetMyTasks_LocalTimeFormatting(t *testing.T) {
|
||||
tsMs := int64(1775174400000)
|
||||
tsStr := strconv.FormatInt(tsMs, 10)
|
||||
expectedDueTimeStr := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
|
||||
expectedCreatedDateStr := time.UnixMilli(tsMs).Local().Format("2006-01-02")
|
||||
expectedRFC3339 := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
formatFlag string
|
||||
pageToken string
|
||||
stubURL string
|
||||
expectedOutput []string
|
||||
}{
|
||||
{
|
||||
name: "pretty format",
|
||||
formatFlag: "pretty",
|
||||
stubURL: "/open-apis/task/v2/tasks",
|
||||
expectedOutput: []string{
|
||||
"Due: " + expectedDueTimeStr,
|
||||
"Created: " + expectedCreatedDateStr,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json format",
|
||||
formatFlag: "json",
|
||||
stubURL: "/open-apis/task/v2/tasks",
|
||||
expectedOutput: []string{
|
||||
`"due_at": "` + expectedRFC3339 + `"`,
|
||||
`"created_at": "` + expectedRFC3339 + `"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "start from page token",
|
||||
formatFlag: "json",
|
||||
pageToken: "pt_001",
|
||||
stubURL: "page_token=pt_001",
|
||||
expectedOutput: []string{
|
||||
`"guid": "task-123"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: tt.stubURL,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Test Task",
|
||||
"created_at": tsStr,
|
||||
"due": map[string]interface{}{
|
||||
"timestamp": tsStr,
|
||||
},
|
||||
"url": "https://example.com/task-123",
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+get-my-tasks", "--format", tt.formatFlag, "--as", "bot"}
|
||||
if tt.pageToken != "" {
|
||||
args = append(args, "--page-token", tt.pageToken)
|
||||
}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
|
||||
for _, expected := range tt.expectedOutput {
|
||||
if !strings.Contains(outNorm, expected) && !strings.Contains(out, expected) {
|
||||
t.Errorf("output missing expected string (%s), got: %s", expected, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) {
|
||||
tsMs := int64(1775174400000)
|
||||
tsStr := strconv.FormatInt(tsMs, 10)
|
||||
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339)
|
||||
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-open",
|
||||
"summary": "Open Task",
|
||||
"completed_at": "0",
|
||||
"url": "https://example.com/task-open",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"guid": "task-done",
|
||||
"summary": "Done Task",
|
||||
"completed_at": tsStr,
|
||||
"url": "https://example.com/task-done",
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`)
|
||||
for _, expected := range []string{
|
||||
`"guid": "task-open"`,
|
||||
`"completed": false`,
|
||||
`"guid": "task-done"`,
|
||||
`"completed": true`,
|
||||
`"completed_at": "` + expectedCompletedAt + `"`,
|
||||
} {
|
||||
if !strings.Contains(outNorm, expected) {
|
||||
t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) {
|
||||
tsMs := int64(1775174400000)
|
||||
tsStr := strconv.FormatInt(tsMs, 10)
|
||||
expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04")
|
||||
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-open",
|
||||
"summary": "Open Task",
|
||||
"completed_at": "0",
|
||||
"url": "https://example.com/task-open",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"guid": "task-done",
|
||||
"summary": "Done Task",
|
||||
"completed_at": tsStr,
|
||||
"url": "https://example.com/task-done",
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, expected := range []string{
|
||||
"[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n",
|
||||
"[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n",
|
||||
} {
|
||||
if !strings.Contains(out, expected) {
|
||||
t.Fatalf("output missing expected string (%s), got: %s", expected, out)
|
||||
}
|
||||
}
|
||||
if count := strings.Count(out, "Completed At:"); count != 1 {
|
||||
t.Fatalf("Completed At count = %d, want 1; output: %s", count, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in
|
||||
// Execute (--created_at / --due-start / --due-end). The parse runs before any
|
||||
// API call, so a malformed value deterministically surfaces a typed
|
||||
// *errs.ValidationError (exit 2) regardless of credentials — the command runs
|
||||
// as user with a throwaway token. Each error carries the corresponding --flag
|
||||
// param so the caller can point at the offending input.
|
||||
func TestGetMyTasks_InvalidTimeFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flag string
|
||||
wantParam string
|
||||
}{
|
||||
{name: "created_at", flag: "--created_at", wantParam: "--created_at"},
|
||||
{name: "due-start", flag: "--due-start", wantParam: "--due-start"},
|
||||
{name: "due-end", flag: "--due-end", wantParam: "--due-end"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := GetMyTasks
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+get-my-tasks", tt.flag, "not-a-time", "--as", "user"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error for %s, got nil", tt.flag)
|
||||
}
|
||||
|
||||
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 != tt.wantParam {
|
||||
t.Errorf("param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
relatedTasksDefaultPageLimit = 20
|
||||
relatedTasksMaxPageLimit = 40
|
||||
relatedTasksPageSize = 100
|
||||
)
|
||||
|
||||
var GetRelatedTasks = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+get-related-tasks",
|
||||
Description: "list tasks related to me",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
|
||||
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
|
||||
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
params["completed"] = false
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/task/v2/task_v2/list_related_task").
|
||||
Params(params)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": "open_id",
|
||||
"page_size": relatedTasksPageSize,
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
|
||||
params["completed"] = "false"
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = relatedTasksDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
if pageLimit > relatedTasksMaxPageLimit {
|
||||
pageLimit = relatedTasksMaxPageLimit
|
||||
}
|
||||
|
||||
var allItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/task_v2/list_related_task", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items, _ := data["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
params["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
userOpenID := runtime.UserOpenId()
|
||||
filtered := make([]map[string]interface{}, 0, len(allItems))
|
||||
for _, item := range allItems {
|
||||
task, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if runtime.Bool("created-by-me") {
|
||||
creator, _ := task["creator"].(map[string]interface{})
|
||||
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, outputRelatedTask(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": filtered,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
|
||||
if len(filtered) == 0 {
|
||||
fmt.Fprintln(w, "No related tasks found.")
|
||||
return
|
||||
}
|
||||
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
|
||||
members, _ := task["members"].([]interface{})
|
||||
for _, member := range members {
|
||||
memberObj, _ := member.(map[string]interface{})
|
||||
role, _ := memberObj["role"].(string)
|
||||
id, _ := memberObj["id"].(string)
|
||||
if strings.EqualFold(role, "follower") && id == userOpenID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestTaskFollowedBy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
userOpenID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "contains follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
map[string]interface{}{"id": "ou_2", "role": "follower"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_2",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "missing follower",
|
||||
task: map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_1", "role": "assignee"},
|
||||
},
|
||||
},
|
||||
userOpenID: "ou_3",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := taskFollowedBy(tt.task, tt.userOpenID)
|
||||
if got != tt.want {
|
||||
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with page token and incomplete filter",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("include-complete", "false")
|
||||
_ = cmd.Flags().Set("page-token", "pt_001")
|
||||
},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
|
||||
},
|
||||
{
|
||||
name: "default query params",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Bool("include-complete", true, "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
out := GetRelatedTasks.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRelatedTasks_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json created by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "done",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123",
|
||||
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
|
||||
},
|
||||
{
|
||||
name: "pretty pagination followed by me",
|
||||
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/task_v2/list_related_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": true,
|
||||
"page_token": "pt_2",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
"summary": "Task One",
|
||||
"url": "https://example.com/task-1",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "page_token=pt_2",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "task-2",
|
||||
"summary": "Task Two",
|
||||
"url": "https://example.com/task-2",
|
||||
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Task One", "Task Two"},
|
||||
},
|
||||
}
|
||||
|
||||
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 := GetRelatedTasks
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func splitAndTrimCSV(input string) []string {
|
||||
parts := strings.Split(input, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTimeRangeMillis(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startMillis, endMillis string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
hasStart = true
|
||||
startMillis = startSec + "000"
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endMillis = endSec + "000"
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startMillis, endMillis, nil
|
||||
}
|
||||
|
||||
func parseTimeRangeRFC3339(input string) (string, string, error) {
|
||||
if strings.TrimSpace(input) == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
parts := strings.SplitN(input, ",", 2)
|
||||
startInput := strings.TrimSpace(parts[0])
|
||||
endInput := ""
|
||||
if len(parts) == 2 {
|
||||
endInput = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
var startTime, endTime string
|
||||
var startSecInt, endSecInt int64
|
||||
var hasStart, hasEnd bool
|
||||
if startInput != "" {
|
||||
startSec, err := parseTimeFlagSec(startInput, "start")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid start timestamp: %v", err)
|
||||
}
|
||||
hasStart = true
|
||||
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if endInput != "" {
|
||||
endSec, err := parseTimeFlagSec(endInput, "end")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
|
||||
if err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid end timestamp: %v", err)
|
||||
}
|
||||
hasEnd = true
|
||||
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
|
||||
}
|
||||
if hasStart && hasEnd && startSecInt > endSecInt {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "start time must be earlier than or equal to end time")
|
||||
}
|
||||
return startTime, endTime, nil
|
||||
}
|
||||
|
||||
func formatTaskDateTimeMillis(msStr string) string {
|
||||
if msStr == "" || msStr == "0" {
|
||||
return ""
|
||||
}
|
||||
ms, err := strconv.ParseInt(msStr, 10, 64)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return time.UnixMilli(ms).Local().Format(time.DateTime)
|
||||
}
|
||||
|
||||
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
|
||||
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
|
||||
out["updated_at"] = updated
|
||||
}
|
||||
}
|
||||
if dueObj, ok := task["due"].(map[string]interface{}); ok {
|
||||
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
|
||||
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
|
||||
out["due_at"] = dueAt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
out := map[string]interface{}{
|
||||
"guid": task["guid"],
|
||||
"summary": task["summary"],
|
||||
"description": task["description"],
|
||||
"status": task["status"],
|
||||
"source": task["source"],
|
||||
"mode": task["mode"],
|
||||
"subtask_count": task["subtask_count"],
|
||||
"tasklists": task["tasklists"],
|
||||
"url": urlVal,
|
||||
}
|
||||
if creator, ok := task["creator"].(map[string]interface{}); ok {
|
||||
out["creator"] = creator
|
||||
}
|
||||
if members, ok := task["members"].([]interface{}); ok {
|
||||
out["members"] = members
|
||||
}
|
||||
if createdAt, _ := task["created_at"].(string); createdAt != "" {
|
||||
if created := formatTaskDateTimeMillis(createdAt); created != "" {
|
||||
out["created_at"] = created
|
||||
}
|
||||
}
|
||||
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
|
||||
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
|
||||
out["completed_at"] = completed
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
|
||||
timeRange := map[string]interface{}{}
|
||||
if start != "" {
|
||||
timeRange["start_time"] = start
|
||||
}
|
||||
if end != "" {
|
||||
timeRange["end_time"] = end
|
||||
}
|
||||
if len(timeRange) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]interface{}{key: timeRange}
|
||||
}
|
||||
|
||||
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
|
||||
if strings.TrimSpace(query) != "" {
|
||||
return nil
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s: query is empty and no filter is provided", action)
|
||||
}
|
||||
|
||||
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
|
||||
var b strings.Builder
|
||||
for i, item := range items {
|
||||
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
|
||||
if status, _ := item["status"].(string); status != "" {
|
||||
fmt.Fprintf(&b, " Status: %s\n", status)
|
||||
}
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(&b, " Created: %s\n", created)
|
||||
}
|
||||
if completed, _ := item["completed_at"].(string); completed != "" {
|
||||
fmt.Fprintf(&b, " Completed: %s\n", completed)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(&b, " URL: %s\n", urlVal)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if hasMore && pageToken != "" {
|
||||
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestSplitAndTrimCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
|
||||
{name: "empty input", input: "", want: []string{}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := splitAndTrimCSV(tt.input)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputTaskSummary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "with timestamps and due",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "summary",
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"created_at": "1775174400000",
|
||||
"due": map[string]interface{}{
|
||||
"timestamp": "1775174400000",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with completed and updated",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "done",
|
||||
"url": "https://example.com/task-456",
|
||||
"completed_at": "1775174400000",
|
||||
"updated_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := outputTaskSummary(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected summary output: %#v", got)
|
||||
}
|
||||
if got["url"] == "" {
|
||||
t.Fatalf("expected url in output, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
for _, tt := range timeTests {
|
||||
t.Run("parse:"+tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeMillis(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
|
||||
}
|
||||
if tt.name == "reversed range fails fast" {
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
|
||||
}
|
||||
if tt.wantStart == "" && start != "" {
|
||||
t.Fatalf("start = %q, want empty", start)
|
||||
}
|
||||
if tt.wantEnd == "" && end != "" {
|
||||
t.Fatalf("end = %q, want empty", end)
|
||||
}
|
||||
if tt.wantStart == "non-empty" && start == "" {
|
||||
t.Fatalf("start should not be empty")
|
||||
}
|
||||
if tt.wantEnd == "non-empty" && end == "" {
|
||||
t.Fatalf("end should not be empty")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
filterTests := []struct {
|
||||
name string
|
||||
query string
|
||||
filter map[string]interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
|
||||
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
|
||||
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
|
||||
}
|
||||
for _, tt := range filterTests {
|
||||
t.Run("filter:"+tt.name, func(t *testing.T) {
|
||||
err := requireSearchFilter(tt.query, tt.filter, "search")
|
||||
if tt.wantErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
|
||||
outputTests := []struct {
|
||||
name string
|
||||
task map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "full related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-123",
|
||||
"summary": "Related Task",
|
||||
"description": "desc",
|
||||
"status": "todo",
|
||||
"source": 1,
|
||||
"mode": 2,
|
||||
"subtask_count": 0,
|
||||
"tasklists": []interface{}{},
|
||||
"url": "https://example.com/task-123&suite_entity_num=t1",
|
||||
"creator": map[string]interface{}{"id": "ou_1"},
|
||||
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
|
||||
"created_at": "1775174400000",
|
||||
"completed_at": "1775174400000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal related task",
|
||||
task: map[string]interface{}{
|
||||
"guid": "task-456",
|
||||
"summary": "Minimal",
|
||||
"url": "https://example.com/task-456",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range outputTests {
|
||||
t.Run("output:"+tt.name, func(t *testing.T) {
|
||||
got := outputRelatedTask(tt.task)
|
||||
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
|
||||
t.Fatalf("unexpected related task output: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
rangeTests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
wantNil bool
|
||||
}{
|
||||
{name: "empty range", start: "", end: "", wantNil: true},
|
||||
{name: "full range", start: "1", end: "2", wantNil: false},
|
||||
}
|
||||
for _, tt := range rangeTests {
|
||||
t.Run("range:"+tt.name, func(t *testing.T) {
|
||||
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
|
||||
if tt.wantNil && got != nil {
|
||||
t.Fatalf("expected nil, got %#v", got)
|
||||
}
|
||||
if !tt.wantNil && got == nil {
|
||||
t.Fatalf("expected range filter, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRelatedTasksPretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []map[string]interface{}
|
||||
hasMore bool
|
||||
pageToken string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "includes next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
|
||||
},
|
||||
hasMore: true,
|
||||
pageToken: "pt_123",
|
||||
wantParts: []string{"Related Task", "Next page token: pt_123"},
|
||||
},
|
||||
{
|
||||
name: "without next token",
|
||||
items: []map[string]interface{}{
|
||||
{"guid": "task-456", "summary": "Another Task"},
|
||||
},
|
||||
hasMore: false,
|
||||
pageToken: "",
|
||||
wantParts: []string{"Another Task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
|
||||
timeTests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
wantStart string
|
||||
wantEnd string
|
||||
}{
|
||||
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
|
||||
{name: "invalid input", input: "bad-time", wantErr: true},
|
||||
{name: "invalid end input", input: "-1d,bad-time", wantErr: true},
|
||||
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
|
||||
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range timeTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := parseTimeRangeRFC3339(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if tt.name == "reversed range fails fast" {
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; error = %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
|
||||
}
|
||||
if tt.wantStart == "rfc3339" {
|
||||
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
|
||||
t.Fatalf("expected RFC3339 start, got %q", start)
|
||||
}
|
||||
} else if start != tt.wantStart {
|
||||
t.Fatalf("unexpected start: %q", start)
|
||||
}
|
||||
if tt.wantEnd == "rfc3339" {
|
||||
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
|
||||
t.Fatalf("expected RFC3339 end, got %q", end)
|
||||
}
|
||||
} else if end != tt.wantEnd {
|
||||
t.Fatalf("unexpected end: %q", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var ReminderTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+reminder",
|
||||
Description: "manage task reminders",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
{Name: "set", Desc: "relative fire minutes to set (e.g. 15m, 1h, 1d)"},
|
||||
{Name: "remove", Type: "bool", Desc: "removes all existing reminders"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Str("set") == "" && !runtime.Bool("remove") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --set or --remove")
|
||||
}
|
||||
if runtime.Str("set") != "" && runtime.Bool("remove") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot specify both --set and --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
|
||||
if runtime.Bool("remove") {
|
||||
d.Desc("1. GET task to find existing reminder IDs").
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Desc("2. POST to remove_reminders with found IDs")
|
||||
} else if setStr := runtime.Str("set"); setStr != "" {
|
||||
d.Desc("1. GET task to check existing reminders").
|
||||
GET("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Desc("2. POST to remove_reminders if any exist").
|
||||
Desc("3. POST to add_reminders").
|
||||
POST("/open-apis/task/v2/tasks/" + taskId + "/add_reminders")
|
||||
}
|
||||
|
||||
return d
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
// First, get the task to find existing reminders
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+taskId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskObj, _ := data["task"].(map[string]interface{})
|
||||
reminders, _ := taskObj["reminders"].([]interface{})
|
||||
|
||||
if runtime.Bool("remove") {
|
||||
if len(reminders) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{"guid": taskId}, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No existing reminders to remove.")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var reminderIds []string
|
||||
for _, r := range reminders {
|
||||
if rMap, ok := r.(map[string]interface{}); ok {
|
||||
if id, ok := rMap["id"].(string); ok {
|
||||
reminderIds = append(reminderIds, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(reminderIds) > 0 {
|
||||
body := map[string]interface{}{
|
||||
"reminder_ids": reminderIds,
|
||||
}
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if setStr := runtime.Str("set"); setStr != "" {
|
||||
// Parse relative time string (e.g. 15m, 1h, 1d, or plain 30)
|
||||
var minutes int
|
||||
var parseErr error
|
||||
|
||||
if strings.HasSuffix(setStr, "m") {
|
||||
minutes, parseErr = strconv.Atoi(strings.TrimSuffix(setStr, "m"))
|
||||
} else if strings.HasSuffix(setStr, "h") {
|
||||
h, e := strconv.Atoi(strings.TrimSuffix(setStr, "h"))
|
||||
if e == nil {
|
||||
minutes = h * 60
|
||||
}
|
||||
parseErr = e
|
||||
} else if strings.HasSuffix(setStr, "d") {
|
||||
d, e := strconv.Atoi(strings.TrimSuffix(setStr, "d"))
|
||||
if e == nil {
|
||||
minutes = d * 24 * 60
|
||||
}
|
||||
parseErr = e
|
||||
} else {
|
||||
// Default to minutes if no suffix
|
||||
minutes, parseErr = strconv.Atoi(setStr)
|
||||
}
|
||||
|
||||
if parseErr != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", parseErr)
|
||||
}
|
||||
|
||||
// If any reminders exist, remove them first
|
||||
if len(reminders) > 0 {
|
||||
var reminderIds []string
|
||||
for _, r := range reminders {
|
||||
if rMap, ok := r.(map[string]interface{}); ok {
|
||||
if id, ok := rMap["id"].(string); ok {
|
||||
reminderIds = append(reminderIds, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(reminderIds) > 0 {
|
||||
body := map[string]interface{}{
|
||||
"reminder_ids": reminderIds,
|
||||
}
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"reminders": []map[string]interface{}{
|
||||
{
|
||||
"relative_fire_minute": minutes,
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_reminders", params, body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
urlVal, _ := taskObj["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": taskId,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task reminders updated successfully!\n")
|
||||
fmt.Fprintf(w, "Task ID: %s\n", taskId)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestReminderTask_RequiresSetOrRemove covers the first Validate guard: neither
|
||||
// --set nor --remove yields a typed validation error (exit 2) before any API
|
||||
// call.
|
||||
func TestReminderTask_RequiresSetOrRemove(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := ReminderTask
|
||||
args := []string{"+reminder", "--task-id", "task-1", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReminderTask_CannotSpecifyBoth covers the second Validate guard: passing
|
||||
// both --set and --remove yields a typed validation error (exit 2).
|
||||
func TestReminderTask_CannotSpecifyBoth(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
s := ReminderTask
|
||||
args := []string{"+reminder", "--task-id", "task-1", "--set", "15m", "--remove", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var ReopenTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+reopen",
|
||||
Description: "reopen a completed task",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id", Required: true},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildReopenBody()
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskId := url.PathEscape(runtime.Str("task-id"))
|
||||
body := buildReopenBody()
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+taskId, params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
summary, _ := task["summary"].(string)
|
||||
fmt.Fprintf(w, "✅ Task reopened successfully!\n")
|
||||
if guid != "" {
|
||||
fmt.Fprintf(w, "Task ID: %s\n", guid)
|
||||
}
|
||||
if summary != "" {
|
||||
fmt.Fprintf(w, "Summary: %s\n", summary)
|
||||
}
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildReopenBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"completed_at": "0",
|
||||
},
|
||||
"update_fields": []string{"completed_at"},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
taskSearchDefaultPageLimit = 20
|
||||
taskSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+search",
|
||||
Description: "search tasks",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
|
||||
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
|
||||
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
|
||||
{Name: "follower", Desc: "follower open_ids, comma-separated"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTaskSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = taskSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > taskSearchMaxPageLimit {
|
||||
pageLimit = taskSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
enriched := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
taskID, _ := itemMap["id"].(string)
|
||||
if taskID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
task, err := getTaskDetail(runtime, taskID)
|
||||
if err != nil {
|
||||
metaData, _ := itemMap["meta_data"].(map[string]interface{})
|
||||
appLink, _ := metaData["app_link"].(string)
|
||||
enriched = append(enriched, map[string]interface{}{
|
||||
"guid": taskID,
|
||||
"url": truncateTaskURL(appLink),
|
||||
})
|
||||
continue
|
||||
}
|
||||
enriched = append(enriched, outputTaskSummary(task))
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": enriched,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
return
|
||||
}
|
||||
for i, item := range enriched {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
|
||||
if created, _ := item["created_at"].(string); created != "" {
|
||||
fmt.Fprintf(w, " Created: %s\n", created)
|
||||
}
|
||||
if dueAt, _ := item["due_at"].(string); dueAt != "" {
|
||||
fmt.Fprintf(w, " Due: %s\n", dueAt)
|
||||
}
|
||||
if urlVal, _ := item["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["creator_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
|
||||
filter["assignee_ids"] = ids
|
||||
}
|
||||
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
|
||||
filter["follower_ids"] = ids
|
||||
}
|
||||
if runtime.Cmd.Flags().Changed("completed") {
|
||||
filter["is_completed"] = runtime.Bool("completed")
|
||||
}
|
||||
if dueRange := runtime.Str("due"); dueRange != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(dueRange)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid due: %v", err).WithParam("--due")
|
||||
}
|
||||
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
|
||||
mergeIntoFilter(filter, dueFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
if task == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "task detail response missing task object")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SetAncestorTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+set-ancestor",
|
||||
Description: "set or clear a task ancestor",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task guid to update", Required: true},
|
||||
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
taskID := url.PathEscape(runtime.Str("task-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
taskID := runtime.Str("task-id")
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskID)+"/set_ancestor_task", params, buildSetAncestorBody(runtime.Str("ancestor-id"))); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": map[string]interface{}{
|
||||
"guid": taskID,
|
||||
},
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
|
||||
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
|
||||
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
|
||||
} else {
|
||||
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
|
||||
if ancestorID == "" {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"ancestor_guid": ancestorID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildSetAncestorBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestorID string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
|
||||
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildSetAncestorBody(tt.ancestorID)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
|
||||
}
|
||||
for k, want := range tt.want {
|
||||
if got[k] != want {
|
||||
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_DryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
taskID string
|
||||
ancestor string
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "with ancestor",
|
||||
taskID: "task-123",
|
||||
ancestor: "task-456",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
|
||||
},
|
||||
{
|
||||
name: "clear ancestor",
|
||||
taskID: "task-123",
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("task-id", "", "")
|
||||
cmd.Flags().String("ancestor-id", "", "")
|
||||
_ = cmd.Flags().Set("task-id", tt.taskID)
|
||||
if tt.ancestor != "" {
|
||||
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
|
||||
out := SetAncestorTask.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAncestorTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json output with ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`},
|
||||
},
|
||||
{
|
||||
name: "pretty output clears ancestor",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
|
||||
},
|
||||
{
|
||||
name: "api-level error (code!=0) returns error",
|
||||
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
|
||||
Body: map[string]interface{}{
|
||||
"code": 10003,
|
||||
"msg": "permission denied",
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
tt.register(reg)
|
||||
|
||||
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if out := stdout.String(); out != "" {
|
||||
t.Fatalf("expected empty stdout on error, got: %s", out)
|
||||
}
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func taskTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
suffix := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-").Replace(t.Name())
|
||||
return &core.CliConfig{
|
||||
AppID: "test-app-" + suffix,
|
||||
AppSecret: "test-secret-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_testuser",
|
||||
UserName: "Test User",
|
||||
}
|
||||
}
|
||||
|
||||
func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) {
|
||||
t.Helper()
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/warm",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
s := common.Shortcut{
|
||||
Service: "test",
|
||||
Command: "+warm-token",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := rctx.CallAPITyped("GET", "/open-apis/test/v1/warm", nil, nil)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
s.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+warm-token", "--as", "bot"})
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("warm tenant token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func taskShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
return cmdutil.TestFactory(t, taskTestConfig(t))
|
||||
}
|
||||
|
||||
func runMountedTaskShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "test"}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
tasklistSearchDefaultPageLimit = 20
|
||||
tasklistSearchMaxPageLimit = 40
|
||||
)
|
||||
|
||||
var SearchTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-search",
|
||||
Description: "search tasklists",
|
||||
Risk: "read",
|
||||
Scopes: []string{"task:tasklist:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "creator", Desc: "creator open_ids, comma-separated"},
|
||||
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasklists/search").
|
||||
Body(body).
|
||||
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := buildTasklistSearchBody(runtime)
|
||||
return err
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pageLimit := runtime.Int("page-limit")
|
||||
if pageLimit <= 0 {
|
||||
pageLimit = tasklistSearchDefaultPageLimit
|
||||
}
|
||||
if runtime.Bool("page-all") {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
if pageLimit > tasklistSearchMaxPageLimit {
|
||||
pageLimit = tasklistSearchMaxPageLimit
|
||||
}
|
||||
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
lastPageToken, _ = data["page_token"].(string)
|
||||
if !lastHasMore || lastPageToken == "" {
|
||||
break
|
||||
}
|
||||
currentBody["page_token"] = lastPageToken
|
||||
}
|
||||
|
||||
tasklists := make([]map[string]interface{}, 0, len(rawItems))
|
||||
for _, item := range rawItems {
|
||||
itemMap, _ := item.(map[string]interface{})
|
||||
tasklistID, _ := itemMap["id"].(string)
|
||||
if tasklistID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
tasklist, err := getTasklistDetail(runtime, tasklistID)
|
||||
if err != nil {
|
||||
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklistID,
|
||||
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
|
||||
})
|
||||
continue
|
||||
}
|
||||
urlVal, _ := tasklist["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
tasklists = append(tasklists, map[string]interface{}{
|
||||
"guid": tasklist["guid"],
|
||||
"name": tasklist["name"],
|
||||
"url": urlVal,
|
||||
"creator": tasklist["creator"],
|
||||
})
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"items": tasklists,
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
return
|
||||
}
|
||||
for i, tasklist := range tasklists {
|
||||
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
|
||||
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
|
||||
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
|
||||
fmt.Fprintf(w, " URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if lastHasMore && lastPageToken != "" {
|
||||
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
filter := map[string]interface{}{}
|
||||
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
|
||||
filter["user_id"] = ids
|
||||
}
|
||||
if createTime := runtime.Str("create-time"); createTime != "" {
|
||||
start, end, err := parseTimeRangeRFC3339(createTime)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid create-time: %v", err).WithParam("--create-time")
|
||||
}
|
||||
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
|
||||
mergeIntoFilter(filter, timeFilter)
|
||||
}
|
||||
}
|
||||
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"query": runtime.Str("query"),
|
||||
}
|
||||
if len(filter) > 0 {
|
||||
body["filter"] = filter
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
body["page_token"] = pageToken
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+url.PathEscape(tasklistID), params, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
if tasklist == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "tasklist detail response missing tasklist object")
|
||||
}
|
||||
return tasklist, nil
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBuildTasklistSearchBody(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*cobra.Command)
|
||||
wantErr bool
|
||||
check func(*testing.T, map[string]interface{})
|
||||
}{
|
||||
{
|
||||
name: "creator create-time and page token",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("creator", "ou_creator")
|
||||
_ = cmd.Flags().Set("create-time", "-7d,+0d")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
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 filter["user_id"].([]string)[0] != "ou_creator" {
|
||||
t.Fatalf("unexpected filter: %#v", filter)
|
||||
}
|
||||
startTime, _ := createTime["start_time"].(string)
|
||||
endTime, _ := createTime["end_time"].(string)
|
||||
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
|
||||
t.Fatalf("unexpected create_time: %#v", createTime)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "requires query or filter",
|
||||
setup: func(cmd *cobra.Command) {},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
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("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
body, err := buildTasklistSearchBody(runtime)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("buildTasklistSearchBody() error = %v", err)
|
||||
}
|
||||
tt.check(t, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchTasklist_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", "Q2")
|
||||
_ = cmd.Flags().Set("page-token", "pt_tl")
|
||||
},
|
||||
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
|
||||
},
|
||||
{
|
||||
name: "dry run error on invalid create time",
|
||||
setup: func(cmd *cobra.Command) {
|
||||
_ = cmd.Flags().Set("create-time", "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("create-time", "", "")
|
||||
cmd.Flags().String("page-token", "", "")
|
||||
tt.setup(cmd)
|
||||
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
|
||||
if !strings.Contains(tt.name, "error") {
|
||||
if err := SearchTasklist.Validate(nil, runtime); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
}
|
||||
out := SearchTasklist.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTasklist_Execute verifies tasklist search output, enrichment, and notices.
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "json success",
|
||||
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/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": "tl-123"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-fallback"`},
|
||||
},
|
||||
{
|
||||
name: "pretty fallback avoids nil name",
|
||||
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-fallback",
|
||||
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
|
||||
},
|
||||
{
|
||||
name: "empty pretty with pagination",
|
||||
args: []string{"+tasklist-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/tasklists/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/tasklists/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{"No tasklists found."},
|
||||
},
|
||||
}
|
||||
|
||||
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 := SearchTasklist
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTasklist_MalformedResponse covers the search parse arm: a 200 with
|
||||
// an unparseable search body surfaces a typed internal invalid_response error
|
||||
// (exit 5). The detail parse arm is swallowed into the fallback path, so only
|
||||
// the top-level search parse propagates.
|
||||
func TestSearchTasklist_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/search",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
s := SearchTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
args := []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var UpdateTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+update",
|
||||
Description: "update task attributes",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "summary", Desc: "task title"},
|
||||
{Name: "description", Desc: "task description"},
|
||||
{Name: "due", Desc: "due date (ISO 8601 / date:YYYY-MM-DD / relative:+2d / ms timestamp)"},
|
||||
{Name: "data", Desc: "JSON payload for task object"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
|
||||
return common.NewDryRunAPI().
|
||||
PATCH("/open-apis/task/v2/tasks/" + taskId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildTaskUpdateBody(runtime)
|
||||
if err != nil {
|
||||
// buildTaskUpdateBody already returns a typed validation error;
|
||||
// propagate it directly instead of re-wrapping as an API error.
|
||||
return err
|
||||
}
|
||||
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
var updatedTasks []map[string]interface{}
|
||||
|
||||
for _, taskId := range taskIds {
|
||||
taskId = strings.TrimSpace(taskId)
|
||||
if taskId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPatch, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId), params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
taskObj, _ := data["task"].(map[string]interface{})
|
||||
if taskObj != nil {
|
||||
updatedTasks = append(updatedTasks, taskObj)
|
||||
}
|
||||
}
|
||||
|
||||
var tasks []map[string]interface{}
|
||||
for _, task := range updatedTasks {
|
||||
guid, _ := task["guid"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
})
|
||||
}
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"tasks": tasks,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(updatedTasks)}, func(w io.Writer) {
|
||||
for _, task := range updatedTasks {
|
||||
guid, _ := task["guid"].(string)
|
||||
summary, _ := task["summary"].(string)
|
||||
urlVal, _ := task["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
fmt.Fprintf(w, "✅ Task updated successfully!\n")
|
||||
fmt.Fprintf(w, "Task ID: %s\n", guid)
|
||||
if summary != "" {
|
||||
fmt.Fprintf(w, "Summary: %s\n", summary)
|
||||
}
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, "Task URL: %s\n", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w, strings.Repeat("-", 20))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskUpdateBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
taskObj := make(map[string]interface{})
|
||||
var updateFields []string
|
||||
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &taskObj); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
|
||||
}
|
||||
// If data is provided, assume keys are update fields
|
||||
for k := range taskObj {
|
||||
updateFields = append(updateFields, k)
|
||||
}
|
||||
}
|
||||
|
||||
if summary := runtime.Str("summary"); summary != "" {
|
||||
taskObj["summary"] = summary
|
||||
if !contains(updateFields, "summary") {
|
||||
updateFields = append(updateFields, "summary")
|
||||
}
|
||||
}
|
||||
|
||||
if desc := runtime.Str("description"); desc != "" {
|
||||
taskObj["description"] = desc
|
||||
if !contains(updateFields, "description") {
|
||||
updateFields = append(updateFields, "description")
|
||||
}
|
||||
}
|
||||
|
||||
if dueStr := runtime.Str("due"); dueStr != "" {
|
||||
dueObj, err := parseTaskTime(dueStr)
|
||||
if err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
|
||||
}
|
||||
taskObj["due"] = dueObj
|
||||
if !contains(updateFields, "due") {
|
||||
updateFields = append(updateFields, "due")
|
||||
}
|
||||
}
|
||||
|
||||
if len(updateFields) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no fields to update")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"task": taskObj,
|
||||
"update_fields": updateFields,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// taskAttachmentUploadMaxSize is the upper bound on a single attachment upload
|
||||
// to the Task service (50MB, as documented by the open API).
|
||||
const taskAttachmentUploadMaxSize int64 = 50 * 1024 * 1024
|
||||
|
||||
// taskAttachmentUploadPath is the Task open-api endpoint that accepts a single
|
||||
// multipart/form-data upload per call.
|
||||
const taskAttachmentUploadPath = "/open-apis/task/v2/attachments/upload"
|
||||
|
||||
// defaultTaskAttachmentResourceType is used when the caller does not pass an
|
||||
// explicit --resource-type flag. Task is the only resource type documented for
|
||||
// this endpoint today, but the flag is kept open so that future resource types
|
||||
// can be targeted without a client upgrade.
|
||||
const defaultTaskAttachmentResourceType = "task"
|
||||
|
||||
// UploadAttachmentTask uploads a single local file as an attachment to a task
|
||||
// (or any other resource type accepted by the Task attachment endpoint).
|
||||
var UploadAttachmentTask = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+upload-attachment",
|
||||
Description: "upload a local file as an attachment to a task",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:attachment:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "resource-id", Desc: "task guid (or task applink URL)", Required: true},
|
||||
{Name: "file", Desc: "local file path (single file, <= 50MB)", Required: true},
|
||||
{Name: "resource-type", Desc: "owning resource type (default: task); use task_delivery when uploading to task agents", Default: defaultTaskAttachmentResourceType},
|
||||
{Name: "user-id-type", Desc: "user id type (default: open_id)", Default: "open_id"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
resourceType := runtime.Str("resource-type")
|
||||
if resourceType == "" {
|
||||
resourceType = defaultTaskAttachmentResourceType
|
||||
}
|
||||
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
||||
filePath := runtime.Str("file")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
if userIDType == "" {
|
||||
userIDType = "open_id"
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
POST(taskAttachmentUploadPath).
|
||||
Params(map[string]interface{}{"user_id_type": userIDType}).
|
||||
Body(map[string]interface{}{
|
||||
"resource_type": resourceType,
|
||||
"resource_id": resourceID,
|
||||
"file": map[string]string{
|
||||
"field": "file",
|
||||
"path": filePath,
|
||||
"name": filepath.Base(filePath),
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resourceType := runtime.Str("resource-type")
|
||||
if resourceType == "" {
|
||||
resourceType = defaultTaskAttachmentResourceType
|
||||
}
|
||||
resourceID := extractTaskGuid(runtime.Str("resource-id"))
|
||||
filePath := runtime.Str("file")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
if userIDType == "" {
|
||||
userIDType = "open_id"
|
||||
}
|
||||
|
||||
fio := runtime.FileIO()
|
||||
if fio == nil {
|
||||
// A nil FileIO is a runtime wiring fault, not user input.
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "file operations require a FileIO provider")
|
||||
}
|
||||
stat, err := fio.Stat(filePath)
|
||||
if err != nil {
|
||||
return taskInputStatError(err, "--file", "cannot access file")
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
||||
}
|
||||
if stat.Size() > taskAttachmentUploadMaxSize {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"attachment %s exceeds the 50MB per-file limit",
|
||||
common.FormatSize(stat.Size()),
|
||||
).WithParam("--file")
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
|
||||
// Observability: input parsed.
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"[+upload-attachment] input parsed: resource_type=%s resource_id=%s file=%s size=%s\n",
|
||||
resourceType, resourceID, filePath, common.FormatSize(stat.Size()),
|
||||
)
|
||||
|
||||
f, err := fio.Open(filePath)
|
||||
if err != nil {
|
||||
return taskInputStatError(err, "--file", "cannot open file")
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Build the multipart body manually so the real filename is preserved
|
||||
// in the `file` part's Content-Disposition. The SDK's Formdata.AddFile
|
||||
// hardcodes the filename to "unknown-file" (see oapi-sdk-go
|
||||
// core/reqtranslator.go), which is what was showing up in the Task UI.
|
||||
var bodyBuf bytes.Buffer
|
||||
mw := common.NewMultipartWriter(&bodyBuf)
|
||||
if err := mw.WriteField("resource_type", resourceType); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
if err := mw.WriteField("resource_id", resourceID); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
filePart, err := mw.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "build multipart body: %s", err)
|
||||
}
|
||||
if _, err := io.Copy(filePart, f); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "write file to multipart body: %s", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "finalize multipart body: %s", err)
|
||||
}
|
||||
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
|
||||
// Observability: HTTP call about to start.
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http call: POST %s user_id_type=%s\n",
|
||||
taskAttachmentUploadPath, userIDType,
|
||||
)
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
httpResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: taskAttachmentUploadPath,
|
||||
QueryParams: queryParams,
|
||||
Body: &bodyBuf,
|
||||
}, client.WithHeaders(headers))
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: error=%v\n", err)
|
||||
return wrapTaskNetworkErr(err, "upload attachment request failed")
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
rawBody, readErr := io.ReadAll(httpResp.Body)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: read_error=%v\n", readErr)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to read response: %v", readErr)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if parseErr := json.Unmarshal(rawBody, &result); parseErr != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: parse_error=%v\n", parseErr)
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to parse response: %v", parseErr)
|
||||
}
|
||||
|
||||
data, err := HandleTaskApiResultWithContext(result, nil, "upload task attachment", runtime.APIClassifyContext())
|
||||
if err != nil {
|
||||
code, _ := result["code"]
|
||||
msg, _ := result["msg"].(string)
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: code=%v msg=%q error=%v\n",
|
||||
code, msg, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// The Task attachment upload endpoint returns `data.items` containing
|
||||
// the freshly created attachment records. Since this shortcut uploads
|
||||
// exactly one file per call, we surface the single record directly as
|
||||
// the output envelope — all fields returned by the API (guid, name,
|
||||
// size, url, resource_type, uploader, ...) are preserved verbatim.
|
||||
items, _ := data["items"].([]interface{})
|
||||
var first map[string]interface{}
|
||||
if len(items) > 0 {
|
||||
first, _ = items[0].(map[string]interface{})
|
||||
}
|
||||
if first == nil {
|
||||
first = map[string]interface{}{}
|
||||
}
|
||||
guid, _ := first["guid"].(string)
|
||||
|
||||
code, _ := result["code"]
|
||||
msg, _ := result["msg"].(string)
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[+upload-attachment] http response: code=%v msg=%q attachment_guid=%s\n",
|
||||
code, msg, guid)
|
||||
|
||||
runtime.OutFormat(first, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Attachment uploaded successfully!\n")
|
||||
fmt.Fprintf(w, "Resource: %s/%s\n", resourceType, resourceID)
|
||||
name, _ := first["name"].(string)
|
||||
if name == "" {
|
||||
name = fileName
|
||||
}
|
||||
fmt.Fprintf(w, "File: %s (%s)\n", name, common.FormatSize(stat.Size()))
|
||||
if guid != "" {
|
||||
fmt.Fprintf(w, "Attachment GUID: %s\n", guid)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// writeTestFile creates a file at name (relative to cwd) with size bytes of
|
||||
// ASCII data and returns the relative path it wrote.
|
||||
func writeTestFile(t *testing.T, name string, size int) string {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(name, bytes.Repeat([]byte("a"), size), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// writeSparseTestFile produces a sparse file of the requested size without
|
||||
// allocating real disk space, useful for exercising the 50MB validation path.
|
||||
func writeSparseTestFile(t *testing.T, name string, size int64) string {
|
||||
t.Helper()
|
||||
fh, err := os.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("Create(%q) error: %v", name, err)
|
||||
}
|
||||
if err := fh.Truncate(size); err != nil {
|
||||
t.Fatalf("Truncate(%q, %d) error: %v", name, size, err)
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
t.Fatalf("Close(%q) error: %v", name, err)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_Success(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
format string
|
||||
contains []string
|
||||
}{
|
||||
{
|
||||
name: "pretty format",
|
||||
format: "pretty",
|
||||
contains: []string{
|
||||
"✅ Attachment uploaded successfully!",
|
||||
"Attachment GUID: att-guid-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "json format",
|
||||
format: "json",
|
||||
contains: []string{
|
||||
`"guid": "att-guid-1"`,
|
||||
`"name": "note.txt"`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 12)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"guid": "att-guid-1",
|
||||
"name": "note.txt",
|
||||
"size": 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
args := []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", tt.format,
|
||||
}
|
||||
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
// Normalize JSON whitespace so that both compact and indented forms match.
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.contains {
|
||||
if !strings.Contains(outNorm, want) && !strings.Contains(out, want) {
|
||||
t.Errorf("stdout missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify multipart body structure.
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_type"]; got != "task" {
|
||||
t.Errorf("resource_type = %q, want %q", got, "task")
|
||||
}
|
||||
if got := body.Fields["resource_id"]; got != "task-guid-123" {
|
||||
t.Errorf("resource_id = %q, want %q", got, "task-guid-123")
|
||||
}
|
||||
if got, ok := body.Files["file"]; !ok {
|
||||
t.Errorf("multipart missing file part")
|
||||
} else if len(got) != 12 {
|
||||
t.Errorf("file size = %d, want 12", len(got))
|
||||
}
|
||||
if got := body.FileNames["file"]; got != "note.txt" {
|
||||
t.Errorf("multipart file filename = %q, want %q", got, "note.txt")
|
||||
}
|
||||
|
||||
// Verify key observability logs on stderr.
|
||||
errOut := stderr.String()
|
||||
for _, log := range []string{
|
||||
"input parsed",
|
||||
"http call: POST /open-apis/task/v2/attachments/upload",
|
||||
"http response",
|
||||
"att-guid-1",
|
||||
} {
|
||||
if !strings.Contains(errOut, log) {
|
||||
t.Errorf("stderr missing log %q; got:\n%s", log, errOut)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_ExplicitResourceTypePassthrough(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 5)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"guid": "att-guid-2"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--resource-type", "custom_type",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_type"]; got != "custom_type" {
|
||||
t.Fatalf("resource_type = %q, want custom_type", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_ResourceIDFromApplink(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 5)
|
||||
|
||||
uploadStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"guid": "att-guid-3"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
applink := "https://applink.feishu.cn/client/todo/task?guid=task-from-url"
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", applink,
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeTaskAttachmentMultipart(t, uploadStub)
|
||||
if got := body.Fields["resource_id"]; got != "task-from-url" {
|
||||
t.Fatalf("resource_id = %q, want task-from-url", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_SizeLimit(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// 50MB + 1 byte; no HTTP stub registered — we must fail before any call.
|
||||
filePath := writeSparseTestFile(t, "big.bin", 50*1024*1024+1)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "50MB") {
|
||||
t.Fatalf("error message should mention 50MB limit, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_FileMissing(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "does-not-exist.bin",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("param = %q, want %q", ve.Param, "--file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_NotRegularFile(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A directory stats fine but is not a regular file; we must reject it as
|
||||
// invalid --file input before any HTTP call (no stub registered).
|
||||
if err := os.Mkdir("a-dir", 0o755); err != nil {
|
||||
t.Fatalf("Mkdir error: %v", err)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "a-dir",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("param = %q, want %q", ve.Param, "--file")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "regular file") {
|
||||
t.Fatalf("error message should mention regular file, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_StatErrorMessage(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
// A missing path fails Stat → taskInputStatError surfaces "cannot access
|
||||
// file" (no longer "file not found"). No HTTP stub: must fail before any call.
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "missing.bin",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot access file") {
|
||||
t.Fatalf("error message should contain %q, got: %v", "cannot access file", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_MalformedResponse(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 4)
|
||||
|
||||
// A 200 response whose body is not valid JSON: the parse failure must
|
||||
// surface a typed internal invalid_response error (exit 5), not a panic
|
||||
// or a silent success.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
RawBody: []byte("this is not json{"),
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse response") {
|
||||
t.Fatalf("error message should mention parse response, got: %v", err)
|
||||
}
|
||||
|
||||
// The parse-error observability log should be emitted on stderr.
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "parse_error") {
|
||||
t.Errorf("stderr missing parse_error log; got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_APIError(t *testing.T) {
|
||||
f, stdout, stderr, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
filePath := writeTestFile(t, "note.txt", 3)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/attachments/upload",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := runMountedTaskShortcut(t, UploadAttachmentTask, []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", filePath,
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(err) = !ok, want typed errs.* error; err = %v", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if p.Code != ErrCodeTaskPermissionDenied {
|
||||
t.Fatalf("code = %d, want %d", p.Code, ErrCodeTaskPermissionDenied)
|
||||
}
|
||||
// permission_denied maps to CategoryAuthorization → exit 3 (was exit 1 under legacy).
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", got, output.ExitAuth)
|
||||
}
|
||||
|
||||
// Key-path log should still be emitted on failure.
|
||||
errOut := stderr.String()
|
||||
for _, log := range []string{"input parsed", "http call", "http response"} {
|
||||
if !strings.Contains(errOut, log) {
|
||||
t.Errorf("stderr missing failure log %q; got:\n%s", log, errOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadAttachmentTask_DryRun(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
extraArgs []string
|
||||
wantResourceType string
|
||||
}{
|
||||
{
|
||||
name: "default resource type",
|
||||
extraArgs: nil,
|
||||
wantResourceType: "task",
|
||||
},
|
||||
{
|
||||
name: "explicit resource type",
|
||||
extraArgs: []string{"--resource-type", "custom_type"},
|
||||
wantResourceType: "custom_type",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := taskShortcutTestFactory(t)
|
||||
|
||||
args := []string{
|
||||
"+upload-attachment",
|
||||
"--resource-id", "task-guid-123",
|
||||
"--file", "./some.pdf",
|
||||
"--as", "bot",
|
||||
"--format", "json",
|
||||
"--dry-run",
|
||||
}
|
||||
args = append(args, tt.extraArgs...)
|
||||
if err := runMountedTaskShortcut(t, UploadAttachmentTask, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
var dry map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &dry); err != nil {
|
||||
t.Fatalf("dry-run output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
calls, _ := dry["api"].([]interface{})
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("expected 1 api call in dry-run, got %d: %v", len(calls), calls)
|
||||
}
|
||||
call := calls[0].(map[string]interface{})
|
||||
if got := call["method"]; got != "POST" {
|
||||
t.Fatalf("method = %v, want POST", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/task/v2/attachments/upload" {
|
||||
t.Fatalf("url = %v, want upload path", got)
|
||||
}
|
||||
params, _ := call["params"].(map[string]interface{})
|
||||
if got := params["user_id_type"]; got != "open_id" {
|
||||
t.Fatalf("params.user_id_type = %v, want open_id", got)
|
||||
}
|
||||
body := call["body"].(map[string]interface{})
|
||||
if got := body["resource_type"]; got != tt.wantResourceType {
|
||||
t.Fatalf("resource_type = %v, want %v", got, tt.wantResourceType)
|
||||
}
|
||||
if got := body["resource_id"]; got != "task-guid-123" {
|
||||
t.Fatalf("resource_id = %v, want task-guid-123", got)
|
||||
}
|
||||
fileDesc := body["file"].(map[string]interface{})
|
||||
if got := fileDesc["field"]; got != "file" {
|
||||
t.Fatalf("file.field = %v, want file", got)
|
||||
}
|
||||
if got := fileDesc["path"]; got != "./some.pdf" {
|
||||
t.Fatalf("file.path = %v, want ./some.pdf", got)
|
||||
}
|
||||
if got := fileDesc["name"]; got != "some.pdf" {
|
||||
t.Fatalf("file.name = %v, want some.pdf", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── multipart body helper ──────────────────────────────────────────────────
|
||||
|
||||
type capturedAttachmentMultipart struct {
|
||||
Fields map[string]string
|
||||
Files map[string][]byte
|
||||
FileNames map[string]string
|
||||
}
|
||||
|
||||
func decodeTaskAttachmentMultipart(t *testing.T, stub *httpmock.Stub) capturedAttachmentMultipart {
|
||||
t.Helper()
|
||||
contentType := stub.CapturedHeaders.Get("Content-Type")
|
||||
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
t.Fatalf("parse content-type %q: %v", contentType, err)
|
||||
}
|
||||
if mediaType != "multipart/form-data" {
|
||||
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
|
||||
body := capturedAttachmentMultipart{
|
||||
Fields: map[string]string{},
|
||||
Files: map[string][]byte{},
|
||||
FileNames: map[string]string{},
|
||||
}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart part: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(part)
|
||||
if err != nil {
|
||||
t.Fatalf("read multipart data: %v", err)
|
||||
}
|
||||
if part.FileName() != "" {
|
||||
body.Files[part.FormName()] = data
|
||||
body.FileNames[part.FormName()] = part.FileName()
|
||||
continue
|
||||
}
|
||||
body.Fields[part.FormName()] = string(data)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var relativeTimeRe = regexp.MustCompile(`^([+-])(\d+)([dwmh])$`)
|
||||
|
||||
func isRelativeTime(s string) bool {
|
||||
return relativeTimeRe.MatchString(s)
|
||||
}
|
||||
|
||||
func parseRelativeTime(s string) (time.Time, error) {
|
||||
matches := relativeTimeRe.FindStringSubmatch(s)
|
||||
if len(matches) == 0 {
|
||||
return time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid relative time format: %s", s)
|
||||
}
|
||||
|
||||
sign := matches[1]
|
||||
amountStr := matches[2]
|
||||
unit := matches[3]
|
||||
|
||||
amount, err := strconv.Atoi(amountStr)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
if sign == "-" {
|
||||
amount = -amount
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch unit {
|
||||
case "d":
|
||||
return now.AddDate(0, 0, amount), nil
|
||||
case "w":
|
||||
return now.AddDate(0, 0, amount*7), nil
|
||||
case "m":
|
||||
return now.Add(time.Duration(amount) * time.Minute), nil
|
||||
case "h":
|
||||
return now.Add(time.Duration(amount) * time.Hour), nil
|
||||
}
|
||||
panic(fmt.Sprintf("unreachable: relativeTimeRe matched unexpected unit %q", unit))
|
||||
}
|
||||
|
||||
const (
|
||||
// ErrCodeTaskInvalidParams is returned when request parameters are invalid.
|
||||
ErrCodeTaskInvalidParams = 1470400
|
||||
// ErrCodeTaskPermissionDenied is returned when the user has no permission.
|
||||
ErrCodeTaskPermissionDenied = 1470403
|
||||
// ErrCodeTaskNotFound is returned when the resource is not found.
|
||||
ErrCodeTaskNotFound = 1470404
|
||||
// ErrCodeTaskConflict is returned when concurrent call conflict.
|
||||
ErrCodeTaskConflict = 1470422
|
||||
// ErrCodeTaskInternalError is returned when server error occurs.
|
||||
ErrCodeTaskInternalError = 1470500
|
||||
// ErrCodeTaskAssigneeLimit is returned when assignee limit exceeded.
|
||||
ErrCodeTaskAssigneeLimit = 1470610
|
||||
// ErrCodeTaskFollowerLimit is returned when follower limit exceeded.
|
||||
ErrCodeTaskFollowerLimit = 1470611
|
||||
// ErrCodeTasklistMemberLimit is returned when tasklist member limit exceeded.
|
||||
ErrCodeTasklistMemberLimit = 1470612
|
||||
// ErrCodeTaskReminderExists is returned when reminder already exists.
|
||||
ErrCodeTaskReminderExists = 1470613
|
||||
)
|
||||
|
||||
// taskAPIHints carries the task-specific recovery hint for each known Lark API
|
||||
// code, layered onto the typed error after errclass.BuildAPIError classifies
|
||||
// it. errclass.APIHint only covers context-free subtypes (e.g. conflict); these
|
||||
// hints carry the resource context APIHint intentionally leaves to the caller.
|
||||
// Authorization (1470403) is omitted: BuildAPIError already attaches the
|
||||
// canonical permission hint.
|
||||
var taskAPIHints = map[int]string{
|
||||
ErrCodeTaskInvalidParams: "Please check required fields, field lengths, or parameter logic (e.g., reminders require a due date).",
|
||||
ErrCodeTaskNotFound: "Please verify if the task, tasklist, or group ID is correct and has not been deleted.",
|
||||
ErrCodeTaskConflict: "Avoid making concurrent API calls using the same client_token.",
|
||||
ErrCodeTaskInternalError: "Please try again. If the error persists, check the content validity or contact support.",
|
||||
ErrCodeTaskAssigneeLimit: "The current task has reached the maximum number of assignees.",
|
||||
ErrCodeTaskFollowerLimit: "The current task has reached the maximum number of followers.",
|
||||
ErrCodeTasklistMemberLimit: "The current tasklist has reached the maximum number of members.",
|
||||
ErrCodeTaskReminderExists: "The task already has a reminder set. Remove the existing reminder before adding a new one.",
|
||||
}
|
||||
|
||||
func callTaskAPITyped(runtime *common.RuntimeContext, method, url string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
|
||||
data, err := runtime.CallAPITyped(method, url, params, body)
|
||||
return data, applyTaskAPIHint(err)
|
||||
}
|
||||
|
||||
func applyTaskAPIHint(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
if hint := taskAPIHints[p.Code]; hint != "" {
|
||||
p.Hint = hint
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// HandleTaskApiResult interprets a parsed Lark API response. A non-zero code is
|
||||
// classified into a typed errs.* error by errclass.BuildAPIError — Category,
|
||||
// Subtype, Code, and log_id are sourced from internal/errclass/codemeta_task.go
|
||||
// — with the task-specific recovery hint (taskAPIHints) layered on top.
|
||||
func HandleTaskApiResult(result interface{}, err error, action string) (map[string]interface{}, error) {
|
||||
return handleTaskAPIResult(result, err, action, errclass.ClassifyContext{})
|
||||
}
|
||||
|
||||
func HandleTaskApiResultWithContext(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
return handleTaskAPIResult(result, err, action, cc)
|
||||
}
|
||||
|
||||
func handleTaskAPIResult(result interface{}, err error, action string, cc errclass.ClassifyContext) (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resultMap, _ := result.(map[string]interface{})
|
||||
codeVal, hasCode := resultMap["code"]
|
||||
if !hasCode {
|
||||
// A Lark response always carries a top-level code; its absence (with no
|
||||
// transport error) means a malformed or unexpected body.
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: unexpected response (missing code field)", action)
|
||||
}
|
||||
|
||||
code, ok := util.ToFloat64(codeVal)
|
||||
if !ok {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: malformed response (non-numeric code %v)", action, codeVal)
|
||||
}
|
||||
larkCode := int(code)
|
||||
if larkCode != 0 {
|
||||
typedErr := errclass.BuildAPIError(resultMap, cc)
|
||||
return nil, applyTaskAPIHint(typedErr)
|
||||
}
|
||||
|
||||
data, _ := resultMap["data"].(map[string]interface{})
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// truncateTaskURL removes extra query parameters from task applink, keeping only guid.
|
||||
func truncateTaskURL(u string) string {
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(u, "&"); idx != -1 {
|
||||
return u[:idx]
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// parseTimeFlagSec parses a time flag that can be absolute (ISO 8601, timestamp) or relative (+/- Nd/w/m/h).
|
||||
// It returns the Unix seconds string.
|
||||
func parseTimeFlagSec(input string, hint string) (string, error) {
|
||||
if isRelativeTime(input) {
|
||||
t, err := parseRelativeTime(input)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Snap to day if unit is days or weeks
|
||||
if strings.HasSuffix(input, "d") || strings.HasSuffix(input, "w") {
|
||||
if hint == "end" {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, t.Location())
|
||||
} else {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%d", t.Unix()), nil
|
||||
}
|
||||
return common.ParseTime(input, hint)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
convey.Convey("contains", t, func() {
|
||||
list := []string{"a", "b", "c"}
|
||||
convey.So(contains(list, "a"), convey.ShouldBeTrue)
|
||||
convey.So(contains(list, "d"), convey.ShouldBeFalse)
|
||||
convey.So(contains([]string{}, "a"), convey.ShouldBeFalse)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseRelativeTime_TypedError(t *testing.T) {
|
||||
_, err := parseRelativeTime("not-relative")
|
||||
if err == nil {
|
||||
t.Fatal("parseRelativeTime(\"not-relative\") expected error, 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 !strings.Contains(err.Error(), "invalid relative time format") {
|
||||
t.Errorf("message = %q, want substring %q", err.Error(), "invalid relative time format")
|
||||
}
|
||||
}
|
||||
|
||||
// apiResult builds a parsed Lark API response with a non-zero code, as
|
||||
// HandleTaskApiResult receives it after json.Unmarshal.
|
||||
func apiResult(code int, msg string) map[string]interface{} {
|
||||
return map[string]interface{}{"code": float64(code), "msg": msg}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_TypedMapping locks the API code → typed
|
||||
// category/subtype/exit mapping. Classification is sourced from
|
||||
// internal/errclass/codemeta_task.go via errclass.BuildAPIError; the
|
||||
// task-specific recovery hint is layered on from taskAPIHints. 1470400 surfaces
|
||||
// exit 1 (API-side parameter rejection, was exit 2 under legacy); 1470403
|
||||
// routes to CategoryAuthorization and surfaces exit 3 (was exit 1).
|
||||
func TestHandleTaskApiResult_TypedMapping(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantSubtype errs.Subtype
|
||||
wantExit int
|
||||
wantRetry bool
|
||||
}{
|
||||
{"invalid_params", ErrCodeTaskInvalidParams, errs.SubtypeInvalidParameters, output.ExitAPI, false},
|
||||
{"not_found", ErrCodeTaskNotFound, errs.SubtypeNotFound, output.ExitAPI, false},
|
||||
{"conflict", ErrCodeTaskConflict, errs.SubtypeConflict, output.ExitAPI, true},
|
||||
{"internal", ErrCodeTaskInternalError, errs.SubtypeServerError, output.ExitAPI, true},
|
||||
{"assignee_limit", ErrCodeTaskAssigneeLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"follower_limit", ErrCodeTaskFollowerLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"member_limit", ErrCodeTasklistMemberLimit, errs.SubtypeQuotaExceeded, output.ExitAPI, false},
|
||||
{"reminder_exists", ErrCodeTaskReminderExists, errs.SubtypeAlreadyExists, output.ExitAPI, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
data, err := HandleTaskApiResult(apiResult(tt.code, "raw upstream detail"), nil, "do thing")
|
||||
if data != nil {
|
||||
t.Errorf("data = %v, want nil on error", data)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if p.Subtype != tt.wantSubtype {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, tt.wantSubtype)
|
||||
}
|
||||
if p.Code != tt.code {
|
||||
t.Errorf("code = %d, want %d", p.Code, tt.code)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != tt.wantExit {
|
||||
t.Errorf("exit code = %d, want %d", got, tt.wantExit)
|
||||
}
|
||||
if p.Retryable != tt.wantRetry {
|
||||
t.Errorf("retryable = %v, want %v", p.Retryable, tt.wantRetry)
|
||||
}
|
||||
// These CategoryAPI codes carry the task-specific recovery hint.
|
||||
if p.Hint != taskAPIHints[tt.code] {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[tt.code])
|
||||
}
|
||||
// BuildAPIError uses the raw upstream msg as the message.
|
||||
if !strings.Contains(err.Error(), "raw upstream detail") {
|
||||
t.Errorf("message = %q, want raw upstream detail", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_PermissionDenied verifies 1470403 routes to a typed
|
||||
// *errs.PermissionError (exit 3) carrying the canonical permission hint from
|
||||
// BuildAPIError — taskAPIHints intentionally omits it so the canonical hint
|
||||
// stands.
|
||||
func TestHandleTaskApiResult_PermissionDenied(t *testing.T) {
|
||||
_, err := HandleTaskApiResult(apiResult(ErrCodeTaskPermissionDenied, "no permission"), nil, "do thing")
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("err = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitAuth)
|
||||
}
|
||||
if strings.TrimSpace(pe.Hint) == "" {
|
||||
t.Error("expected a canonical permission hint, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
|
||||
_, err := HandleTaskApiResultWithContext(map[string]interface{}{
|
||||
"code": float64(99991672),
|
||||
"msg": "access denied",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "task:attachment:write"},
|
||||
},
|
||||
},
|
||||
}, nil, "upload task attachment", errclass.ClassifyContext{
|
||||
Brand: "lark",
|
||||
AppID: "cli_a123",
|
||||
Identity: "bot",
|
||||
})
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("err = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if pe.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
|
||||
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {
|
||||
t.Errorf("MissingScopes = %#v, want task:attachment:write", pe.MissingScopes)
|
||||
}
|
||||
if !strings.Contains(pe.Hint, pe.ConsoleURL) {
|
||||
t.Errorf("hint = %q, want to include console URL %q", pe.Hint, pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallTaskAPITyped_TaskHint(t *testing.T) {
|
||||
cfg := taskTestConfig(t)
|
||||
f, _, _, reg := cmdutil.TestFactory(t, cfg)
|
||||
rt := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+x"}, cfg, f, core.AsUser)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/task/v2/tasks/t-1",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(ErrCodeTaskReminderExists),
|
||||
"msg": "reminder exists",
|
||||
},
|
||||
})
|
||||
|
||||
_, err := callTaskAPITyped(rt, http.MethodGet, "/open-apis/task/v2/tasks/t-1", nil, nil)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if p.Hint != taskAPIHints[ErrCodeTaskReminderExists] {
|
||||
t.Errorf("hint = %q, want %q", p.Hint, taskAPIHints[ErrCodeTaskReminderExists])
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_MalformedResponse covers the two malformed-body arms:
|
||||
// a response with no top-level code, and one whose code is non-numeric. Both
|
||||
// must surface a typed internal invalid_response error (exit 5) rather than
|
||||
// silently passing through as a success.
|
||||
func TestHandleTaskApiResult_MalformedResponse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result map[string]interface{}
|
||||
}{
|
||||
{"missing code field", map[string]interface{}{"msg": "weird", "data": map[string]interface{}{}}},
|
||||
{"non-numeric code", map[string]interface{}{"code": "oops", "msg": "weird"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data, err := HandleTaskApiResult(tc.result, nil, "do thing")
|
||||
if data != nil {
|
||||
t.Errorf("data = %v, want nil", data)
|
||||
}
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_Success returns the data map unchanged when code == 0.
|
||||
func TestHandleTaskApiResult_Success(t *testing.T) {
|
||||
want := map[string]interface{}{"guid": "t-1"}
|
||||
data, err := HandleTaskApiResult(map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"data": want,
|
||||
}, nil, "do thing")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if data["guid"] != "t-1" {
|
||||
t.Errorf("data = %v, want guid=t-1", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleTaskApiResult_UnknownCode covers the fallback arm: an uncatalogued
|
||||
// code becomes a generic CategoryAPI error with SubtypeUnknown and no layered
|
||||
// hint.
|
||||
func TestHandleTaskApiResult_UnknownCode(t *testing.T) {
|
||||
_, err := HandleTaskApiResult(apiResult(9999999, "weird"), nil, "do thing")
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T, want typed errs.* error", err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %q, want %q", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if p.Code != 9999999 {
|
||||
t.Errorf("code = %d, want 9999999", p.Code)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitAPI)
|
||||
}
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Errorf("error type = %T, want *errs.APIError", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var AddTaskToTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-task-add",
|
||||
Description: "add tasks to a tasklist",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
|
||||
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
|
||||
{Name: "section-guid", Desc: "section guid"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
taskId := url.PathEscape(strings.TrimSpace(taskIds[0]))
|
||||
|
||||
body := map[string]interface{}{
|
||||
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tasklistGuid := extractTasklistGuid(runtime.Str("tasklist-id"))
|
||||
taskIds := strings.Split(runtime.Str("task-id"), ",")
|
||||
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
}
|
||||
|
||||
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
|
||||
body["section_guid"] = sectionGuid
|
||||
}
|
||||
|
||||
var successful []map[string]interface{}
|
||||
var failed []map[string]interface{}
|
||||
|
||||
for _, taskId := range taskIds {
|
||||
taskId = strings.TrimSpace(taskId)
|
||||
if taskId == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+url.PathEscape(taskId)+"/add_tasklist", params, body)
|
||||
if err != nil {
|
||||
failDetail := map[string]interface{}{
|
||||
"guid": taskId,
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failDetail["type"] = string(p.Subtype)
|
||||
failDetail["code"] = p.Code
|
||||
failDetail["message"] = p.Message
|
||||
failDetail["hint"] = p.Hint
|
||||
} else {
|
||||
failDetail["type"] = "api_error"
|
||||
failDetail["message"] = err.Error()
|
||||
}
|
||||
failed = append(failed, failDetail)
|
||||
} else {
|
||||
task, _ := data["task"].(map[string]interface{})
|
||||
guid, _ := task["guid"].(string)
|
||||
taskUrl, _ := task["url"].(string)
|
||||
taskUrl = truncateTaskURL(taskUrl)
|
||||
successful = append(successful, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": taskUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
resultData := map[string]interface{}{
|
||||
"successful_tasks": successful,
|
||||
"failed_tasks": failed,
|
||||
"tasklist_guid": tasklistGuid,
|
||||
}
|
||||
|
||||
// Item-level failures surface as a non-zero exit (ok:false) so callers
|
||||
// don't have to inspect failed_tasks to detect a partial add; the full
|
||||
// payload (successful + failed) stays on stdout either way.
|
||||
if len(failed) > 0 {
|
||||
return runtime.OutPartialFailure(resultData, nil)
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Tasks added to tasklist %s!\n", tasklistGuid)
|
||||
fmt.Fprintf(w, "Successful: %d, Failed: %d\n", len(successful), len(failed))
|
||||
|
||||
if len(successful) > 0 {
|
||||
fmt.Fprintln(w, "Successful Tasks:")
|
||||
for _, t := range successful {
|
||||
guid, _ := t["guid"].(string)
|
||||
taskUrl, _ := t["url"].(string)
|
||||
fmt.Fprintf(w, " - ID: %s", guid)
|
||||
if taskUrl != "" {
|
||||
fmt.Fprintf(w, ", URL: %s", taskUrl)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
if len(failed) > 0 {
|
||||
fmt.Fprintln(w, "Failed Tasks:")
|
||||
for _, f := range failed {
|
||||
fmt.Fprintf(w, " - %s: %s\n", f["guid"], f["message"])
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestAddTaskToTasklist_Success(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
s := AddTaskToTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
|
||||
t.Errorf("expected tasklist_guid in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddTaskToTasklist_PartialFailure exercises the batch path: some tasks
|
||||
// succeed, others fail with typed API errors. Successful and failed tasks both
|
||||
// land in stdout as an ok:false envelope, and the command returns the typed
|
||||
// partial-failure exit signal (exit 1) via runtime.OutPartialFailure. The
|
||||
// failed_tasks[].type carries the typed subtype (e.g. "permission_denied",
|
||||
// "not_found") read off errs.ProblemOf.
|
||||
func TestAddTaskToTasklist_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-ok/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-perm/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks/task-missing/add_tasklist",
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskNotFound, "msg": "task not found",
|
||||
},
|
||||
})
|
||||
|
||||
s := AddTaskToTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-ok,task-perm,task-missing", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
// Partial failure now surfaces as a non-zero exit (ok:false), not nil.
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("expected *output.PartialFailureError on partial failure, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
|
||||
// Successful task is in stdout.
|
||||
if !strings.Contains(out, "task-ok") {
|
||||
t.Errorf("expected successful task-ok in output, got: %s", out)
|
||||
}
|
||||
|
||||
// Failed tasks carry the typed subtype, not the legacy Detail.Type.
|
||||
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
|
||||
}
|
||||
if !strings.Contains(out, string(errs.SubtypeNotFound)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypeNotFound, out)
|
||||
}
|
||||
|
||||
// The legacy shapes must not leak.
|
||||
if strings.Contains(out, "permission_error") {
|
||||
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var CreateTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-create",
|
||||
Description: "create a tasklist and optionally add tasks",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:tasklist:write", "task:task:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "tasklist name", Required: true},
|
||||
{Name: "member", Desc: "comma-separated open_ids to add as editors"},
|
||||
{Name: "data", Desc: "JSON array of tasks to create within this tasklist"},
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
body := buildTasklistCreateBody(runtime)
|
||||
|
||||
d := common.NewDryRunAPI().
|
||||
Desc("1. Create Tasklist").
|
||||
POST("/open-apis/task/v2/tasklists").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
d.Desc("2. Create Tasks within the new tasklist (concurrently)")
|
||||
}
|
||||
|
||||
return d
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body := buildTasklistCreateBody(runtime)
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
// Validate --data (client input) before any remote write, so a malformed
|
||||
// payload fails fast without creating an orphan tasklist.
|
||||
var tasks []map[string]interface{}
|
||||
if dataStr := runtime.Str("data"); dataStr != "" {
|
||||
if err := json.Unmarshal([]byte(dataStr), &tasks); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse --data as JSON array: %v", err).WithParam("--data")
|
||||
}
|
||||
}
|
||||
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tasklist, _ := data["tasklist"].(map[string]interface{})
|
||||
tasklistGuid, _ := tasklist["guid"].(string)
|
||||
tasklistName, _ := tasklist["name"].(string)
|
||||
tasklistUrl, _ := tasklist["url"].(string)
|
||||
tasklistUrl = truncateTaskURL(tasklistUrl)
|
||||
|
||||
var createdTasks []map[string]interface{}
|
||||
var failedTasks []map[string]interface{}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i, taskDef := range tasks {
|
||||
wg.Add(1)
|
||||
go func(idx int, tDef map[string]interface{}) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "recovered in defer: %v\n", r)
|
||||
}
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
// Add tasklist_guid to the task definition
|
||||
tDef["tasklists"] = []map[string]interface{}{
|
||||
{
|
||||
"tasklist_guid": tasklistGuid,
|
||||
},
|
||||
}
|
||||
|
||||
// If assignee is provided as string, convert it to members
|
||||
if assignee, ok := tDef["assignee"].(string); ok {
|
||||
tDef["members"] = []map[string]interface{}{
|
||||
{
|
||||
"id": assignee,
|
||||
"role": "assignee",
|
||||
"type": "user",
|
||||
},
|
||||
}
|
||||
delete(tDef, "assignee")
|
||||
}
|
||||
|
||||
tData, tErr := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, tDef)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if tErr != nil {
|
||||
summary, _ := tDef["summary"].(string)
|
||||
failedTasks = append(failedTasks, buildTaskCreateFailure(idx, summary, tErr))
|
||||
return
|
||||
}
|
||||
|
||||
if t, ok := tData["task"].(map[string]interface{}); ok {
|
||||
guid, _ := t["guid"].(string)
|
||||
urlVal, _ := t["url"].(string)
|
||||
urlVal = truncateTaskURL(urlVal)
|
||||
createdTasks = append(createdTasks, map[string]interface{}{
|
||||
"guid": guid,
|
||||
"url": urlVal,
|
||||
})
|
||||
}
|
||||
}(i, taskDef)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": tasklistGuid,
|
||||
"url": tasklistUrl,
|
||||
"created_tasks": createdTasks,
|
||||
"failed_tasks": failedTasks,
|
||||
}
|
||||
|
||||
pretty := func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Tasklist created successfully!\n")
|
||||
fmt.Fprintf(w, "Tasklist Name: %s\n", tasklistName)
|
||||
fmt.Fprintf(w, "Tasklist ID: %s\n", tasklistGuid)
|
||||
if tasklistUrl != "" {
|
||||
fmt.Fprintf(w, "Tasklist URL: %s\n", tasklistUrl)
|
||||
}
|
||||
|
||||
if len(tasks) > 0 {
|
||||
fmt.Fprintln(w, strings.Repeat("-", 20))
|
||||
fmt.Fprintf(w, "Tasks created: %d/%d\n", len(createdTasks), len(tasks))
|
||||
for _, t := range createdTasks {
|
||||
guid, _ := t["guid"].(string)
|
||||
urlVal, _ := t["url"].(string)
|
||||
fmt.Fprintf(w, " - ID: %s", guid)
|
||||
if urlVal != "" {
|
||||
fmt.Fprintf(w, ", URL: %s", urlVal)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
if len(failedTasks) > 0 {
|
||||
fmt.Fprintf(w, "\nFailed tasks:\n")
|
||||
for _, f := range failedTasks {
|
||||
fmt.Fprintf(w, " - Index %v (%s): %s\n", f["index"], f["summary"], f["message"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-task creation failures surface as a non-zero exit. JSON/JQ callers
|
||||
// need an ok:false envelope, while pretty output should preserve the
|
||||
// command-specific human-readable summary.
|
||||
if len(failedTasks) > 0 {
|
||||
if runtime.Format == "pretty" && runtime.JqExpr == "" {
|
||||
runtime.OutFormat(outData, nil, pretty)
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return runtime.OutPartialFailure(outData, nil)
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, pretty)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTaskCreateFailure(index int, summary string, err error) map[string]interface{} {
|
||||
failDetail := map[string]interface{}{
|
||||
"index": index,
|
||||
"summary": summary,
|
||||
}
|
||||
if p, ok := errs.ProblemOf(err); ok {
|
||||
failDetail["type"] = string(p.Subtype)
|
||||
failDetail["code"] = p.Code
|
||||
failDetail["message"] = p.Message
|
||||
failDetail["hint"] = p.Hint
|
||||
} else {
|
||||
failDetail["type"] = "api_error"
|
||||
failDetail["message"] = err.Error()
|
||||
}
|
||||
return failDetail
|
||||
}
|
||||
|
||||
func buildTasklistCreateBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": runtime.Str("name"),
|
||||
}
|
||||
|
||||
if memberStr := runtime.Str("member"); memberStr != "" {
|
||||
ids := strings.Split(memberStr, ",")
|
||||
var members []map[string]interface{}
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, map[string]interface{}{
|
||||
"id": id,
|
||||
"role": "editor",
|
||||
"type": "user",
|
||||
})
|
||||
}
|
||||
body["members"] = members
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestCreateTasklist_PartialFailure exercises the batch sub-task path: the
|
||||
// tasklist is created (code 0), then two sub-tasks are created concurrently —
|
||||
// one succeeds, one fails with a typed API error. The command returns the typed
|
||||
// partial-failure exit signal (*output.PartialFailureError, ExitAPI) via
|
||||
// runtime.OutPartialFailure, and stdout carries both created_tasks (the
|
||||
// success) and failed_tasks (the failure) so the partial result is inspectable.
|
||||
// Sub-tasks are routed by summary via BodyFilter because both POST the same
|
||||
// /tasks URL and run on separate goroutines.
|
||||
func TestCreateTasklist_PartialFailure(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"guid": "tl-new",
|
||||
"name": "My List",
|
||||
"url": "https://example.feishu.cn/tl-new",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Succeeding sub-task (summary "ok-task").
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{
|
||||
"guid": "task-ok",
|
||||
"url": "https://example.feishu.cn/task-ok",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Failing sub-task (summary "bad-task") → typed permission_denied.
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": ErrCodeTaskPermissionDenied, "msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
data := `[{"summary":"ok-task"},{"summary":"bad-task"}]`
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--data", data, "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
|
||||
// The tasklist itself is created and stays in the payload.
|
||||
if !strings.Contains(out, "tl-new") {
|
||||
t.Errorf("expected created tasklist guid tl-new in output, got: %s", out)
|
||||
}
|
||||
// Success lands in created_tasks.
|
||||
if !strings.Contains(out, "task-ok") {
|
||||
t.Errorf("expected created sub-task task-ok in output, got: %s", out)
|
||||
}
|
||||
// Failure lands in failed_tasks (keyed by index + summary).
|
||||
if !strings.Contains(out, "bad-task") {
|
||||
t.Errorf("expected failed sub-task bad-task in output, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, string(errs.SubtypePermissionDenied)) {
|
||||
t.Errorf("expected typed subtype %q in failed_tasks, got: %s", errs.SubtypePermissionDenied, out)
|
||||
}
|
||||
if !strings.Contains(out, `"code": 1470403`) && !strings.Contains(out, `"code":1470403`) {
|
||||
t.Errorf("expected task permission code in failed_tasks, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "permission_error") {
|
||||
t.Errorf("legacy type \"permission_error\" leaked into output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTasklist_PartialFailurePrettyOutput(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"guid": "tl-new",
|
||||
"name": "My List",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("ok-task")) },
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"task": map[string]interface{}{"guid": "task-ok"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasks",
|
||||
BodyFilter: func(b []byte) bool { return bytes.Contains(b, []byte("bad-task")) },
|
||||
Body: map[string]interface{}{"code": ErrCodeTaskPermissionDenied, "msg": "no permission"},
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
err := runMountedTaskShortcut(t, s, []string{
|
||||
"+tasklist-create",
|
||||
"--name", "My List",
|
||||
"--data", `[{"summary":"ok-task"},{"summary":"bad-task"}]`,
|
||||
"--as", "bot",
|
||||
"--format", "pretty",
|
||||
}, f, stdout)
|
||||
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T, want *output.PartialFailureError; err = %v", err, err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{
|
||||
"Tasklist created successfully",
|
||||
"Tasks created: 1/2",
|
||||
"Failed tasks:",
|
||||
"Index",
|
||||
"bad-task",
|
||||
"user lacks permission",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("pretty output missing %q; got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"ok":`) {
|
||||
t.Errorf("pretty partial failure should use text output, got JSON envelope:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTasklist_InvalidDataJSON covers the --data validation arm: a string
|
||||
// that is not a JSON array must surface a typed *errs.ValidationError
|
||||
// (invalid_argument, exit 2) after the tasklist create succeeds.
|
||||
func TestCreateTasklist_InvalidDataJSON(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
// No POST /tasklists stub is registered on purpose: invalid --data must be
|
||||
// rejected before any remote write, leaving no orphan tasklist. If the
|
||||
// ordering regressed (create first), the POST would hit no stub and surface
|
||||
// as a non-validation transport error, failing the assertion below.
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--data", "{not-an-array", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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 (ExitValidation)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateTasklist_MalformedResponse covers the create-tasklist parse arm: a
|
||||
// 200 with a non-JSON body must surface a typed
|
||||
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard.
|
||||
func TestCreateTasklist_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := CreateTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-create", "--name", "My List", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %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 (ExitInternal)", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var MembersTasklist = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+tasklist-members",
|
||||
Description: "manage tasklist members",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:tasklist:write"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
|
||||
Flags: []common.Flag{
|
||||
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
|
||||
{Name: "set", Desc: "comma-separated open_ids to set as exact members (replaces existing)"},
|
||||
{Name: "add", Desc: "comma-separated open_ids to add as members"},
|
||||
{Name: "remove", Desc: "comma-separated open_ids to remove from members"},
|
||||
},
|
||||
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
hasSet := runtime.Str("set") != ""
|
||||
hasAdd := runtime.Str("add") != ""
|
||||
hasRemove := runtime.Str("remove") != ""
|
||||
|
||||
if hasSet && (hasAdd || hasRemove) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot combine --set with --add or --remove")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
d := common.NewDryRunAPI()
|
||||
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
|
||||
|
||||
if runtime.Str("set") != "" || (runtime.Str("add") == "" && runtime.Str("remove") == "") {
|
||||
d.Desc("GET tasklist details/members").
|
||||
GET("/open-apis/task/v2/tasklists/" + tlId).
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
}
|
||||
|
||||
if runtime.Str("add") != "" {
|
||||
body := buildTlMembersBody(runtime.Str("add"))
|
||||
d.Desc("Add members").
|
||||
POST("/open-apis/task/v2/tasklists/" + tlId + "/add_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
if runtime.Str("remove") != "" {
|
||||
body := buildTlMembersBody(runtime.Str("remove"))
|
||||
d.Desc("Remove members").
|
||||
POST("/open-apis/task/v2/tasklists/" + tlId + "/remove_members").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"}).
|
||||
Body(body)
|
||||
}
|
||||
|
||||
return d
|
||||
},
|
||||
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
tlId := url.PathEscape(extractTasklistGuid(runtime.Str("tasklist-id")))
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
|
||||
setStr := runtime.Str("set")
|
||||
addStr := runtime.Str("add")
|
||||
removeStr := runtime.Str("remove")
|
||||
|
||||
// If no modifications, just list
|
||||
if setStr == "" && addStr == "" && removeStr == "" {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tl, _ := data["tasklist"].(map[string]interface{})
|
||||
membersRaw, _ := tl["members"].([]interface{})
|
||||
tlUrl, _ := tl["url"].(string)
|
||||
tlUrl = truncateTaskURL(tlUrl)
|
||||
|
||||
var members []interface{}
|
||||
for _, m := range membersRaw {
|
||||
if mObj, ok := m.(map[string]interface{}); ok {
|
||||
members = append(members, map[string]interface{}{
|
||||
"id": mObj["id"],
|
||||
"role": mObj["role"],
|
||||
"type": mObj["type"],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{
|
||||
"guid": tlId,
|
||||
"url": tlUrl,
|
||||
"name": tl["name"],
|
||||
"members": members,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Tasklist: %s (%s)\n", tl["name"], tlId)
|
||||
if tlUrl != "" {
|
||||
fmt.Fprintf(w, "Tasklist URL: %s\n", tlUrl)
|
||||
}
|
||||
fmt.Fprintf(w, "Members (%d):\n", len(members))
|
||||
for _, m := range members {
|
||||
if mObj, ok := m.(map[string]interface{}); ok {
|
||||
fmt.Fprintf(w, " - %s (%s)\n", mObj["id"], mObj["role"])
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var lastTasklist map[string]interface{}
|
||||
if setStr != "" {
|
||||
// Query existing to diff for "set" behavior
|
||||
data, err := callTaskAPITyped(runtime, http.MethodGet, "/open-apis/task/v2/tasklists/"+tlId, params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTasklist, _ = data["tasklist"].(map[string]interface{})
|
||||
|
||||
var existingIds []string
|
||||
if members, ok := lastTasklist["members"].([]interface{}); ok {
|
||||
for _, m := range members {
|
||||
if mObj, ok := m.(map[string]interface{}); ok {
|
||||
if id, ok := mObj["id"].(string); ok {
|
||||
existingIds = append(existingIds, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetIds := strings.Split(setStr, ",")
|
||||
var targetClean []string
|
||||
for _, t := range targetIds {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
targetClean = append(targetClean, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Diff
|
||||
var toAdd []string
|
||||
var toRemove []string
|
||||
|
||||
for _, t := range targetClean {
|
||||
if !contains(existingIds, t) {
|
||||
toAdd = append(toAdd, t)
|
||||
}
|
||||
}
|
||||
for _, e := range existingIds {
|
||||
if !contains(targetClean, e) {
|
||||
toRemove = append(toRemove, e)
|
||||
}
|
||||
}
|
||||
|
||||
if len(toAdd) > 0 {
|
||||
body := buildTlMembersBody(strings.Join(toAdd, ","))
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTasklist, _ = data["tasklist"].(map[string]interface{})
|
||||
}
|
||||
|
||||
if len(toRemove) > 0 {
|
||||
body := buildTlMembersBody(strings.Join(toRemove, ","))
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTasklist, _ = data["tasklist"].(map[string]interface{})
|
||||
}
|
||||
|
||||
} else {
|
||||
// Add / Remove mode
|
||||
if addStr != "" {
|
||||
body := buildTlMembersBody(addStr)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/add_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTasklist, _ = data["tasklist"].(map[string]interface{})
|
||||
}
|
||||
|
||||
if removeStr != "" {
|
||||
body := buildTlMembersBody(removeStr)
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/"+tlId+"/remove_members", params, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastTasklist, _ = data["tasklist"].(map[string]interface{})
|
||||
}
|
||||
}
|
||||
|
||||
tlUrl, _ := lastTasklist["url"].(string)
|
||||
tlUrl = truncateTaskURL(tlUrl)
|
||||
|
||||
// Standardized write output: return resource identifiers
|
||||
outData := map[string]interface{}{
|
||||
"guid": tlId,
|
||||
"url": tlUrl,
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✅ Tasklist members updated successfully!\n")
|
||||
fmt.Fprintf(w, "Tasklist ID: %s\n", tlId)
|
||||
if tlUrl != "" {
|
||||
fmt.Fprintf(w, "Tasklist URL: %s\n", tlUrl)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func buildTlMembersBody(idsStr string) map[string]interface{} {
|
||||
ids := strings.Split(idsStr, ",")
|
||||
var members []map[string]interface{}
|
||||
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
members = append(members, map[string]interface{}{
|
||||
"id": id,
|
||||
"role": "editor",
|
||||
"type": "user",
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"members": members,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestBuildTlMembersBody(t *testing.T) {
|
||||
convey.Convey("Build with ids", t, func() {
|
||||
body := buildTlMembersBody("u1, u2 , ")
|
||||
members := body["members"].([]map[string]interface{})
|
||||
convey.So(len(members), convey.ShouldEqual, 2)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetCombinedWithAddRejected covers the Validate guard:
|
||||
// --set is mutually exclusive with --add/--remove. It must surface a typed
|
||||
// *errs.ValidationError (exit 2) before any API call is made.
|
||||
func TestMembersTasklist_SetCombinedWithAddRejected(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--add", "ou_b", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T, want *errs.ValidationError; err = %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 (ExitValidation)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMembersTasklist_ListMalformedResponse covers the list arm (no
|
||||
// set/add/remove): a 200 with a non-JSON body must surface a typed
|
||||
// *errs.InternalError(invalid_response) (exit 5) from the json.Unmarshal guard,
|
||||
// not a silent success.
|
||||
func TestMembersTasklist_ListMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetMalformedResponse covers the --set arm: the diff path
|
||||
// first GETs the tasklist; a non-JSON body there must surface the typed
|
||||
// internal invalid_response error.
|
||||
func TestMembersTasklist_SetMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_AddMalformedResponse covers the add/remove arm: the POST
|
||||
// to add_members returns a non-JSON body, which must surface the typed internal
|
||||
// invalid_response error.
|
||||
func TestMembersTasklist_AddMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/add_members",
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--add", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_SetRemoveDiffMalformedResponse covers the --set diff's
|
||||
// remove_members arm: the GET returns an existing member absent from the target
|
||||
// set, so the shortcut issues a remove_members POST whose 200 carries a
|
||||
// non-JSON body, which must surface the typed internal invalid_response error.
|
||||
// The target equals one existing member, so no add_members call precedes it.
|
||||
func TestMembersTasklist_SetRemoveDiffMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123",
|
||||
Status: 200,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"tasklist": map[string]interface{}{
|
||||
"url": "https://example.com/tl-123",
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"id": "ou_keep"},
|
||||
map[string]interface{}{"id": "ou_drop"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--set", "ou_keep", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestMembersTasklist_RemoveMalformedResponse covers the add/remove mode's
|
||||
// remove_members arm: with only --remove set, the add arm is skipped and the
|
||||
// remove_members POST returns a non-JSON body, which must surface the typed
|
||||
// internal invalid_response error.
|
||||
func TestMembersTasklist_RemoveMalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/tasklists/tl-123/remove_members",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json"),
|
||||
})
|
||||
|
||||
s := MembersTasklist
|
||||
s.AuthTypes = []string{"bot", "user"}
|
||||
|
||||
args := []string{"+tasklist-members", "--tasklist-id", "tl-123", "--remove", "ou_a", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, s, args, f, stdout)
|
||||
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// assertInvalidResponse asserts a typed *errs.InternalError(invalid_response)
|
||||
// with exit 5 — the contract for a parse-response failure across the members
|
||||
// arms.
|
||||
func assertInvalidResponse(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %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 (ExitInternal)", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user