mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
Compare commits
6 Commits
v1.0.9
...
fix/shortc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b88039eeca | ||
|
|
3917b77e91 | ||
|
|
dc0d92708b | ||
|
|
085ffd87f3 | ||
|
|
f6b8091843 | ||
|
|
0e7f507efb |
@@ -127,8 +127,8 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
list := strings.Join(supported, ", ")
|
||||
if f.IdentityAutoDetected {
|
||||
return output.ErrValidation(
|
||||
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
|
||||
as, list, supported[0])
|
||||
"this command requires %s identity, but no user login was found (auto-detected as %s)\nhint: run `lark-cli auth login` first, then retry",
|
||||
list, as)
|
||||
}
|
||||
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
|
||||
}
|
||||
|
||||
@@ -174,15 +174,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
|
||||
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
|
||||
f.IdentityAutoDetected = true
|
||||
|
||||
err := f.CheckIdentity(core.AsUser, []string{"bot"})
|
||||
err := f.CheckIdentity(core.AsBot, []string{"user"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolved identity") {
|
||||
t.Errorf("expected 'resolved identity' in error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "requires user identity") {
|
||||
t.Errorf("expected 'requires user identity' in error, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hint: use --as bot") {
|
||||
t.Errorf("expected hint in error, got: %v", err)
|
||||
if !strings.Contains(err.Error(), "auth login") {
|
||||
t.Errorf("expected 'auth login' hint in error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ const (
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
)
|
||||
|
||||
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
|
||||
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||
// rate limit
|
||||
case LarkErrRateLimit:
|
||||
return ExitAPI, "rate_limit", "please try again later"
|
||||
|
||||
// drive-specific constraints that benefit from actionable hints
|
||||
case LarkErrDriveResourceContention:
|
||||
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
|
||||
case LarkErrDriveCrossTenantUnit:
|
||||
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
|
||||
case LarkErrDriveCrossBrand:
|
||||
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
|
||||
}
|
||||
|
||||
return ExitAPI, "api_error", ""
|
||||
|
||||
64
internal/output/lark_errors_test.go
Normal file
64
internal/output/lark_errors_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
|
||||
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
wantExitCode int
|
||||
wantType string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: LarkErrDriveResourceContention,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
},
|
||||
{
|
||||
name: "cross tenant unit",
|
||||
code: LarkErrDriveCrossTenantUnit,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: LarkErrDriveCrossBrand,
|
||||
wantExitCode: ExitAPI,
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
|
||||
if gotExitCode != tt.wantExitCode {
|
||||
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
|
||||
}
|
||||
if gotHint == "" {
|
||||
t.Fatal("expected non-empty hint")
|
||||
}
|
||||
if !strings.Contains(gotHint, tt.wantHint) {
|
||||
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
136
shortcuts/drive/drive_create_shortcut.go
Normal file
136
shortcuts/drive/drive_create_shortcut.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var driveCreateShortcutAllowedTypes = map[string]bool{
|
||||
"file": true,
|
||||
"docx": true,
|
||||
"bitable": true,
|
||||
"doc": true,
|
||||
"sheet": true,
|
||||
"mindnote": true,
|
||||
"slides": true,
|
||||
}
|
||||
|
||||
type driveCreateShortcutSpec struct {
|
||||
FileToken string
|
||||
FileType string
|
||||
FolderToken string
|
||||
}
|
||||
|
||||
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
|
||||
return driveCreateShortcutSpec{
|
||||
FileToken: strings.TrimSpace(runtime.Str("file-token")),
|
||||
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
|
||||
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
|
||||
}
|
||||
}
|
||||
|
||||
// RequestBody builds the create_shortcut API payload from the shortcut spec.
|
||||
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"parent_token": s.FolderToken,
|
||||
"refer_entity": map[string]interface{}{
|
||||
"refer_token": s.FileToken,
|
||||
"refer_type": s.FileType,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
|
||||
var DriveCreateShortcut = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+create-shortcut",
|
||||
Description: "Create a Drive shortcut in another folder",
|
||||
Risk: "write",
|
||||
Scopes: []string{"space:document:shortcut"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file-token", Desc: "source file token to reference", Required: true},
|
||||
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Create a Drive shortcut").
|
||||
POST("/open-apis/drive/v1/files/create_shortcut").
|
||||
Desc("[1] Create shortcut").
|
||||
Body(spec.RequestBody())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec := newDriveCreateShortcutSpec(runtime)
|
||||
|
||||
fmt.Fprintf(
|
||||
runtime.IO().ErrOut,
|
||||
"Creating shortcut for %s %s in folder %s...\n",
|
||||
spec.FileType,
|
||||
common.MaskToken(spec.FileToken),
|
||||
common.MaskToken(spec.FolderToken),
|
||||
)
|
||||
|
||||
data, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/files/create_shortcut",
|
||||
nil,
|
||||
spec.RequestBody(),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"created": true,
|
||||
"source_file_token": spec.FileToken,
|
||||
"source_type": spec.FileType,
|
||||
"folder_token": spec.FolderToken,
|
||||
}
|
||||
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
|
||||
out["shortcut_token"] = shortcutToken
|
||||
}
|
||||
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
|
||||
out["title"] = title
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
|
||||
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
|
||||
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
}
|
||||
if spec.FileType == "wiki" {
|
||||
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
|
||||
}
|
||||
if spec.FileType == "folder" {
|
||||
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
|
||||
}
|
||||
if !driveCreateShortcutAllowedTypes[spec.FileType] {
|
||||
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
336
shortcuts/drive/drive_create_shortcut_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
|
||||
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
spec driveCreateShortcutSpec
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "wiki",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "wiki_token_test",
|
||||
FileType: "wiki",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "underlying file token first",
|
||||
},
|
||||
{
|
||||
name: "folder",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "folder_token_test",
|
||||
FileType: "folder",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "not folders",
|
||||
},
|
||||
{
|
||||
name: "shortcut",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "shortcut_token_test",
|
||||
FileType: "shortcut",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
{
|
||||
name: "missing folder token",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "docx",
|
||||
},
|
||||
wantErr: "--folder-token must not be empty",
|
||||
},
|
||||
{
|
||||
name: "unknown",
|
||||
spec: driveCreateShortcutSpec{
|
||||
FileToken: "file_token_test",
|
||||
FileType: "unknown",
|
||||
FolderToken: "target_folder_token_test",
|
||||
},
|
||||
wantErr: "Supported types",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateDriveCreateShortcutSpec(tt.spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
|
||||
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
dry := DriveCreateShortcut.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry run: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("unmarshal dry run json: %v", err)
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("expected 1 API call, got %d", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("first method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
|
||||
}
|
||||
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
|
||||
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"succ_shortcut_node": map[string]interface{}{
|
||||
"token": "shortcut_token_test",
|
||||
"name": "shortcut_name_test",
|
||||
"type": "docx",
|
||||
"parent_token": "folder_target_token_test",
|
||||
"url": "https://example.feishu.cn/docx/shortcut_token_test",
|
||||
"shortcut_info": map[string]interface{}{
|
||||
"target_type": "docx",
|
||||
"target_token": "doc_token_test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", " doc_token_test ",
|
||||
"--type", " DOCX ",
|
||||
"--folder-token", " folder_target_token_test ",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
body := decodeCapturedJSONBody(t, createStub)
|
||||
if body["parent_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
|
||||
}
|
||||
referEntity, _ := body["refer_entity"].(map[string]interface{})
|
||||
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
|
||||
t.Fatalf("unexpected refer_entity: %#v", referEntity)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["shortcut_token"] != "shortcut_token_test" {
|
||||
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
|
||||
}
|
||||
if data["folder_token"] != "folder_target_token_test" {
|
||||
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
|
||||
}
|
||||
if data["source_file_token"] != "doc_token_test" {
|
||||
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
|
||||
}
|
||||
if data["title"] != "shortcut_name_test" {
|
||||
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
|
||||
}
|
||||
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
|
||||
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
|
||||
}
|
||||
if data["created"] != true {
|
||||
t.Fatalf("created = %#v, want true", data["created"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
|
||||
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
|
||||
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
|
||||
FileToken: "doc_token_test",
|
||||
FileType: "docx",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
|
||||
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +create-shortcut"}
|
||||
cmd.Flags().String("file-token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("folder-token", "", "")
|
||||
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
|
||||
t.Fatalf("set --file-token: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("folder-token", " "); err != nil {
|
||||
t.Fatalf("set --folder-token: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContext(cmd, nil)
|
||||
err := DriveCreateShortcut.Validate(context.Background(), runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
|
||||
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
msg string
|
||||
wantType string
|
||||
wantHint string
|
||||
wantMsgPart string
|
||||
}{
|
||||
{
|
||||
name: "resource contention",
|
||||
code: output.LarkErrDriveResourceContention,
|
||||
msg: "resource contention occurred, please retry",
|
||||
wantType: "conflict",
|
||||
wantHint: "avoid concurrent duplicate requests",
|
||||
wantMsgPart: "resource contention occurred",
|
||||
},
|
||||
{
|
||||
name: "cross tenant and unit",
|
||||
code: output.LarkErrDriveCrossTenantUnit,
|
||||
msg: "cross tenant and unit not support",
|
||||
wantType: "cross_tenant_unit",
|
||||
wantHint: "same tenant and region/unit",
|
||||
wantMsgPart: "cross tenant and unit not support",
|
||||
},
|
||||
{
|
||||
name: "cross brand",
|
||||
code: output.LarkErrDriveCrossBrand,
|
||||
msg: "cross brand not support",
|
||||
wantType: "cross_brand",
|
||||
wantHint: "same brand environment",
|
||||
wantMsgPart: "cross brand not support",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_shortcut",
|
||||
Body: map[string]interface{}{
|
||||
"code": float64(tt.code),
|
||||
"msg": tt.msg,
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
|
||||
"+create-shortcut",
|
||||
"--file-token", "doc_token_test",
|
||||
"--type", "docx",
|
||||
"--folder-token", "folder_token_test",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured exit error, got %v", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail.Type != tt.wantType {
|
||||
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
|
||||
}
|
||||
if exitErr.Detail.Code != tt.code {
|
||||
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
|
||||
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
|
||||
}
|
||||
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
|
||||
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
DriveUpload,
|
||||
DriveCreateShortcut,
|
||||
DriveDownload,
|
||||
DriveAddComment,
|
||||
DriveExport,
|
||||
|
||||
@@ -5,12 +5,14 @@ package drive
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+upload",
|
||||
"+create-shortcut",
|
||||
"+download",
|
||||
"+add-comment",
|
||||
"+export",
|
||||
|
||||
@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
|
||||
msgFormat := runtime.Str("msg-format")
|
||||
outputDir := runtime.Str("output-dir")
|
||||
if outputDir != "" {
|
||||
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
|
||||
home, err := vfs.UserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot expand ~: %w", err)
|
||||
}
|
||||
if outputDir == "~" {
|
||||
outputDir = home
|
||||
} else {
|
||||
outputDir = filepath.Join(home, outputDir[2:])
|
||||
}
|
||||
} else if filepath.IsAbs(outputDir) {
|
||||
outputDir = filepath.Clean(outputDir)
|
||||
} else {
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
|
||||
// literal relative path (creating a directory named "~"), which is
|
||||
// confusing. This also covers ~user/path forms.
|
||||
if strings.HasPrefix(outputDir, "~") {
|
||||
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
|
||||
}
|
||||
// Resolve symlinks on the output directory so all writes use the real
|
||||
// filesystem path. This prevents a symlink from redirecting writes to
|
||||
// an unintended location (TOCTOU mitigation).
|
||||
// Enforce CWD containment: reject absolute paths, path traversal,
|
||||
// and symlink escapes. SafeOutputPath returns a resolved absolute path
|
||||
// under CWD, preventing writes to arbitrary system directories.
|
||||
safePath, err := validate.SafeOutputPath(outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir = safePath
|
||||
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
|
||||
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(outputDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve output directory: %w", err)
|
||||
}
|
||||
outputDir = resolved
|
||||
}
|
||||
labelIDsInput := runtime.Str("label-ids")
|
||||
folderIDsInput := runtime.Str("folder-ids")
|
||||
|
||||
@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
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 {
|
||||
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"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"}).
|
||||
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
|
||||
"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{}
|
||||
|
||||
|
||||
43
shortcuts/task/tasklist_add_task_test.go
Normal file
43
shortcuts/task/tasklist_add_task_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,8 @@ lark-cli docs +create --title "产品需求" --markdown '<callout emoji="💡" b
|
||||
|
||||
- 使用标准 Markdown 语法作为基础
|
||||
- 使用自定义 XML 标签实现飞书特有功能(具体标签见各功能章节)
|
||||
- 需要显示特殊字符时使用反斜杠转义:`* ~ ` $ [ ] < > { } | ^`
|
||||
- 只有当字符会被解释为 Markdown / Lark 富文本语法时,才需要使用反斜杠转义:``* ~ ` $ [ ] < > { } | ^``
|
||||
- 普通文本中的孤立字符不要过度转义。例如 `5 * 3`、`version~1.0`、`final_trajectory` 通常应保持原样,只有像 `*斜体*`、`**粗体**`、`~~删除线~~` 这种会触发格式化的写法,想按字面量显示时才需要转义
|
||||
|
||||
---
|
||||
|
||||
@@ -657,7 +658,7 @@ $$
|
||||
## 最佳实践
|
||||
|
||||
- **空行分隔**:不同块类型之间用空行分隔
|
||||
- **转义字符**:特殊字符用 `\` 转义:`\*` `\~` `\``
|
||||
- **转义字符**:只有在字符会触发格式化时才用 `\` 转义。例如想输出字面量 `*斜体*` 时写成 `\*斜体\*`;但 `5 * 3`、`version~1.0`、`final_trajectory` 这类普通文本通常不需要转义
|
||||
- **图片**:使用 URL,系统自动下载上传
|
||||
- **分栏**:列宽总和必须为 100
|
||||
- **表格选择**:简单数据用 Markdown,复杂嵌套用 `<lark-table>`
|
||||
|
||||
@@ -194,6 +194,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
|----------|------|
|
||||
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive |
|
||||
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) |
|
||||
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token |
|
||||
|
||||
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
103
skills/lark-drive/references/lark-drive-create-shortcut.md
Normal file
@@ -0,0 +1,103 @@
|
||||
|
||||
# drive +create-shortcut
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
在目标文件夹中为一个现有 Drive 文件创建快捷方式。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 为普通文件创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <FILE_TOKEN> \
|
||||
--type file
|
||||
|
||||
# 为新版文档创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx
|
||||
|
||||
# 为电子表格创建快捷方式
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <SHEET_TOKEN> \
|
||||
--type sheet
|
||||
|
||||
# 仅预览即将发起的请求,不真正执行
|
||||
lark-cli drive +create-shortcut \
|
||||
--folder-token <TARGET_FOLDER_TOKEN> \
|
||||
--file-token <DOCX_TOKEN> \
|
||||
--type docx \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--folder-token` | 是 | 目标父文件夹 token |
|
||||
| `--file-token` | 是 | 源文件 token,表示被引用的原始文件 |
|
||||
| `--type` | 是 | 源文件类型,推荐值:`file`、`docx`、`doc`、`sheet`、`bitable`、`mindnote`、`slides` |
|
||||
|
||||
## 输入规则
|
||||
|
||||
- 该 shortcut 的最小输入是 `--folder-token` + `--file-token` + `--type`
|
||||
- CLI 层会把 `--file-token` 和 `--type` 组装为底层 API 所需的 `refer_entity`
|
||||
- `--file-token` 必须是 Drive 文件 token,不要直接传 wiki 节点 token
|
||||
- 如果来源是 `/wiki/...` 链接,必须先按 [`lark-drive`](../SKILL.md) 中的 wiki 解析流程拿到真实 `obj_token`,再创建快捷方式
|
||||
- 目标位置必须是云空间文件夹;这个 shortcut 不是“复制文件内容”,而是“在另一个文件夹里挂一个引用入口”
|
||||
|
||||
## 类型说明
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| `file` | 普通文件 |
|
||||
| `docx` | 新版云文档 |
|
||||
| `doc` | 旧版云文档 |
|
||||
| `sheet` | 电子表格 |
|
||||
| `bitable` | 多维表格 |
|
||||
| `mindnote` | 思维笔记 |
|
||||
| `slides` | 幻灯片 |
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 成功时会调用 `POST /open-apis/drive/v1/files/create_shortcut`
|
||||
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto`、`--format`、`--jq`、`--dry-run` 使用
|
||||
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建快捷方式
|
||||
- 这是写入操作;执行前应确认目标文件夹和源文件都准确无误
|
||||
|
||||
## 限制
|
||||
|
||||
- 该接口不支持并发调用
|
||||
- 调用频率上限为 5 QPS,且 10000 次/天
|
||||
- 不支持跨租户、跨地域创建快捷方式
|
||||
- 不支持跨品牌创建快捷方式
|
||||
- 如果目标父文件夹单层挂载数量超过限制,会返回 `1062507`
|
||||
|
||||
## 权限要求
|
||||
|
||||
- 当前调用身份需要能访问源文件
|
||||
- 当前调用身份需要对目标文件夹有编辑权限
|
||||
- 如果权限不足,常见表现为 `1061004 forbidden`
|
||||
|
||||
## 常见错误
|
||||
|
||||
| 错误码 / 错误信息 | 原因 | 处理建议 |
|
||||
|------|------|------|
|
||||
| `1061002 params error` | 缺少必填参数,或 `--file-token` / `--type` 组合无法构成有效源文件信息 | 检查 `--file-token`、`--type` 是否完整且匹配;如显式传了 `--folder-token`,再确认其值有效 |
|
||||
| `1061003 not found` | 源文件或目标文件夹不存在 | 重新确认 token 是否正确、资源是否已删除 |
|
||||
| `1061004 forbidden` | 对源文件没有访问权限,或对目标文件夹没有编辑权限 | 切换到有权限的身份,或先授予文档 / 文件夹权限 |
|
||||
| `1061005 auth failed` | 身份类型或 access token 不正确 | 检查 `--as` 使用的身份及当前登录态 |
|
||||
| `1061007 file has been delete` | 源文件已删除 | 确认原文件仍存在,再重新执行 |
|
||||
| `1062507 parent node out of sibling num` | 目标文件夹单层挂载数超过上限 | 清理目标目录,或换一个父文件夹 |
|
||||
| `1061045 resource contention occurred, please retry` | 平台内部资源争抢 | 稍后重试,不要并发重复调用 |
|
||||
| `1064510 cross tenant and unit not support` | 跨租户或跨地域请求 | 改为在同租户、同地域范围内操作 |
|
||||
| `1064511 cross brand not support` | 跨品牌请求 | 改为在同品牌环境内操作 |
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间全部命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -83,6 +83,15 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
- `add` — 添加任务成员
|
||||
- `remove` — 移除任务成员
|
||||
|
||||
### sections
|
||||
|
||||
- `create` — 创建自定义分组
|
||||
- `delete` — 删除自定义分组
|
||||
- `get` — 获取自定义分组详情
|
||||
- `list` — 获取自定义分组列表
|
||||
- `patch` — 更新自定义分组
|
||||
- `tasks` — 获取自定义分组任务列表
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
@@ -104,3 +113,9 @@ lark-cli task <resource> <method> [flags] # 调用 API
|
||||
| `subtasks.list` | `task:task:read` |
|
||||
| `members.add` | `task:task:write` |
|
||||
| `members.remove` | `task:task:write` |
|
||||
| `sections.create` | `task:section:write` |
|
||||
| `sections.delete` | `task:section:write` |
|
||||
| `sections.get` | `task:section:read` |
|
||||
| `sections.list` | `task:section:read` |
|
||||
| `sections.patch` | `task:section:write` |
|
||||
| `sections.tasks` | `task:section:read` |
|
||||
|
||||
@@ -12,6 +12,12 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|
||||
|
||||
# Add multiple tasks to a tasklist
|
||||
lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<task_guid>,<another_task_guid>,<third_task_guid>"
|
||||
|
||||
# Add a task to a specific section in the tasklist
|
||||
lark-cli task +tasklist-task-add \
|
||||
--tasklist-id "<tasklist_guid>" \
|
||||
--task-id "<task_guid>" \
|
||||
--section-guid "<section_guid>"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
@@ -20,6 +26,7 @@ lark-cli task +tasklist-task-add --tasklist-id "<tasklist_guid>" --task-id "<tas
|
||||
|-----------|----------|-------------|
|
||||
| `--tasklist-id <guid>` | Yes | The GUID of the tasklist, or a full AppLink URL. |
|
||||
| `--task-id <guids>` | Yes | Comma-separated list of task GUIDs to add to the tasklist. For Feishu task applinks, use each task's `guid` query parameter, not the `suite_entity_num` / display task ID like `t104121`. |
|
||||
| `--section-guid <guid>` | No | The GUID of the custom section to add the tasks to. If omitted, tasks will be added to the default section. |
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
77
tests/cli_e2e/base/base_basic_workflow_test.go
Normal file
77
tests/cli_e2e/base/base_basic_workflow_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBase_BasicWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
baseName := "lark-cli-e2e-base-basic-" + clie2e.GenerateSuffix()
|
||||
baseToken := createBaseWithRetry(t, ctx, baseName)
|
||||
|
||||
t.Run("get base", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+base-get", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
returnedBaseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
|
||||
if returnedBaseToken == "" {
|
||||
returnedBaseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
|
||||
}
|
||||
assert.Equal(t, baseToken, returnedBaseToken, "stdout:\n%s", result.Stdout)
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.base.name").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
tableName := "lark-cli-e2e-table-basic-" + clie2e.GenerateSuffix()
|
||||
tableID, primaryFieldID, primaryViewID := createTableWithRetry(
|
||||
t,
|
||||
parentT,
|
||||
ctx,
|
||||
baseToken,
|
||||
tableName,
|
||||
`[{"name":"Name","type":"text"}]`,
|
||||
`{"name":"Main","type":"grid"}`,
|
||||
)
|
||||
|
||||
t.Run("get table", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-get", "--base-token", baseToken, "--table-id", tableID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, tableID, gjson.Get(result.Stdout, "data.table.id").String())
|
||||
assert.Equal(t, tableName, gjson.Get(result.Stdout, "data.table.name").String())
|
||||
})
|
||||
|
||||
t.Run("list tables and find created table", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+table-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.True(t, gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`).Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
require.NotEmpty(t, primaryFieldID)
|
||||
require.NotEmpty(t, primaryViewID)
|
||||
}
|
||||
127
tests/cli_e2e/base/base_role_workflow_test.go
Normal file
127
tests/cli_e2e/base/base_role_workflow_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestBase_RoleWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-role-"+clie2e.GenerateSuffix())
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+advperm-enable", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
roleName := "Reviewer-" + clie2e.GenerateSuffix()
|
||||
createRole(t, ctx, baseToken, `{"role_name":"`+roleName+`","role_type":"custom_role"}`)
|
||||
roleID := ""
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
if roleID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cleanupCtx, cancel := cleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"base", "+role-delete", "--base-token", baseToken, "--role-id", roleID, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if deleteErr != nil || deleteResult.ExitCode != 0 {
|
||||
reportCleanupFailure(parentT, "delete role "+roleID, deleteResult, deleteErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-list", "--base-token", baseToken},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
roleListPayload := gjson.Get(result.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, roleListPayload, "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, gjson.Valid(roleListPayload), "role list payload should be valid JSON: %s", roleListPayload)
|
||||
|
||||
roleItems := gjson.Get(roleListPayload, "base_roles").Array()
|
||||
assert.NotEmpty(t, roleItems, "role list should contain at least one role: %s", roleListPayload)
|
||||
|
||||
found := false
|
||||
for _, item := range roleItems {
|
||||
rolePayload := item.String()
|
||||
if !gjson.Valid(rolePayload) {
|
||||
continue
|
||||
}
|
||||
if gjson.Get(rolePayload, "role_name").String() == roleName {
|
||||
roleID = gjson.Get(rolePayload, "role_id").String()
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, roleID, "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before get")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
rolePayload := gjson.Get(result.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, rolePayload, "stdout:\n%s", result.Stdout)
|
||||
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", result.Stdout)
|
||||
assert.Equal(t, roleID, gjson.Get(rolePayload, "role_id").String())
|
||||
})
|
||||
|
||||
t.Run("update", func(t *testing.T) {
|
||||
require.NotEmpty(t, roleID, "role ID should be resolved before update")
|
||||
|
||||
updatedRoleName := roleName + " Updated"
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-update", "--base-token", baseToken, "--role-id", roleID, "--json", `{"role_name":"` + updatedRoleName + `","role_type":"custom_role"}`, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, true)
|
||||
|
||||
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
|
||||
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
|
||||
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
|
||||
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
|
||||
})
|
||||
|
||||
}
|
||||
164
tests/cli_e2e/base/helpers_test.go
Normal file
164
tests/cli_e2e/base/helpers_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const cleanupTimeout = 30 * time.Second
|
||||
|
||||
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
|
||||
parentT.Helper()
|
||||
|
||||
if err != nil {
|
||||
parentT.Errorf("%s: %v", prefix, err)
|
||||
return
|
||||
}
|
||||
if result == nil {
|
||||
parentT.Errorf("%s: nil result", prefix)
|
||||
return
|
||||
}
|
||||
if isCleanupSuppressedResult(result) {
|
||||
return
|
||||
}
|
||||
|
||||
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
func cleanupContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), cleanupTimeout)
|
||||
}
|
||||
|
||||
func isCleanupSuppressedResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(result.Stdout)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(result.Stderr)
|
||||
}
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
start := strings.LastIndex(raw, "\n{")
|
||||
if start >= 0 {
|
||||
start++
|
||||
} else {
|
||||
start = strings.Index(raw, "{")
|
||||
}
|
||||
if start < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
payload := raw[start:]
|
||||
if !gjson.Valid(payload) {
|
||||
return false
|
||||
}
|
||||
|
||||
if gjson.Get(payload, "error.type").String() != "api_error" {
|
||||
return false
|
||||
}
|
||||
|
||||
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
|
||||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
|
||||
return true
|
||||
}
|
||||
|
||||
return gjson.Get(payload, "error.code").Int() == 800004135 ||
|
||||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
|
||||
}
|
||||
|
||||
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
|
||||
if baseToken == "" {
|
||||
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
|
||||
}
|
||||
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
|
||||
return baseToken
|
||||
}
|
||||
|
||||
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
|
||||
t.Helper()
|
||||
|
||||
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name}
|
||||
if fieldsJSON != "" {
|
||||
args = append(args, "--fields", fieldsJSON)
|
||||
}
|
||||
if viewJSON != "" {
|
||||
args = append(args, "--view", viewJSON)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
tableID = gjson.Get(result.Stdout, "data.table.id").String()
|
||||
if tableID == "" {
|
||||
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
|
||||
}
|
||||
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
|
||||
|
||||
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
|
||||
if primaryFieldID == "" {
|
||||
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
|
||||
}
|
||||
|
||||
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
|
||||
if primaryViewID == "" {
|
||||
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
|
||||
}
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := cleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
if deleteErr != nil || deleteResult.ExitCode != 0 {
|
||||
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
|
||||
}
|
||||
})
|
||||
|
||||
return tableID, primaryFieldID, primaryViewID
|
||||
}
|
||||
|
||||
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
return gjson.Get(result.Stdout, "data.role_id").String()
|
||||
}
|
||||
86
tests/cli_e2e/calendar/calendar_create_event_test.go
Normal file
86
tests/cli_e2e/calendar/calendar_create_event_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestCalendar_CreateEvent tests the workflow of creating a calendar event.
|
||||
func TestCalendar_CreateEvent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
eventSummary := "lark-cli-e2e-event-" + suffix
|
||||
eventDescription := "test event description"
|
||||
|
||||
startAt := time.Now().UTC().Add(1 * time.Hour).Truncate(time.Minute)
|
||||
endAt := startAt.Add(1 * time.Hour)
|
||||
startTime := startAt.Format(time.RFC3339)
|
||||
endTime := endAt.Format(time.RFC3339)
|
||||
|
||||
var eventID string
|
||||
calendarID := getPrimaryCalendarID(t, ctx)
|
||||
|
||||
t.Run("create event with shortcut", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "+create",
|
||||
"--summary", eventSummary,
|
||||
"--start", startTime,
|
||||
"--end", endTime,
|
||||
"--calendar-id", calendarID,
|
||||
"--description", eventDescription,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
eventID = gjson.Get(result.Stdout, "data.event_id").String()
|
||||
require.NotEmpty(t, eventID)
|
||||
})
|
||||
|
||||
t.Run("verify event created", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, eventSummary, gjson.Get(result.Stdout, "data.event.summary").String())
|
||||
assert.Equal(t, eventDescription, gjson.Get(result.Stdout, "data.event.description").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(startAt), gjson.Get(result.Stdout, "data.event.start_time.timestamp").String())
|
||||
assert.Equal(t, unixSecondsRFC3339(endAt), gjson.Get(result.Stdout, "data.event.end_time.timestamp").String())
|
||||
})
|
||||
|
||||
t.Run("delete event", func(t *testing.T) {
|
||||
require.NotEmpty(t, eventID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "events", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": calendarID,
|
||||
"event_id": eventID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
136
tests/cli_e2e/calendar/calendar_manage_calendar_test.go
Normal file
136
tests/cli_e2e/calendar/calendar_manage_calendar_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestCalendar_ManageCalendar tests the workflow of managing calendars.
|
||||
func TestCalendar_ManageCalendar(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
calendarSummary := "lark-cli-e2e-cal-" + suffix
|
||||
updatedCalendarSummary := calendarSummary + "-updated"
|
||||
calendarDescription := "test calendar created by e2e"
|
||||
|
||||
var createdCalendarID string
|
||||
|
||||
t.Run("list calendars", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
require.NotEmpty(t, gjson.Get(result.Stdout, "data.calendar_list").Array(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("get primary calendar", func(t *testing.T) {
|
||||
primaryCalendarID := getPrimaryCalendarID(t, ctx)
|
||||
require.NotEmpty(t, primaryCalendarID)
|
||||
})
|
||||
|
||||
t.Run("create calendar", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "create"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"summary": calendarSummary,
|
||||
"description": calendarDescription,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
createdCalendarID = gjson.Get(result.Stdout, "data.calendar.calendar_id").String()
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
})
|
||||
|
||||
t.Run("get created calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, createdCalendarID, gjson.Get(result.Stdout, "data.calendar_id").String())
|
||||
assert.Equal(t, calendarSummary, gjson.Get(result.Stdout, "data.summary").String())
|
||||
assert.Equal(t, calendarDescription, gjson.Get(result.Stdout, "data.description").String())
|
||||
})
|
||||
|
||||
t.Run("find created calendar in list", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "list"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
require.True(t, gjson.Get(result.Stdout, `data.calendar_list.#(calendar_id=="`+createdCalendarID+`")`).Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "patch"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"summary": updatedCalendarSummary,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("verify updated calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
assert.Equal(t, updatedCalendarSummary, gjson.Get(result.Stdout, "data.summary").String())
|
||||
})
|
||||
|
||||
t.Run("delete calendar", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdCalendarID)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"calendar_id": createdCalendarID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
35
tests/cli_e2e/calendar/helpers_test.go
Normal file
35
tests/cli_e2e/calendar/helpers_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func getPrimaryCalendarID(t *testing.T, ctx context.Context) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"calendar", "calendars", "primary"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
calendarID := gjson.Get(result.Stdout, "data.calendars.0.calendar.calendar_id").String()
|
||||
require.NotEmpty(t, calendarID, "stdout:\n%s", result.Stdout)
|
||||
return calendarID
|
||||
}
|
||||
|
||||
func unixSecondsRFC3339(t time.Time) string {
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
51
tests/cli_e2e/contact/contact_shortcut_test.go
Normal file
51
tests/cli_e2e/contact/contact_shortcut_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
)
|
||||
|
||||
func TestContact_GetUser_BotWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
var targetOpenID string
|
||||
|
||||
t.Run("discover-user-via-api", func(t *testing.T) {
|
||||
// Bot identity cannot use +search-user or +get-user (self).
|
||||
// However, it CAN call the raw API to list users if it has contact permissions.
|
||||
// We use this to discover a real open_id for the next step.
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/contact/v3/users"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
targetOpenID = gjson.Get(result.Stdout, "data.items.0.open_id").String()
|
||||
|
||||
require.NotEmpty(t, targetOpenID, "expected to find at least one user via raw API")
|
||||
})
|
||||
|
||||
t.Run("get-user-by-id-as-bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, targetOpenID, "targetOpenID should be populated")
|
||||
// DefaultAs is automatically "bot" in the clie2e framework
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"contact", "+get-user", "--user-id", targetOpenID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
returnedID := gjson.Get(result.Stdout, "data.user.open_id").String()
|
||||
require.Equal(t, targetOpenID, returnedID)
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -57,6 +58,15 @@ type Result struct {
|
||||
RunErr error
|
||||
}
|
||||
|
||||
// RetryOptions configures retry behavior for flaky external API calls.
|
||||
type RetryOptions struct {
|
||||
Attempts int
|
||||
InitialDelay time.Duration
|
||||
MaxDelay time.Duration
|
||||
BackoffMultiple int
|
||||
ShouldRetry func(*Result) bool
|
||||
}
|
||||
|
||||
// RunCmd executes lark-cli and captures stdout/stderr/exit code.
|
||||
func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
binaryPath, err := ResolveBinaryPath(req)
|
||||
@@ -99,6 +109,63 @@ func RunCmd(ctx context.Context, req Request) (*Result, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RunCmdWithRetry reruns a command when the result matches the configured retry condition.
|
||||
func RunCmdWithRetry(ctx context.Context, req Request, opts RetryOptions) (*Result, error) {
|
||||
if opts.Attempts <= 0 {
|
||||
opts.Attempts = 4
|
||||
}
|
||||
if opts.InitialDelay <= 0 {
|
||||
opts.InitialDelay = 1 * time.Second
|
||||
}
|
||||
if opts.MaxDelay <= 0 {
|
||||
opts.MaxDelay = 6 * time.Second
|
||||
}
|
||||
if opts.BackoffMultiple <= 1 {
|
||||
opts.BackoffMultiple = 2
|
||||
}
|
||||
if opts.ShouldRetry == nil {
|
||||
opts.ShouldRetry = func(result *Result) bool {
|
||||
return result != nil && result.ExitCode != 0
|
||||
}
|
||||
}
|
||||
|
||||
delay := opts.InitialDelay
|
||||
var lastResult *Result
|
||||
for attempt := 1; attempt <= opts.Attempts; attempt++ {
|
||||
result, err := RunCmd(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lastResult = result
|
||||
if attempt == opts.Attempts || !opts.ShouldRetry(result) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return lastResult, nil
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
nextDelay := delay * time.Duration(opts.BackoffMultiple)
|
||||
if nextDelay > opts.MaxDelay {
|
||||
delay = opts.MaxDelay
|
||||
} else {
|
||||
delay = nextDelay
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult, nil
|
||||
}
|
||||
|
||||
// GenerateSuffix returns a high-entropy UTC timestamp suffix suitable for remote test resource names.
|
||||
func GenerateSuffix() string {
|
||||
now := time.Now().UTC()
|
||||
return fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond())
|
||||
}
|
||||
|
||||
// ResolveBinaryPath finds the CLI binary path using request, env, then PATH.
|
||||
func ResolveBinaryPath(req Request) (string, error) {
|
||||
if req.BinaryPath != "" {
|
||||
|
||||
48
tests/cli_e2e/docs/docs_create_fetch_test.go
Normal file
48
tests/cli_e2e/docs/docs_create_fetch_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDocs_CreateAndFetchWorkflow tests the create and fetch lifecycle.
|
||||
func TestDocs_CreateAndFetchWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-docs-folder-" + suffix
|
||||
docTitle := "lark-cli-e2e-docs-" + suffix
|
||||
docContent := "# Test Document\n\nThis document was created by lark-cli e2e test."
|
||||
|
||||
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
|
||||
var docToken string
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
docToken = createDocWithRetry(t, ctx, folderToken, docTitle, docContent)
|
||||
})
|
||||
|
||||
t.Run("fetch", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before fetch")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, docTitle, gjson.Get(result.Stdout, "data.title").String())
|
||||
})
|
||||
}
|
||||
67
tests/cli_e2e/docs/docs_update_test.go
Normal file
67
tests/cli_e2e/docs/docs_update_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDocs_UpdateWorkflow tests the create, update, and verify lifecycle.
|
||||
func TestDocs_UpdateWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-update-folder-" + suffix
|
||||
originalTitle := "lark-cli-e2e-update-" + suffix
|
||||
updatedTitle := "lark-cli-e2e-update-updated-" + suffix
|
||||
originalContent := "# Original\n\nThis is the original content."
|
||||
updatedContent := "# Updated\n\nThis is the updated content."
|
||||
|
||||
folderToken := createDocsFolderWithRetry(t, ctx, folderName)
|
||||
var docToken string
|
||||
|
||||
t.Run("create", func(t *testing.T) {
|
||||
docToken = createDocWithRetry(t, ctx, folderToken, originalTitle, originalContent)
|
||||
})
|
||||
|
||||
t.Run("update-title-and-content", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before update")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+update",
|
||||
"--doc", docToken,
|
||||
"--mode", "overwrite",
|
||||
"--markdown", updatedContent,
|
||||
"--new-title", updatedTitle,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("verify", func(t *testing.T) {
|
||||
require.NotEmpty(t, docToken, "document token should be created before verify")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+fetch",
|
||||
"--doc", docToken,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
assert.Equal(t, updatedTitle, gjson.Get(result.Stdout, "data.title").String())
|
||||
})
|
||||
}
|
||||
54
tests/cli_e2e/docs/helpers_test.go
Normal file
54
tests/cli_e2e/docs/helpers_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package docs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createDocsFolderWithRetry(t *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "create_folder"},
|
||||
Data: map[string]any{
|
||||
"name": name,
|
||||
"folder_token": "",
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.token").String()
|
||||
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
|
||||
|
||||
return folderToken
|
||||
}
|
||||
|
||||
func createDocWithRetry(t *testing.T, ctx context.Context, folderToken string, title string, markdown string) string {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, folderToken, "folder token is required")
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--folder-token", folderToken,
|
||||
"--title", title,
|
||||
"--markdown", markdown,
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
docToken := gjson.Get(result.Stdout, "data.doc_id").String()
|
||||
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
|
||||
return docToken
|
||||
}
|
||||
29
tests/cli_e2e/drive/drive_files_workflow_test.go
Normal file
29
tests/cli_e2e/drive/drive_files_workflow_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
)
|
||||
|
||||
// TestDrive_FilesCreateFolderWorkflow tests the files create_folder resource command.
|
||||
func TestDrive_FilesCreateFolderWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-folder-" + suffix
|
||||
|
||||
t.Run("create_folder", func(t *testing.T) {
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName)
|
||||
if folderToken == "" {
|
||||
t.Fatalf("folder token should be available")
|
||||
}
|
||||
})
|
||||
}
|
||||
44
tests/cli_e2e/drive/helpers_test.go
Normal file
44
tests/cli_e2e/drive/helpers_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// createDriveFolder creates a private folder for the current workflow and
|
||||
// deletes it during cleanup.
|
||||
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "create_folder"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"name": name,
|
||||
"folder_token": "",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.token").String()
|
||||
require.NotEmpty(t, folderToken, "stdout:\n%s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
clie2e.RunCmd(context.Background(), clie2e.Request{
|
||||
Args: []string{"drive", "files", "delete"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"file_token": folderToken, "type": "folder"},
|
||||
})
|
||||
})
|
||||
|
||||
return folderToken
|
||||
}
|
||||
124
tests/cli_e2e/im/chat_workflow_test.go
Normal file
124
tests/cli_e2e/im/chat_workflow_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
|
||||
func TestIM_ChatUpdateWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
originalName := "lark-cli-e2e-im-update-" + suffix
|
||||
updatedName := originalName + "-updated"
|
||||
updatedDescription := "Updated description for e2e test"
|
||||
|
||||
chatID := createChat(t, parentT, ctx, originalName)
|
||||
|
||||
t.Run("update chat name", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-update",
|
||||
"--chat-id", chatID,
|
||||
"--name", updatedName,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("update chat description", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-update",
|
||||
"--chat-id", chatID,
|
||||
"--description", updatedDescription,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("get updated chat", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "get"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
|
||||
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
|
||||
})
|
||||
}
|
||||
|
||||
// TestIM_ChatsGetWorkflow tests the im chats get command.
|
||||
func TestIM_ChatsGetWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-chats-get-" + suffix
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
|
||||
t.Run("get chat info", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "get"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Logf("chats get result: %s", result.Stdout)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
dataExists := gjson.Get(result.Stdout, "data").Exists()
|
||||
require.True(t, dataExists, "data object should exist")
|
||||
|
||||
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
|
||||
require.Equal(t, chatName, chatNameGot)
|
||||
})
|
||||
}
|
||||
|
||||
// TestIM_ChatsLinkWorkflow tests the im chats link command.
|
||||
func TestIM_ChatsLinkWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-chats-link-" + suffix
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
|
||||
t.Run("get chat share link", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "chats", "link"},
|
||||
Params: map[string]any{"chat_id": chatID},
|
||||
Data: map[string]any{
|
||||
"validity_period": "week",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
|
||||
require.NotEmpty(t, shareLink, "share_link should not be empty")
|
||||
t.Logf("Generated share link: %s", shareLink)
|
||||
})
|
||||
}
|
||||
60
tests/cli_e2e/im/helpers_test.go
Normal file
60
tests/cli_e2e/im/helpers_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// createChat creates a private chat with the given name and returns the chatID.
|
||||
// The chat will be automatically cleaned up via parentT.Cleanup().
|
||||
// Note: Chat deletion is not available via lark-cli im command.
|
||||
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+chat-create",
|
||||
"--name", name,
|
||||
"--type", "private",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
|
||||
require.NotEmpty(t, chatID, "chat_id should not be empty")
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// No IM chat delete command is currently available in lark-cli,
|
||||
// so created chats are intentionally left in the test account.
|
||||
})
|
||||
|
||||
return chatID
|
||||
}
|
||||
|
||||
// sendMessage sends a text message to the specified chat and returns the messageID.
|
||||
func sendMessage(t *testing.T, parentT *testing.T, ctx context.Context, chatID string, text string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-send",
|
||||
"--chat-id", chatID,
|
||||
"--text", text,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
messageID := gjson.Get(result.Stdout, "data.message_id").String()
|
||||
require.NotEmpty(t, messageID, "message_id should not be empty")
|
||||
|
||||
return messageID
|
||||
}
|
||||
53
tests/cli_e2e/im/message_workflow_test.go
Normal file
53
tests/cli_e2e/im/message_workflow_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestIM_MessagesReplyWorkflow tests the +messages-reply shortcut.
|
||||
func TestIM_MessagesReplyWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
chatName := "lark-cli-e2e-im-reply-" + suffix
|
||||
originalMessage := "Original message for reply test"
|
||||
replyText := "This is a reply"
|
||||
|
||||
chatID := createChat(t, parentT, ctx, chatName)
|
||||
messageID := sendMessage(t, parentT, ctx, chatID, originalMessage)
|
||||
|
||||
t.Run("reply to message with text", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--text", replyText,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("reply to message with markdown", func(t *testing.T) {
|
||||
markdownReply := "**Bold** reply"
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"im", "+messages-reply",
|
||||
"--message-id", messageID,
|
||||
"--markdown", markdownReply,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
}
|
||||
239
tests/cli_e2e/sheets/sheets_crud_workflow_test.go
Normal file
239
tests/cli_e2e/sheets/sheets_crud_workflow_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_CRUDE2EWorkflow tests the full lifecycle of spreadsheet operations
|
||||
// using all shortcut methods: +create, +read, +write, +append, +find, +info, +export
|
||||
func TestSheets_CRUDE2EWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with +create", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-" + suffix},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// Best-effort cleanup - spreadsheets don't have a direct delete shortcut
|
||||
// The spreadsheet will be cleaned up by the test environment if needed
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get spreadsheet info with +info", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet.token").String())
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write data with +write", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Age", "City"},
|
||||
{"Alice", 25, "Beijing"},
|
||||
{"Bob", 30, "Shanghai"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("read data with +read", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+read",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C3",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Verify the data was written correctly
|
||||
values := gjson.Get(result.Stdout, "data.valueRange.values")
|
||||
require.True(t, values.IsArray(), "values should be an array, stdout: %s", result.Stdout)
|
||||
assert.Equal(t, "Name", values.Array()[0].Array()[0].String())
|
||||
assert.Equal(t, "Alice", values.Array()[1].Array()[0].String())
|
||||
})
|
||||
|
||||
t.Run("append rows with +append", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{{"Charlie", 28, "Guangzhou"}}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+append",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A4:C4",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("find cells with +find", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+find",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--find", "Alice",
|
||||
"--range", fmt.Sprintf("%s!A1:C10", sheetID),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assert.Equal(t, true, gjson.Get(result.Stdout, "ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
matchedCells := gjson.Get(result.Stdout, "data.find_result.matched_cells")
|
||||
require.True(t, matchedCells.IsArray(), "matched_cells should be an array, stdout: %s", result.Stdout)
|
||||
assert.True(t, len(matchedCells.Array()) > 0, "should find at least one cell containing 'Alice'")
|
||||
})
|
||||
|
||||
t.Run("export spreadsheet with +export", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
// Export is an async operation; verify it initiates correctly
|
||||
// The command may have filesystem race issues but the API call succeeds
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+export",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--file-extension", "xlsx",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Export initiates successfully and returns file_token even if there's a temp file race
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.file_token").String(),
|
||||
"export should return file_token, stdout: %s", result.Stdout)
|
||||
})
|
||||
}
|
||||
|
||||
// TestSheets_SpreadsheetsResource tests the spreadsheets resource methods
|
||||
func TestSheets_SpreadsheetsResource(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
|
||||
t.Run("create spreadsheet with spreadsheets create", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "create"},
|
||||
Data: map[string]any{
|
||||
"title": "lark-cli-e2e-sheets-resource-" + suffix,
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// Best-effort cleanup
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get spreadsheet with spreadsheets get", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "get"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, spreadsheetToken, gjson.Get(result.Stdout, "data.spreadsheet.token").String())
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.spreadsheet.url").String())
|
||||
})
|
||||
|
||||
t.Run("patch spreadsheet with spreadsheets patch", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
updatedTitle := "lark-cli-e2e-sheets-patched-" + suffix
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "patch"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
Data: map[string]any{"title": updatedTitle},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
// Verify the title was updated by fetching the spreadsheet
|
||||
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheets", "get"},
|
||||
Params: map[string]any{"spreadsheet_token": spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
getResult.AssertExitCode(t, 0)
|
||||
getResult.AssertStdoutStatus(t, 0)
|
||||
|
||||
// Verify the title was actually updated
|
||||
assert.Equal(t, updatedTitle, gjson.Get(getResult.Stdout, "data.spreadsheet.title").String())
|
||||
})
|
||||
}
|
||||
174
tests/cli_e2e/sheets/sheets_filter_workflow_test.go
Normal file
174
tests/cli_e2e/sheets/sheets_filter_workflow_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestSheets_FilterWorkflow tests the spreadsheet sheet filter operations
|
||||
func TestSheets_FilterWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
spreadsheetToken := ""
|
||||
sheetID := ""
|
||||
|
||||
t.Run("create spreadsheet with initial data", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+create", "--title", "lark-cli-e2e-sheets-filter-" + suffix},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
spreadsheetToken = gjson.Get(result.Stdout, "data.spreadsheet_token").String()
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token should not be empty, stdout: %s", result.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
// No sheets delete command is currently available in lark-cli,
|
||||
// so created spreadsheets are intentionally left in the test account.
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get sheet info", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "+info", "--spreadsheet-token", spreadsheetToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
sheetID = gjson.Get(result.Stdout, "data.sheets.sheets.0.sheet_id").String()
|
||||
require.NotEmpty(t, sheetID, "sheet_id should not be empty, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("write test data for filtering", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
values := [][]any{
|
||||
{"Name", "Score", "Grade"},
|
||||
{"Alice", 85, "B"},
|
||||
{"Bob", 92, "A"},
|
||||
{"Charlie", 78, "C"},
|
||||
{"Diana", 95, "A"},
|
||||
}
|
||||
valuesJSON, _ := json.Marshal(values)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"sheets", "+write",
|
||||
"--spreadsheet-token", spreadsheetToken,
|
||||
"--sheet-id", sheetID,
|
||||
"--range", "A1:C5",
|
||||
"--values", string(valuesJSON),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
})
|
||||
|
||||
t.Run("create filter with spreadsheet.sheet.filters create", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
filterData := map[string]any{
|
||||
"range": fmt.Sprintf("%s!A1:D5", sheetID),
|
||||
"col": "C",
|
||||
"filter_type": "multiValue",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "multiValue",
|
||||
"expected": []any{"A", "B"},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "create"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("get filter with spreadsheet.sheet.filters get", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "get"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
filterInfo := gjson.Get(result.Stdout, "data.sheet_filter_info")
|
||||
require.True(t, filterInfo.Exists(), "filter info should exist, stdout: %s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("update filter with spreadsheet.sheet.filters update", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
filterData := map[string]any{
|
||||
"col": "B",
|
||||
"filter_type": "number",
|
||||
"condition": map[string]any{
|
||||
"filter_type": "number",
|
||||
"compare_type": "greater",
|
||||
"expected": []any{80},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "update"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
Data: filterData,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
|
||||
t.Run("delete filter with spreadsheet.sheet.filters delete", func(t *testing.T) {
|
||||
require.NotEmpty(t, spreadsheetToken, "spreadsheet token is required")
|
||||
require.NotEmpty(t, sheetID, "sheet_id is required")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"sheets", "spreadsheet.sheet.filters", "delete"},
|
||||
Params: map[string]any{
|
||||
"spreadsheet_token": spreadsheetToken,
|
||||
"sheet_id": sheetID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
})
|
||||
}
|
||||
@@ -19,7 +19,7 @@ func TestTask_CommentWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
commentContent := "lark-cli-e2e-comment-" + suffix
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_ReminderWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
Data: map[string]any{
|
||||
@@ -57,9 +57,9 @@ func TestTask_ReminderWorkflow(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("remove reminder", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"task", "+reminder", "--task-id", taskGUID, "--remove"},
|
||||
})
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_StatusWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
taskGUID := createTask(t, parentT, ctx, clie2e.Request{
|
||||
Args: []string{"task", "+create"},
|
||||
Data: map[string]any{
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_TasklistAddTaskWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
tasklistName := "lark-cli-e2e-tasklist-add-" + suffix
|
||||
taskSummary := "lark-cli-e2e-tasklist-add-task-" + suffix
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestTask_TasklistWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := time.Now().UTC().Format("20060102-150405")
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
tasklistName := "lark-cli-e2e-tasklist-" + suffix
|
||||
taskSummary := "lark-cli-e2e-task-in-tasklist-" + suffix
|
||||
taskDescription := "created by tests/cli_e2e/task"
|
||||
|
||||
70
tests/cli_e2e/wiki/helpers_test.go
Normal file
70
tests/cli_e2e/wiki/helpers_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createWikiNode(t *testing.T, ctx context.Context, req clie2e.Request) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, req, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
node := gjson.Get(result.Stdout, "data.node")
|
||||
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, spaceID, "space ID is required")
|
||||
require.NotEmpty(t, nodeToken, "node token is required")
|
||||
|
||||
pageToken := ""
|
||||
seenPageTokens := map[string]struct{}{}
|
||||
for {
|
||||
params := map[string]any{
|
||||
"space_id": spaceID,
|
||||
"page_size": 50,
|
||||
}
|
||||
if pageToken != "" {
|
||||
if _, seen := seenPageTokens[pageToken]; seen {
|
||||
t.Fatalf("wiki node list pagination loop detected for space %q, repeated page_token %q", spaceID, pageToken)
|
||||
}
|
||||
seenPageTokens[pageToken] = struct{}{}
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "list"},
|
||||
DefaultAs: "bot",
|
||||
Params: params,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
node := gjson.Get(result.Stdout, `data.items.#(node_token=="`+nodeToken+`")`)
|
||||
if node.Exists() {
|
||||
return node
|
||||
}
|
||||
|
||||
hasMore := gjson.Get(result.Stdout, "data.has_more").Bool()
|
||||
pageToken = gjson.Get(result.Stdout, "data.page_token").String()
|
||||
if !hasMore || pageToken == "" {
|
||||
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, result.Stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
tests/cli_e2e/wiki/wiki_workflow_test.go
Normal file
150
tests/cli_e2e/wiki/wiki_workflow_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWiki_NodeWorkflow(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
|
||||
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
|
||||
|
||||
var spaceID string
|
||||
var createdNodeToken string
|
||||
var createdObjToken string
|
||||
var copiedNodeToken string
|
||||
|
||||
t.Run("create node", func(t *testing.T) {
|
||||
node := createWikiNode(t, ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "create"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": "my_library",
|
||||
},
|
||||
Data: map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": createdTitle,
|
||||
},
|
||||
})
|
||||
|
||||
spaceID = node.Get("space_id").String()
|
||||
createdNodeToken = node.Get("node_token").String()
|
||||
createdObjToken = node.Get("obj_token").String()
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, createdNodeToken)
|
||||
require.NotEmpty(t, createdObjToken)
|
||||
assert.Equal(t, createdTitle, node.Get("title").String())
|
||||
assert.Equal(t, "origin", node.Get("node_type").String())
|
||||
assert.Equal(t, "docx", node.Get("obj_type").String())
|
||||
})
|
||||
|
||||
t.Run("get created node", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "get_node"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"token": createdNodeToken,
|
||||
"obj_type": "wiki",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, createdNodeToken, gjson.Get(result.Stdout, "data.node.node_token").String())
|
||||
assert.Equal(t, createdObjToken, gjson.Get(result.Stdout, "data.node.obj_token").String())
|
||||
assert.Equal(t, createdTitle, gjson.Get(result.Stdout, "data.node.title").String())
|
||||
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.node.space_id").String())
|
||||
})
|
||||
|
||||
t.Run("get space", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before get")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "get"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": spaceID,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.Equal(t, spaceID, gjson.Get(result.Stdout, "data.space.space_id").String())
|
||||
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.space.name").String(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("list spaces", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "spaces", "list"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"page_size": 1,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, 0)
|
||||
|
||||
assert.True(t, gjson.Get(result.Stdout, "data.page_token").Exists(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, gjson.Get(result.Stdout, "data.items").Exists(), "stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
t.Run("list nodes and find created node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before list")
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be available before list")
|
||||
|
||||
nodeItem := findWikiNodeByToken(t, ctx, spaceID, createdNodeToken)
|
||||
assert.Equal(t, createdTitle, nodeItem.Get("title").String())
|
||||
assert.Equal(t, createdObjToken, nodeItem.Get("obj_token").String())
|
||||
})
|
||||
|
||||
t.Run("copy node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before copy")
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
|
||||
|
||||
copiedNode := createWikiNode(t, ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "nodes", "copy"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{
|
||||
"space_id": spaceID,
|
||||
"node_token": createdNodeToken,
|
||||
},
|
||||
Data: map[string]any{
|
||||
"target_space_id": spaceID,
|
||||
"title": copiedTitle,
|
||||
},
|
||||
})
|
||||
|
||||
copiedNodeToken = copiedNode.Get("node_token").String()
|
||||
require.NotEmpty(t, copiedNodeToken)
|
||||
assert.Equal(t, copiedTitle, copiedNode.Get("title").String())
|
||||
assert.Equal(t, spaceID, copiedNode.Get("space_id").String())
|
||||
assert.NotEqual(t, createdNodeToken, copiedNodeToken)
|
||||
})
|
||||
|
||||
t.Run("list nodes and find copied node", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before second list")
|
||||
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before second list")
|
||||
|
||||
nodeItem := findWikiNodeByToken(t, ctx, spaceID, copiedNodeToken)
|
||||
assert.Equal(t, copiedTitle, nodeItem.Get("title").String())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user