mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Task commands now return structured, typed errors instead of the legacy exit-code envelope: every failure carries a stable category, subtype, and recovery hint, so callers can branch on the error class instead of parsing messages. Exit codes derive from the error category — input validation exits 2, a permission denial exits 3, other API errors exit 1. Batch operations (adding tasks to a tasklist, creating a tasklist with tasks) now report partial failure honestly: the per-item successes and failures stay on stdout and the command exits non-zero instead of masking failures as a success.
200 lines
6.6 KiB
Go
200 lines
6.6 KiB
Go
// 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)
|
|
}
|
|
}
|