mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
main
...
feat/drive
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c906b9415 |
358
shortcuts/drive/drive_copy.go
Normal file
358
shortcuts/drive/drive_copy.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
driveCopyMaxNameBytes = 256
|
||||
// driveCopyMySpaceSentinel lets callers target the My Space root folder
|
||||
// without knowing its token; Execute resolves it via the root-folder-meta
|
||||
// endpoint (absent from platform metadata, path fixed per official docs).
|
||||
driveCopyMySpaceSentinel = "my_space"
|
||||
driveCopyRootFolderMetaPath = "/open-apis/drive/explorer/v2/root_folder/meta"
|
||||
)
|
||||
|
||||
var driveCopyTypes = []string{"doc", "docx", "sheet", "file", "mindnote", "slides", "bitable", "base", "wiki"}
|
||||
|
||||
type driveCopyRef struct {
|
||||
Token string
|
||||
Type string
|
||||
SourceFlag string
|
||||
}
|
||||
|
||||
type driveCopyExtra struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
type driveCopySpec struct {
|
||||
Ref driveCopyRef
|
||||
Name string
|
||||
FolderToken string // empty when FolderMySpace is set
|
||||
FolderMySpace bool
|
||||
Extras []driveCopyExtra
|
||||
}
|
||||
|
||||
// DriveCopy copies a Drive file into a target folder through the Drive copy
|
||||
// API, with URL parsing. Wiki inputs are rejected with a redirect to the
|
||||
// existing `wiki +node-copy` shortcut.
|
||||
var DriveCopy = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+copy",
|
||||
Description: "Copy a doc/docx/sheet/file/mindnote/slides/base(bitable) into a target folder, with URL parsing; wiki inputs are redirected to wiki +node-copy",
|
||||
Risk: "write",
|
||||
Scopes: []string{"docs:document:copy"},
|
||||
ConditionalScopes: []string{"drive:drive.metadata:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/mindnote/slides/base/bitable)"},
|
||||
{Name: "token", Desc: "document token or document URL; bare tokens require --type"},
|
||||
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveCopyTypes},
|
||||
{Name: "name", Desc: "name for the copied file, up to 256 bytes", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token, folder URL, or the constant my_space to copy into the caller's My Space root folder", Required: true},
|
||||
{Name: "extra", Type: "string_array", Desc: "repeatable key=value pair forwarded verbatim as a custom copy parameter, e.g. --extra target_type=docx to convert a legacy doc into a docx copy"},
|
||||
},
|
||||
Tips: []string{
|
||||
"The source type must match the real file type; the API rejects mismatches.",
|
||||
"Use `--extra target_type=docx` with a legacy doc source to create the copy as a new-version docx.",
|
||||
"`--folder-token my_space` resolves the caller's My Space root folder automatically; resolution needs the drive:drive.metadata:readonly (or drive:drive) scope.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveCopySpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveCopySpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return buildDriveCopyDryRun(spec)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveCopySpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
folderToken := spec.FolderToken
|
||||
if spec.FolderMySpace {
|
||||
folderToken, err = resolveDriveCopyMySpaceRoot(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Copying %s %s to folder %s...\n",
|
||||
spec.Ref.Type, common.MaskToken(spec.Ref.Token), common.MaskToken(folderToken))
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
fmt.Sprintf("/open-apis/drive/v1/files/%s/copy", validate.EncodePathSegment(spec.Ref.Token)),
|
||||
nil,
|
||||
buildDriveCopyBody(spec, folderToken),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.Out(buildDriveCopyOutput(runtime, spec, folderToken, data), nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func readDriveCopySpec(runtime *common.RuntimeContext) (driveCopySpec, error) {
|
||||
ref, err := resolveDriveCopyInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveCopySpec{}, err
|
||||
}
|
||||
spec := driveCopySpec{
|
||||
Ref: ref,
|
||||
Name: strings.TrimSpace(runtime.Str("name")),
|
||||
}
|
||||
spec.FolderToken, spec.FolderMySpace, err = resolveDriveCopyFolderToken(runtime.Str("folder-token"))
|
||||
if err != nil {
|
||||
return driveCopySpec{}, err
|
||||
}
|
||||
spec.Extras, err = parseDriveCopyExtras(runtime.StrArray("extra"))
|
||||
if err != nil {
|
||||
return driveCopySpec{}, err
|
||||
}
|
||||
if err := validateDriveCopySpec(spec); err != nil {
|
||||
return driveCopySpec{}, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// parseDriveCopyExtras converts repeated `key=value` specs into the API's
|
||||
// extra parameter shape, preserving order and transcribing values verbatim.
|
||||
func parseDriveCopyExtras(specs []string) ([]driveCopyExtra, error) {
|
||||
if len(specs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
extras := make([]driveCopyExtra, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
key, value, found := strings.Cut(spec, "=")
|
||||
if !found {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: expected format key=value", spec).WithParam("--extra")
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: key must not be empty", spec).WithParam("--extra")
|
||||
}
|
||||
if value == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: value must not be empty", spec).WithParam("--extra")
|
||||
}
|
||||
extras = append(extras, driveCopyExtra{Key: key, Value: value})
|
||||
}
|
||||
return extras, nil
|
||||
}
|
||||
|
||||
func validateDriveCopySpec(spec driveCopySpec) error {
|
||||
if spec.Name == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty or whitespace-only").WithParam("--name")
|
||||
}
|
||||
if len(spec.Name) > driveCopyMaxNameBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds %d bytes (got %d)", driveCopyMaxNameBytes, len(spec.Name)).WithParam("--name")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveDriveCopyInput(urlInput, tokenInput, explicitType string) (driveCopyRef, error) {
|
||||
urlInput = strings.TrimSpace(urlInput)
|
||||
tokenInput = strings.TrimSpace(tokenInput)
|
||||
if urlInput != "" && tokenInput != "" {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
|
||||
}
|
||||
if urlInput == "" && tokenInput == "" {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
|
||||
}
|
||||
|
||||
raw := urlInput
|
||||
sourceFlag := "--url"
|
||||
if raw == "" {
|
||||
raw = tokenInput
|
||||
sourceFlag = "--token"
|
||||
}
|
||||
inputType := normalizeDriveCopyType(strings.ToLower(strings.TrimSpace(explicitType)))
|
||||
|
||||
if ref, ok := common.ParseResourceURL(raw); ok {
|
||||
refType := normalizeDriveCopyType(ref.Type)
|
||||
if inputType != "" && inputType != refType {
|
||||
return driveCopyRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
inputType,
|
||||
refType,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if refType == "wiki" {
|
||||
return driveCopyRef{}, driveCopyWikiRedirectError(sourceFlag, ref.Token)
|
||||
}
|
||||
if !driveCopyTypeSupported(refType) {
|
||||
return driveCopyRef{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported %s resource type %q; drive copy supports doc, docx, sheet, file, mindnote, slides, and bitable/base",
|
||||
sourceFlag,
|
||||
refType,
|
||||
).WithParam(sourceFlag)
|
||||
}
|
||||
return driveCopyRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
|
||||
}
|
||||
if inputType == "" {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, mindnote, slides, bitable, base)", sourceFlag).WithParam("--type")
|
||||
}
|
||||
if inputType == "wiki" {
|
||||
return driveCopyRef{}, driveCopyWikiRedirectError("--type", raw)
|
||||
}
|
||||
if !driveCopyTypeSupported(inputType) {
|
||||
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, mindnote, slides, bitable, base", inputType).WithParam("--type")
|
||||
}
|
||||
return driveCopyRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
|
||||
}
|
||||
|
||||
// driveCopyWikiRedirectError guides wiki inputs to the dedicated wiki copy
|
||||
// command instead of the Drive copy API, which cannot place copies in the
|
||||
// wiki tree.
|
||||
func driveCopyWikiRedirectError(param, nodeToken string) *errs.ValidationError {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"wiki node %q cannot be copied with drive +copy; use wiki +node-copy instead",
|
||||
nodeToken,
|
||||
).WithParam(param).WithHint(
|
||||
"run: lark-cli wiki +node-copy --space-id <space-id> --node-token %s --target-space-id <target-space-id> (or --target-parent-node-token); resolve <space-id> with: lark-cli wiki +node-get --token %s",
|
||||
nodeToken,
|
||||
nodeToken,
|
||||
)
|
||||
}
|
||||
|
||||
func resolveDriveCopyFolderToken(input string) (string, bool, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if strings.EqualFold(input, driveCopyMySpaceSentinel) {
|
||||
return "", true, nil
|
||||
}
|
||||
if ref, ok := common.ParseResourceURL(input); ok {
|
||||
if ref.Type != "folder" {
|
||||
return "", false, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--folder-token URL resolves to %q, not a folder; pass a folder URL, a folder token, or my_space",
|
||||
ref.Type,
|
||||
).WithParam("--folder-token")
|
||||
}
|
||||
return ref.Token, false, nil
|
||||
}
|
||||
if err := validate.ResourceName(input, "--folder-token"); err != nil {
|
||||
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token")
|
||||
}
|
||||
return input, false, nil
|
||||
}
|
||||
|
||||
// resolveDriveCopyMySpaceRoot fetches the caller's My Space root folder token.
|
||||
// The endpoint is absent from platform metadata; the path follows the official
|
||||
// get-root-folder-meta documentation and works for both user and bot tokens.
|
||||
func resolveDriveCopyMySpaceRoot(runtime *common.RuntimeContext) (string, error) {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolving My Space root folder...\n")
|
||||
data, err := runtime.CallAPITyped("GET", driveCopyRootFolderMetaPath, nil, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.TrimSpace(common.GetString(data, "token"))
|
||||
if token == "" {
|
||||
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root folder meta returned an empty token")
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved My Space root: %s\n", common.MaskToken(token))
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func normalizeDriveCopyType(docType string) string {
|
||||
switch strings.TrimSpace(docType) {
|
||||
case "base":
|
||||
return "bitable"
|
||||
default:
|
||||
return strings.TrimSpace(docType)
|
||||
}
|
||||
}
|
||||
|
||||
func driveCopyTypeSupported(docType string) bool {
|
||||
switch normalizeDriveCopyType(docType) {
|
||||
case "doc", "docx", "sheet", "file", "mindnote", "slides", "bitable":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func buildDriveCopyBody(spec driveCopySpec, folderToken string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"name": spec.Name,
|
||||
"type": spec.Ref.Type,
|
||||
"folder_token": folderToken,
|
||||
}
|
||||
if len(spec.Extras) > 0 {
|
||||
extras := make([]map[string]interface{}, 0, len(spec.Extras))
|
||||
for _, extra := range spec.Extras {
|
||||
extras = append(extras, map[string]interface{}{"key": extra.Key, "value": extra.Value})
|
||||
}
|
||||
body["extra"] = extras
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func buildDriveCopyDryRun(spec driveCopySpec) *common.DryRunAPI {
|
||||
if spec.FolderMySpace {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("2-step orchestration: resolve My Space root -> copy").
|
||||
GET(driveCopyRootFolderMetaPath).
|
||||
Desc("[1] Resolve the caller's My Space root folder token").
|
||||
POST("/open-apis/drive/v1/files/:file_token/copy").
|
||||
Desc("[2] Copy file into the resolved root folder").
|
||||
Body(buildDriveCopyBody(spec, "<root folder token from step 1>")).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("1-step request: copy file into target folder").
|
||||
POST("/open-apis/drive/v1/files/:file_token/copy").
|
||||
Body(buildDriveCopyBody(spec, spec.FolderToken)).
|
||||
Set("file_token", spec.Ref.Token)
|
||||
}
|
||||
|
||||
func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{
|
||||
"copied": true,
|
||||
"source_file_token": spec.Ref.Token,
|
||||
"source_type": spec.Ref.Type,
|
||||
"folder_token": folderToken,
|
||||
}
|
||||
file := common.GetMap(data, "file")
|
||||
if token := common.GetString(file, "token"); token != "" {
|
||||
out["file_token"] = token
|
||||
if url := common.GetString(file, "url"); url != "" {
|
||||
out["url"] = url
|
||||
} else if built := common.BuildResourceURL(runtime.Config.Brand, common.GetString(file, "type"), token); built != "" {
|
||||
out["url"] = built
|
||||
}
|
||||
}
|
||||
if fileType := common.GetString(file, "type"); fileType != "" {
|
||||
out["file_type"] = fileType
|
||||
}
|
||||
if name := common.GetString(file, "name"); name != "" {
|
||||
out["name"] = name
|
||||
}
|
||||
return out
|
||||
}
|
||||
811
shortcuts/drive/drive_copy_test.go
Normal file
811
shortcuts/drive/drive_copy_test.go
Normal file
@@ -0,0 +1,811 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestResolveDriveCopyInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlInput string
|
||||
rawInput string
|
||||
docType string
|
||||
wantToken string
|
||||
wantType string
|
||||
wantErr string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "url docx",
|
||||
urlInput: "https://example.larksuite.com/docx/docxCopySource?from=share",
|
||||
wantToken: "docxCopySource",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "url base normalizes to bitable",
|
||||
urlInput: "https://example.larksuite.com/base/bitableCopySource",
|
||||
wantToken: "bitableCopySource",
|
||||
wantType: "bitable",
|
||||
},
|
||||
{
|
||||
name: "token flag also accepts url",
|
||||
rawInput: "https://example.larksuite.com/sheets/sheetCopySource",
|
||||
wantToken: "sheetCopySource",
|
||||
wantType: "sheet",
|
||||
},
|
||||
{
|
||||
name: "bare token with type",
|
||||
rawInput: "mindnoteCopySource",
|
||||
docType: "mindnote",
|
||||
wantToken: "mindnoteCopySource",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare token with base alias",
|
||||
rawInput: "bitableCopySource",
|
||||
docType: "base",
|
||||
wantToken: "bitableCopySource",
|
||||
wantType: "bitable",
|
||||
},
|
||||
{
|
||||
name: "url and token mutually exclusive",
|
||||
urlInput: "https://example.larksuite.com/docx/docxCopySource",
|
||||
rawInput: "docxCopySource",
|
||||
wantErr: "mutually exclusive",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "missing input",
|
||||
wantErr: "specify --url or --token",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "bare token needs type",
|
||||
rawInput: "docxCopySource",
|
||||
wantErr: "--type is required",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "type conflicts with url",
|
||||
urlInput: "https://example.larksuite.com/docx/docxCopySource",
|
||||
docType: "sheet",
|
||||
wantErr: "conflicts",
|
||||
wantParam: "--type",
|
||||
},
|
||||
{
|
||||
name: "folder url unsupported as source",
|
||||
urlInput: "https://example.larksuite.com/drive/folder/folderCopySource",
|
||||
wantErr: "unsupported",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "unrecognized url",
|
||||
urlInput: "https://example.larksuite.com/unknown/path",
|
||||
wantErr: "unsupported --url URL",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "token with path fragments",
|
||||
rawInput: "token/with/slash",
|
||||
wantErr: "invalid bare token",
|
||||
wantParam: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid bare type",
|
||||
rawInput: "someToken",
|
||||
docType: "folder",
|
||||
wantErr: "invalid --type",
|
||||
wantParam: "--type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, tt.wantParam)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Token != tt.wantToken || got.Type != tt.wantType {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDriveCopyInputWikiRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlInput string
|
||||
rawInput string
|
||||
docType string
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "wiki url",
|
||||
urlInput: "https://example.larksuite.com/wiki/wikiCopySource",
|
||||
wantParam: "--url",
|
||||
},
|
||||
{
|
||||
name: "wiki url via token flag",
|
||||
rawInput: "https://example.larksuite.com/wiki/wikiCopySource",
|
||||
wantParam: "--token",
|
||||
},
|
||||
{
|
||||
name: "bare token with wiki type",
|
||||
rawInput: "wikiCopySource",
|
||||
docType: "wiki",
|
||||
wantParam: "--type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType)
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki redirect error, got nil")
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, tt.wantParam)
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Message, "wiki +node-copy") {
|
||||
t.Fatalf("message should redirect to wiki +node-copy, got %q", validationErr.Message)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "wiki +node-copy --space-id") {
|
||||
t.Fatalf("hint should carry the wiki +node-copy command, got %q", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "--node-token wikiCopySource") {
|
||||
t.Fatalf("hint should carry the parsed node token, got %q", validationErr.Hint)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "wiki +node-get --token wikiCopySource") {
|
||||
t.Fatalf("hint should explain how to resolve the space id, got %q", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDriveCopyFolderToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantToken string
|
||||
wantMySpace bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "bare folder token",
|
||||
input: "folderCopyTarget",
|
||||
wantToken: "folderCopyTarget",
|
||||
},
|
||||
{
|
||||
name: "folder url",
|
||||
input: "https://example.larksuite.com/drive/folder/folderCopyTarget",
|
||||
wantToken: "folderCopyTarget",
|
||||
},
|
||||
{
|
||||
name: "my_space sentinel",
|
||||
input: "my_space",
|
||||
wantMySpace: true,
|
||||
},
|
||||
{
|
||||
name: "my_space sentinel is case-insensitive and trimmed",
|
||||
input: " MY_SPACE ",
|
||||
wantMySpace: true,
|
||||
},
|
||||
{
|
||||
name: "non-folder url",
|
||||
input: "https://example.larksuite.com/docx/docxCopyTarget",
|
||||
wantErr: "not a folder",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: " ",
|
||||
wantErr: "--folder-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, mySpace, err := resolveDriveCopyFolderToken(tt.input)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--folder-token")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if mySpace != tt.wantMySpace {
|
||||
t.Fatalf("mySpace = %v, want %v", mySpace, tt.wantMySpace)
|
||||
}
|
||||
if got != tt.wantToken {
|
||||
t.Fatalf("token = %q, want %q", got, tt.wantToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDriveCopyExtras(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
extras, err := parseDriveCopyExtras(nil)
|
||||
if err != nil || extras != nil {
|
||||
t.Fatalf("empty specs = (%#v, %v), want (nil, nil)", extras, err)
|
||||
}
|
||||
|
||||
extras, err = parseDriveCopyExtras([]string{"target_type=docx", "flag=a=b"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := []driveCopyExtra{{Key: "target_type", Value: "docx"}, {Key: "flag", Value: "a=b"}}
|
||||
if len(extras) != len(want) {
|
||||
t.Fatalf("extras = %#v, want %#v", extras, want)
|
||||
}
|
||||
for i := range want {
|
||||
if extras[i] != want[i] {
|
||||
t.Fatalf("extras[%d] = %#v, want %#v (order and values must be preserved verbatim)", i, extras[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
for _, bad := range []string{"no-separator", "=docx", " =docx", "target_type="} {
|
||||
_, err := parseDriveCopyExtras([]string{bad})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid --extra") {
|
||||
t.Fatalf("spec %q: expected invalid --extra error, got %v", bad, err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--extra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDriveCopyBodyExtras(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveCopySpec{
|
||||
Ref: driveCopyRef{Token: "docCopySource", Type: "doc", SourceFlag: "--url"},
|
||||
Name: "Copied doc",
|
||||
FolderToken: "folderCopyTarget",
|
||||
}
|
||||
if _, ok := buildDriveCopyBody(spec, spec.FolderToken)["extra"]; ok {
|
||||
t.Fatal("body should omit extra when no --extra is passed")
|
||||
}
|
||||
|
||||
spec.Extras = []driveCopyExtra{{Key: "target_type", Value: "docx"}}
|
||||
body := buildDriveCopyBody(spec, spec.FolderToken)
|
||||
extras, ok := body["extra"].([]map[string]interface{})
|
||||
if !ok || len(extras) != 1 {
|
||||
t.Fatalf("body extra = %#v, want 1 key/value entry", body["extra"])
|
||||
}
|
||||
if extras[0]["key"] != "target_type" || extras[0]["value"] != "docx" {
|
||||
t.Fatalf("extra[0] = %#v, want target_type=docx", extras[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDriveCopySpec(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
base := driveCopySpec{
|
||||
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
|
||||
Name: "Copy name",
|
||||
FolderToken: "folderCopyTarget",
|
||||
}
|
||||
|
||||
if err := validateDriveCopySpec(base); err != nil {
|
||||
t.Fatalf("unexpected error for valid spec: %v", err)
|
||||
}
|
||||
|
||||
empty := base
|
||||
empty.Name = ""
|
||||
err := validateDriveCopySpec(empty)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name must not be empty") {
|
||||
t.Fatalf("expected empty-name error, got %v", err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--name")
|
||||
|
||||
long := base
|
||||
long.Name = strings.Repeat("字", 90) // 270 bytes in UTF-8
|
||||
err = validateDriveCopySpec(long)
|
||||
if err == nil || !strings.Contains(err.Error(), "exceeds 256 bytes") {
|
||||
t.Fatalf("expected name-length error, got %v", err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--name")
|
||||
}
|
||||
|
||||
func assertDriveCopyValidationError(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
|
||||
}
|
||||
if validationErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if cause := errors.Unwrap(err); cause != nil {
|
||||
t.Fatalf("unexpected cause on direct validation error: %v", cause)
|
||||
}
|
||||
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyExecuteDocx(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
copyStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"file": map[string]interface{}{
|
||||
"token": "docxCopyResult",
|
||||
"type": "docx",
|
||||
"name": "Copied doc",
|
||||
"url": "https://example.larksuite.com/docx/docxCopyResult",
|
||||
"parent_token": "folderCopyTarget",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(copyStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--url", "https://example.larksuite.com/docx/docxCopySource",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "https://example.larksuite.com/drive/folder/folderCopyTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody))
|
||||
}
|
||||
if got := requestBody["name"]; got != "Copied doc" {
|
||||
t.Fatalf("body name = %#v, want Copied doc", got)
|
||||
}
|
||||
if got := requestBody["type"]; got != "docx" {
|
||||
t.Fatalf("body type = %#v, want docx", got)
|
||||
}
|
||||
if got := requestBody["folder_token"]; got != "folderCopyTarget" {
|
||||
t.Fatalf("body folder_token = %#v, want folderCopyTarget (parsed from folder URL)", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := data["copied"]; got != true {
|
||||
t.Fatalf("copied = %#v, want true", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxCopyResult" {
|
||||
t.Fatalf("file_token = %q, want docxCopyResult", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
|
||||
t.Fatalf("file_type = %q, want docx", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "url", "data.url"); got != "https://example.larksuite.com/docx/docxCopyResult" {
|
||||
t.Fatalf("url = %q, want backend url", got)
|
||||
}
|
||||
if got := mustStringField(t, data, "source_file_token", "data.source_file_token"); got != "docxCopySource" {
|
||||
t.Fatalf("source_file_token = %q, want docxCopySource", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyExecuteBuildsURLFallback(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/sheetCopySource/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"file": map[string]interface{}{
|
||||
"token": "sheetCopyResult",
|
||||
"type": "sheet",
|
||||
"name": "Copied sheet",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "sheetCopySource",
|
||||
"--type", "sheet",
|
||||
"--name", "Copied sheet",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
url := mustStringField(t, data, "url", "data.url")
|
||||
if !strings.HasSuffix(url, "/sheets/sheetCopyResult") {
|
||||
t.Fatalf("url = %q, want built fallback ending in /sheets/sheetCopyResult", url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyExecuteAPIError(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 1248006,
|
||||
"msg": "no permission",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected API error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != 1248006 {
|
||||
t.Fatalf("problem code = %d, want 1248006", problem.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyMountedDryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--url", "https://example.larksuite.com/docx/docxCopySource",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--extra", "target_type=docx",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
if got := out["dry_run"]; got != true {
|
||||
t.Fatalf("dry_run = %#v, want true\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
apis, ok := data["api"].([]interface{})
|
||||
if !ok || len(apis) != 1 {
|
||||
t.Fatalf("expected 1 api entry, got %#v\nstdout:\n%s", data["api"], stdout.String())
|
||||
}
|
||||
call := mustMapValue(t, apis[0], "api.0")
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" {
|
||||
t.Fatalf("url = %#v, want resolved copy endpoint", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api.0.body")
|
||||
if got := body["folder_token"]; got != "folderCopyTarget" {
|
||||
t.Fatalf("body folder_token = %#v, want folderCopyTarget", got)
|
||||
}
|
||||
extras, ok := body["extra"].([]interface{})
|
||||
if !ok || len(extras) != 1 {
|
||||
t.Fatalf("body extra = %#v, want 1 entry", body["extra"])
|
||||
}
|
||||
extra := mustMapValue(t, extras[0], "api.0.body.extra.0")
|
||||
if extra["key"] != "target_type" || extra["value"] != "docx" {
|
||||
t.Fatalf("extra[0] = %#v, want target_type=docx", extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyMountedMySpaceExecute(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"id": "7000000000000000001",
|
||||
"token": "rootFolderResolved",
|
||||
"user_id": "7000000000000000002",
|
||||
},
|
||||
},
|
||||
})
|
||||
copyStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/docxCopySource/copy",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"file": map[string]interface{}{
|
||||
"token": "docxCopyResult",
|
||||
"type": "docx",
|
||||
"name": "Copied doc",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(copyStub)
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "my_space",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody))
|
||||
}
|
||||
if got := requestBody["folder_token"]; got != "rootFolderResolved" {
|
||||
t.Fatalf("body folder_token = %#v, want resolved root token", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "folder_token", "data.folder_token"); got != "rootFolderResolved" {
|
||||
t.Fatalf("output folder_token = %q, want resolved root token", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyMountedMySpaceRootResolveErrors(t *testing.T) {
|
||||
t.Run("api error propagates", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663,
|
||||
"msg": "token invalid",
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "my_space",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected root resolve error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if problem.Code != 99991663 {
|
||||
t.Fatalf("problem code = %d, want 99991663", problem.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty token is an internal error", func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/explorer/v2/root_folder/meta",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{"id": "7000000000000000001"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "my_space",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "empty token") {
|
||||
t.Fatalf("expected empty-token error, got %v", err)
|
||||
}
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if internalErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildDriveCopyDryRunMySpace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveCopySpec{
|
||||
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
|
||||
Name: "Copied doc",
|
||||
FolderMySpace: true,
|
||||
}
|
||||
raw, err := json.Marshal(buildDriveCopyDryRun(spec))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal dry-run preview: %v", err)
|
||||
}
|
||||
payload := decodeJSONMap(t, string(raw))
|
||||
|
||||
apis, ok := payload["api"].([]interface{})
|
||||
if !ok || len(apis) != 2 {
|
||||
t.Fatalf("expected 2 api entries, got %#v", payload["api"])
|
||||
}
|
||||
step1 := mustMapValue(t, apis[0], "api.0")
|
||||
if step1["method"] != "GET" || step1["url"] != "/open-apis/drive/explorer/v2/root_folder/meta" {
|
||||
t.Fatalf("api.0 = %#v, want root folder meta GET", step1)
|
||||
}
|
||||
step2 := mustMapValue(t, apis[1], "api.1")
|
||||
body := mustMapValue(t, step2["body"], "api.1.body")
|
||||
if got := body["folder_token"]; got != "<root folder token from step 1>" {
|
||||
t.Fatalf("api.1.body.folder_token = %#v, want placeholder", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyMountedWikiInputFailsValidation(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiCopySource",
|
||||
"--name", "Copied wiki",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected wiki redirect error, got nil")
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--url")
|
||||
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(validationErr.Hint, "wiki +node-copy") {
|
||||
t.Fatalf("hint should redirect to wiki +node-copy, got %q", validationErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyMountedFolderAndNameValidation(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
err := mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "https://example.larksuite.com/docx/notAFolder",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "not a folder") {
|
||||
t.Fatalf("expected non-folder target error, got %v", err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--folder-token")
|
||||
|
||||
err = mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", " ",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--name must not be empty") {
|
||||
t.Fatalf("expected whitespace-name error, got %v", err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--name")
|
||||
|
||||
err = mountAndRunDrive(t, DriveCopy, []string{
|
||||
"+copy",
|
||||
"--token", "docxCopySource",
|
||||
"--type", "docx",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "folderCopyTarget",
|
||||
"--extra", "no-separator",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "expected format key=value") {
|
||||
t.Fatalf("expected malformed --extra error, got %v", err)
|
||||
}
|
||||
assertDriveCopyValidationError(t, err, "--extra")
|
||||
}
|
||||
|
||||
func TestBuildDriveCopyDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
spec := driveCopySpec{
|
||||
Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"},
|
||||
Name: "Copied doc",
|
||||
FolderToken: "folderCopyTarget",
|
||||
}
|
||||
preview := buildDriveCopyDryRun(spec)
|
||||
raw, err := json.Marshal(preview)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal dry-run preview: %v", err)
|
||||
}
|
||||
payload := decodeJSONMap(t, string(raw))
|
||||
|
||||
apis, ok := payload["api"].([]interface{})
|
||||
if !ok || len(apis) != 1 {
|
||||
t.Fatalf("expected 1 api entry, got %#v", payload["api"])
|
||||
}
|
||||
call := mustMapValue(t, apis[0], "api.0")
|
||||
if got := call["method"]; got != "POST" {
|
||||
t.Fatalf("method = %#v, want POST", got)
|
||||
}
|
||||
if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" {
|
||||
t.Fatalf("url = %#v, want resolved copy endpoint", got)
|
||||
}
|
||||
body := mustMapValue(t, call["body"], "api.0.body")
|
||||
if got := body["type"]; got != "docx" {
|
||||
t.Fatalf("body type = %#v, want docx", got)
|
||||
}
|
||||
if got := body["name"]; got != "Copied doc" {
|
||||
t.Fatalf("body name = %#v, want Copied doc", got)
|
||||
}
|
||||
if got := body["folder_token"]; got != "folderCopyTarget" {
|
||||
t.Fatalf("body folder_token = %#v, want folderCopyTarget", got)
|
||||
}
|
||||
if got := payload["file_token"]; got != "docxCopySource" {
|
||||
t.Fatalf("file_token = %#v, want docxCopySource", got)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveUpload,
|
||||
DriveCreateFolder,
|
||||
DriveCreateShortcut,
|
||||
DriveCopy,
|
||||
DriveDownload,
|
||||
DrivePreview,
|
||||
DriveCover,
|
||||
|
||||
@@ -18,6 +18,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+upload",
|
||||
"+create-folder",
|
||||
"+create-shortcut",
|
||||
"+copy",
|
||||
"+download",
|
||||
"+preview",
|
||||
"+cover",
|
||||
|
||||
@@ -16,12 +16,12 @@ metadata:
|
||||
|
||||
> **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。
|
||||
|
||||
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
|
||||
> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive +copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。这是会改变文档归属和权限继承的写操作,执行前确认源节点与目标位置。
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
|
||||
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive +copy`,用法见 [`references/lark-drive-copy.md`](references/lark-drive-copy.md);如果是 wiki URL/token,使用 `wiki +node-copy`,见 [`lark-wiki-node-copy.md`](../lark-wiki/references/lark-wiki-node-copy.md)。
|
||||
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
@@ -128,6 +128,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| `+sync` | 双向同步本地目录与 Drive 文件夹:拉取 `new_remote`、推送 `new_local`,`modified` 按 `--on-conflict=remote-wins\|local-wins\|keep-both\|ask` 处理;`--quick` 用修改时间近似比较;`--on-duplicate-remote` 支持 `fail` / `newest` / `oldest`;只同步 `type=file`,跳过在线文档和 shortcut,且不会删除两端多余文件。 |
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+copy`](references/lark-drive-copy.md) | 复制 doc/docx/sheet/file/mindnote/slides/base(bitable) 到目标文件夹;支持 URL 传参,wiki 输入会引导改用 `wiki +node-copy`。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加全文/局部评论;不支持妙搭 apps。 |
|
||||
| [`+list-comments`](references/lark-drive-list-comments.md) | 分页获取评论列表。 |
|
||||
| [`+batch-query-comments`](references/lark-drive-batch-query-comments.md) | 按评论 ID 批量获取评论。 |
|
||||
@@ -170,7 +171,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
|
||||
|
||||
### files
|
||||
|
||||
- `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制
|
||||
- `copy` — 复制文件;优先使用 [`drive +copy`](references/lark-drive-copy.md) shortcut
|
||||
- `create_folder` — 新建文件夹
|
||||
- `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)
|
||||
- `patch` — 修改文件标题
|
||||
|
||||
62
skills/lark-drive/references/lark-drive-copy.md
Normal file
62
skills/lark-drive/references/lark-drive-copy.md
Normal file
@@ -0,0 +1,62 @@
|
||||
|
||||
# drive +copy
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
复制一个 Drive 文件(在线文档、表格、多维表格、幻灯片、思维笔记或普通文件)到目标文件夹,生成一个内容相同的新副本。推荐直接传文档 URL。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 推荐:源文档传 URL(自动识别类型和 token)
|
||||
lark-cli drive +copy --url "https://example.larksuite.com/docx/<DOCX_TOKEN>" --name '副本名称' --folder-token <TARGET_FOLDER_TOKEN>
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--url` | 与 `--token` 二选一 | 源文档 URL,支持 `doc` / `docx` / `sheet` / `file` / `mindnote` / `slides` / `base` / `bitable` 路径 |
|
||||
| `--token` | 与 `--url` 二选一 | 源文档 token 或 URL;裸 token 必须配合 `--type` |
|
||||
| `--type` | 裸 token 时必填 | 源文件类型:`doc`、`docx`、`sheet`、`file`、`mindnote`、`slides`、`bitable`(`base` 为兼容别名);传 URL 时可省略,显式传入时必须与 URL 类型一致 |
|
||||
| `--name` | 是 | 副本名称,最长 256 字节 |
|
||||
| `--folder-token` | 是 | 目标文件夹 token、文件夹 URL,或常量 `my_space`(复制到当前身份"我的空间"根目录,内部自动解析根 token) |
|
||||
| `--extra` | 否 | 可重复的 `key=value` 对,原样透传给 API 的 `extra` 自定义复制参数;典型用法 `--extra target_type=docx`(复制旧版 doc 时转换为 docx 副本) |
|
||||
|
||||
## 输入规则
|
||||
|
||||
- `--url` 与 `--token` 互斥,只传一个
|
||||
- `--type` 必须与源文件真实类型一致,类型不匹配时服务端会返回失败
|
||||
- `base` 与 `bitable` 是同一概念,CLI 会把 `base` 归一化为 `bitable` 后发给服务端
|
||||
- 目标文件夹必须是云空间(云盘/云存储)文件夹 token,不能传 wiki 节点 token
|
||||
|
||||
## Wiki 场景
|
||||
|
||||
`drive +copy` 只复制云盘(Drive)文件,不接受 wiki URL / token;传入时返回校验错误,错误 hint 会给出替代命令。知识库内复制节点用 [`lark-wiki`](../../lark-wiki/SKILL.md) 的 `wiki +node-copy`;要把 wiki 文档复制成 Drive 空间里的独立副本(脱离知识库),先用 `drive +inspect` 解包拿到底层 `token` 和 `type`,再对底层 token 执行 `drive +copy`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto`、`--format`、`--jq`、`--dry-run` 使用
|
||||
- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建副本
|
||||
- 这是写入操作;执行前应确认源文档和目标文件夹准确无误
|
||||
|
||||
## 输出
|
||||
|
||||
```json
|
||||
{
|
||||
"copied": true,
|
||||
"file_token": "<new_file_token>",
|
||||
"file_type": "docx",
|
||||
"name": "副本名称",
|
||||
"url": "https://example.larksuite.com/docx/<new_file_token>",
|
||||
"source_file_token": "<source_file_token>",
|
||||
"source_type": "docx",
|
||||
"folder_token": "<target_folder_token>"
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
|
||||
- [lark-wiki](../../lark-wiki/SKILL.md) -- 知识库节点复制(`wiki +node-copy`)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
@@ -1,9 +1,9 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 40 leaf commands
|
||||
- Covered: 21
|
||||
- Coverage: 52.5%
|
||||
- Denominator: 41 leaf commands
|
||||
- Covered: 22
|
||||
- Coverage: 53.7%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -12,6 +12,8 @@
|
||||
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
|
||||
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
|
||||
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
|
||||
- TestDriveCopyDryRun_DocxURL / TestDriveCopyDryRun_BareTokenBaseAlias / TestDriveCopyDryRun_MySpaceTarget / TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy: dry-run coverage for `drive +copy`; asserts URL parsing to `files/:token/copy`, request body shape (`name`/`type`/`folder_token`, `--extra key=value` → `extra` key/value array), folder-URL target parsing, `base`→`bitable` normalization, the `--folder-token my_space` two-step plan (`root_folder/meta -> copy` with a placeholder folder_token), and the typed validation error that redirects wiki inputs to `wiki +node-copy` (hint carries the parsed node token and the `wiki +node-get` space-id lookup).
|
||||
- TestDrive_CopyWorkflow: proves `drive +copy` against the real backend. Uploads a source file into a temporary Drive folder, copies it with a new name via bare token + `--type file`, asserts the copy returns a distinct token with the requested name, downloads the copy, and verifies the content matches the source; a second copy targets `--folder-token my_space` and asserts the output carries the resolved root token instead of the sentinel. All copies and the folder are removed via best-effort cleanup hooks. The root-folder-meta resolution endpoint (absent from platform metadata) was live-verified for both user and bot identities. Live-probed manually beforehand as user: URL input, output shape, and the wiki redirect were confirmed against the live API. Error codes in the skill reference were also live-probed: nonexistent source token → 1061003, `--type` mismatch (docx declared as sheet) → 1061003 (not 1061002 — the server looks the token up under the declared type).
|
||||
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_AppsPageURL / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, apps `/page/<token>` URL parsing with `file_type=apps`, default `is_solved=false`, default omitted `is_whole` and `user_id_type`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
|
||||
- TestDrive_CommentOpsDryRun: dry-run coverage for `drive +batch-query-comments`, `drive +resolve-comment`, `drive +restore-comment`, `drive +add-reply`, `drive +list-replies`, `drive +update-reply`, `drive +delete-reply`, and `drive +react-reply`; asserts URL→type inference (incl. Miaoda apps `/page/<token>` → `file_type=apps` and Base `/base/` → `file_type=bitable`), `file_type`/`page_size`/`need_reaction`/`need_relation` (docx-gated) query wiring, `comment_ids` / `is_solved` / reply `content.elements[]` (text_run) / reaction `action`+`reaction_type`+`reply_id` body shapes, resolved `:comment_id`/`:reply_id` path segments, the Wiki `get_node -> batch_query` / `get_node -> replies list` / `get_node -> v2 reaction` orchestration plans, and the batch_query wiki dry-run surfacing `need_relation` as the `<sent only when obj_type is docx>` placeholder, without live API calls. All eight verified manually against live documents (list → batch-query → add-reply → list-replies → update-reply → react add/delete → resolve/restore → delete-reply round trip; root-reply update rewriting the comment body, the `1069303 forbidden` cross-identity update rejection, the server persisting arbitrary `reaction_type` strings, and count=0 reaction tombstones were probed live as well).
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
|
||||
@@ -40,6 +42,7 @@
|
||||
| ✓ | drive +delete-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--comment-id` + `--reply-id` path segments; `--dry-run` bypasses the `--yes` gate | dry-run pins request shape; opt-in live workflow deletes the created reply and verifies the count returns to baseline |
|
||||
| ✓ | drive +react-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--token + --type wiki` resolve plan; v2 `.../comments/reaction` POST; `--reply-id`/`--emoji`/`--action add\|delete` → body `reply_id`/`reaction_type`/`action` | dry-run pins request/body shape and wiki resolve plan; opt-in live workflow adds then removes a reaction, polling `+list-replies --need-reaction` with count>0 presence checks; local `--emoji` enum validation guards the unvalidated server field |
|
||||
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
|
||||
| ✓ | drive +copy | shortcut | drive_copy_dryrun_test.go::TestDriveCopyDryRun_DocxURL; drive_copy_dryrun_test.go::TestDriveCopyDryRun_BareTokenBaseAlias; drive_copy_dryrun_test.go::TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy; drive_copy_workflow_test.go::TestDrive_CopyWorkflow | `--url` doc URL vs bare `--token + --type`; `--type base` alias; `--name` body; `--folder-token` URL vs bare vs `my_space` sentinel; `--extra key=value` passthrough; wiki URL/token redirect to `wiki +node-copy` | dry-run locks request shape, the my_space two-step plan, and wiki redirect guidance; live workflow copies an uploaded file, verifies content via download, and copies into the resolved My Space root; `--extra` body shape confirmed against the live API (docx copy with `target_type=docx`) |
|
||||
| ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
@@ -63,7 +66,7 @@
|
||||
| ✕ | drive file.comments patch | api | | none | no file comment workflow yet |
|
||||
| ✕ | drive file.statistics get | api | | none | no statistics workflow yet |
|
||||
| ✕ | drive file.view_records list | api | | none | no view-record workflow yet |
|
||||
| ✕ | drive files copy | api | | none | no file copy workflow yet |
|
||||
| ✕ | drive files copy | api | | none | endpoint exercised live through `drive +copy` (TestDrive_CopyWorkflow); the raw service command itself has no workflow |
|
||||
| ✓ | drive files create_folder | api | drive_files_workflow_test.go::TestDrive_FilesCreateFolderWorkflow/create_folder as bot | `name`; empty `folder_token` in `--data` | |
|
||||
| ✕ | drive files list | api | | none | no list workflow yet |
|
||||
| ✕ | drive metas batch_query | api | | none | no metadata workflow yet |
|
||||
|
||||
153
tests/cli_e2e/drive/drive_copy_dryrun_test.go
Normal file
153
tests/cli_e2e/drive/drive_copy_dryrun_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDriveCopyDryRun_DocxURL(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--url", "https://example.larksuite.com/docx/docxDryRunCopy?from=share",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "https://example.larksuite.com/drive/folder/folderDryRunCopy",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("api.0.method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" {
|
||||
t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.name").String(); got != "Copied doc" {
|
||||
t.Fatalf("api.0.body.name=%q, want Copied doc\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "docx" {
|
||||
t.Fatalf("api.0.body.type=%q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.folder_token").String(); got != "folderDryRunCopy" {
|
||||
t.Fatalf("api.0.body.folder_token=%q, want token parsed from folder URL\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyDryRun_BareTokenBaseAlias(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--token", "bitableDryRunCopy",
|
||||
"--type", "base",
|
||||
"--name", "Copied base",
|
||||
"--folder-token", "folderDryRunCopy",
|
||||
"--extra", "target_type=bitable",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/bitableDryRunCopy/copy" {
|
||||
t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "bitable" {
|
||||
t.Fatalf("api.0.body.type=%q, want bitable (base alias normalized)\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.extra.0.key").String(); got != "target_type" {
|
||||
t.Fatalf("api.0.body.extra.0.key=%q, want target_type\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.body.extra.0.value").String(); got != "bitable" {
|
||||
t.Fatalf("api.0.body.extra.0.value=%q, want bitable\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyDryRun_MySpaceTarget(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--url", "https://example.larksuite.com/docx/docxDryRunCopy",
|
||||
"--name", "Copied doc",
|
||||
"--folder-token", "my_space",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/explorer/v2/root_folder/meta" {
|
||||
t.Fatalf("api.0.url=%q, want root folder meta\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" {
|
||||
t.Fatalf("api.1.url=%q, want copy endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := clie2e.DryRunGet(out, "api.1.body.folder_token").String(); got != "<root folder token from step 1>" {
|
||||
t.Fatalf("api.1.body.folder_token=%q, want placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--url", "https://example.larksuite.com/wiki/wikiDryRunCopy",
|
||||
"--name", "Copied wiki",
|
||||
"--folder-token", "folderDryRunCopy",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("wiki URL should be rejected with a redirect error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
if !strings.Contains(result.Stderr, `"type": "validation"`) {
|
||||
t.Fatalf("stderr should carry a typed validation error\nstderr:\n%s", result.Stderr)
|
||||
}
|
||||
if !strings.Contains(result.Stderr, "wiki +node-copy --space-id") ||
|
||||
!strings.Contains(result.Stderr, "--node-token wikiDryRunCopy") {
|
||||
t.Fatalf("stderr should guide to wiki +node-copy with the parsed node token\nstderr:\n%s", result.Stderr)
|
||||
}
|
||||
if !strings.Contains(result.Stderr, "wiki +node-get --token wikiDryRunCopy") {
|
||||
t.Fatalf("stderr should explain how to resolve the space id\nstderr:\n%s", result.Stderr)
|
||||
}
|
||||
}
|
||||
138
tests/cli_e2e/drive/drive_copy_workflow_test.go
Normal file
138
tests/cli_e2e/drive/drive_copy_workflow_test.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_CopyWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-copy-"+suffix, "")
|
||||
workDir := t.TempDir()
|
||||
|
||||
scheduleDelete := func(fileToken string) {
|
||||
t.Helper()
|
||||
if fileToken == "" {
|
||||
return
|
||||
}
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
}
|
||||
|
||||
sourceContent := "drive copy e2e: source content\n"
|
||||
sourcePath := filepath.Join(workDir, "copy-source.txt")
|
||||
if err := os.WriteFile(sourcePath, []byte(sourceContent), 0o644); err != nil {
|
||||
t.Fatalf("write source file: %v", err)
|
||||
}
|
||||
|
||||
uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", "copy-source.txt",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
uploadResult.AssertExitCode(t, 0)
|
||||
uploadResult.AssertStdoutStatus(t, true)
|
||||
sourceToken := gjson.Get(uploadResult.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, sourceToken, "uploaded source should have a token, stdout:\n%s", uploadResult.Stdout)
|
||||
scheduleDelete(sourceToken)
|
||||
|
||||
copyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--token", sourceToken,
|
||||
"--type", "file",
|
||||
"--name", "copy-result.txt",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
copyResult.AssertExitCode(t, 0)
|
||||
copyResult.AssertStdoutStatus(t, true)
|
||||
|
||||
copiedToken := gjson.Get(copyResult.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, copiedToken, "copy should return the new file token, stdout:\n%s", copyResult.Stdout)
|
||||
scheduleDelete(copiedToken)
|
||||
if copiedToken == sourceToken {
|
||||
t.Fatalf("copied token should differ from source token %q\nstdout:\n%s", sourceToken, copyResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(copyResult.Stdout, "data.name").String(); got != "copy-result.txt" {
|
||||
t.Fatalf("data.name=%q, want copy-result.txt\nstdout:\n%s", got, copyResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(copyResult.Stdout, "data.file_type").String(); got != "file" {
|
||||
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, copyResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(copyResult.Stdout, "data.source_file_token").String(); got != sourceToken {
|
||||
t.Fatalf("data.source_file_token=%q, want %q\nstdout:\n%s", got, sourceToken, copyResult.Stdout)
|
||||
}
|
||||
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+download",
|
||||
"--file-token", copiedToken,
|
||||
"--output", "copy-downloaded.txt",
|
||||
"--overwrite",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
mySpaceResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+copy",
|
||||
"--token", sourceToken,
|
||||
"--type", "file",
|
||||
"--name", "copy-result-my-space.txt",
|
||||
"--folder-token", "my_space",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
mySpaceResult.AssertExitCode(t, 0)
|
||||
mySpaceResult.AssertStdoutStatus(t, true)
|
||||
|
||||
mySpaceToken := gjson.Get(mySpaceResult.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, mySpaceToken, "my_space copy should return the new file token, stdout:\n%s", mySpaceResult.Stdout)
|
||||
scheduleDelete(mySpaceToken)
|
||||
if got := gjson.Get(mySpaceResult.Stdout, "data.folder_token").String(); got == "" || got == "my_space" {
|
||||
t.Fatalf("data.folder_token=%q, want the resolved My Space root token\nstdout:\n%s", got, mySpaceResult.Stdout)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workDir, "copy-downloaded.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded copy: %v", err)
|
||||
}
|
||||
if string(data) != sourceContent {
|
||||
t.Fatalf("copied content=%q want %q", string(data), sourceContent)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user