chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# Drive CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
|
||||
## 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`.
|
||||
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
|
||||
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
|
||||
- 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 `duplicate_remote_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.
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup.
|
||||
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
|
||||
| ✓ | 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 +delete | shortcut | | none | no primary delete workflow yet |
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
| ✓ | drive +secure-label-list | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--page-size`; `--page-token`; `--lang` | dry-run only; live label availability depends on tenant security-label configuration |
|
||||
| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level |
|
||||
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
|
||||
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
|
||||
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
|
||||
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
|
||||
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comment.replys update | api | | none | no reply workflow yet |
|
||||
| ✕ | drive file.comments create_v2 | api | | none | no file comment workflow yet |
|
||||
| ✕ | drive file.comments list | api | | none | no file comment workflow yet |
|
||||
| ✕ | 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 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 |
|
||||
| ✕ | drive permission.members auth | api | | none | permission workflows not covered |
|
||||
| ✕ | drive permission.members create | api | | none | permission workflows not covered |
|
||||
| ✕ | drive permission.members transfer_owner | api | | none | permission workflows not covered |
|
||||
| ✕ | drive user remove_subscription | api | | none | subscription workflows not covered |
|
||||
| ✕ | drive user subscription | api | | none | subscription workflows not covered |
|
||||
| ✕ | drive user subscription_status | api | | none | subscription workflows not covered |
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveAddCommentDryRun_File(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", "+add-comment",
|
||||
"--doc", "https://example.larksuite.com/file/fileDryRunComment",
|
||||
"--content", `[{"type":"text","text":"please update README"}]`,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/metas/batch_query" {
|
||||
t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" {
|
||||
t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" {
|
||||
t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" {
|
||||
t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out)
|
||||
}
|
||||
if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() {
|
||||
t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" {
|
||||
t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveAddCommentDryRun_Base(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", "+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseDryRunComment",
|
||||
"--content", `[{"type":"text","text":"please check this record"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/baseDryRunComment/new_comments" {
|
||||
t.Fatalf("api.0.url=%q, want new_comments\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.file_type").String(); got != "bitable" {
|
||||
t.Fatalf("api.0.body.file_type=%q, want bitable\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.anchor.block_id").String(); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("api.0.body.anchor.block_id=%q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.anchor.base_record_id").String(); got != "recBIBgGmb" {
|
||||
t.Fatalf("api.0.body.anchor.base_record_id=%q, want recBIBgGmb\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.anchor.base_view_id").String(); got != "vewc46MG1R" {
|
||||
t.Fatalf("api.0.body.anchor.base_view_id=%q, want vewc46MG1R\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" {
|
||||
t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow")
|
||||
}
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md"
|
||||
|
||||
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"markdown", "+create",
|
||||
"--name", fileName,
|
||||
"--content", "# Comment target\n\nbody\n",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createResult.AssertExitCode(t, 0)
|
||||
createResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", fileToken,
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+add-comment",
|
||||
"--doc", fileToken,
|
||||
"--type", "file",
|
||||
"--content", `[{"type":"text","text":"please update README"}]`,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
commentResult.AssertExitCode(t, 0)
|
||||
commentResult.AssertStdoutStatus(t, true)
|
||||
|
||||
commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String()
|
||||
require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout)
|
||||
if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" {
|
||||
t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName {
|
||||
t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
|
||||
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_ApplyPermissionDryRun locks in the request shape the shortcut
|
||||
// emits under --dry-run: the real CLI binary is invoked end-to-end (so the
|
||||
// full flag-parsing, validation, and dry-run renderers all execute), and the
|
||||
// printed request is inspected to confirm
|
||||
// - HTTP method, URL template, and the token path segment,
|
||||
// - type query parameter (auto-inferred from a URL input, explicit for a
|
||||
// bare token input),
|
||||
// - perm / remark body fields.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any network call.
|
||||
func TestDrive_ApplyPermissionDryRun(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantType string
|
||||
wantPerm string
|
||||
wantBody map[string]string // optional substrings (key=rendered token) to require
|
||||
}{
|
||||
{
|
||||
name: "URL input auto-infers docx type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--perm", "view",
|
||||
"--remark", "e2e note",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members/apply",
|
||||
wantType: "docx",
|
||||
wantPerm: "view",
|
||||
wantBody: map[string]string{"remark": "e2e note"},
|
||||
},
|
||||
{
|
||||
name: "URL input auto-infers sheet type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/sheets/shtcnE2E002?sheet=abc",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E002/members/apply",
|
||||
wantType: "sheet",
|
||||
wantPerm: "edit",
|
||||
},
|
||||
{
|
||||
// Explicit --type must override URL inference: the /docx/ marker
|
||||
// would infer type=docx, but the caller asked for type=wiki (e.g.
|
||||
// to apply against the underlying wiki node rather than its docx
|
||||
// target). The URL token itself is still used as the path token.
|
||||
name: "explicit --type overrides URL inference",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E003",
|
||||
"--type", "wiki",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E003/members/apply",
|
||||
wantType: "wiki",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit type",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "bscE2E004",
|
||||
"--type", "bitable",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/bscE2E004/members/apply",
|
||||
wantType: "bitable",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "slides URL inference",
|
||||
args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "https://example.feishu.cn/slides/slE2E004",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/slE2E004/members/apply",
|
||||
wantType: "slides",
|
||||
wantPerm: "view",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
// Dry-run output is the JSON envelope; gjson walks into api[0].
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.perm").String(); got != tt.wantPerm {
|
||||
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
|
||||
}
|
||||
for k, v := range tt.wantBody {
|
||||
if got := gjson.Get(out, "api.0.body."+k).String(); got != v {
|
||||
t.Fatalf("body.%s = %q, want %q\nstdout:\n%s", k, got, v, out)
|
||||
}
|
||||
}
|
||||
// When no --remark is passed, the body must NOT carry an empty
|
||||
// remark field (the owner's request card would otherwise render
|
||||
// a blank note).
|
||||
if _, wantsRemark := tt.wantBody["remark"]; !wantsRemark {
|
||||
if gjson.Get(out, "api.0.body.remark").Exists() {
|
||||
t.Fatalf("body.remark should be omitted when --remark is empty, stdout:\n%s", out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_ApplyPermissionDryRunRejectsFullAccess locks in the client-side
|
||||
// enum guard: the spec rejects perm=full_access, so the shortcut must refuse
|
||||
// it before the request ever reaches the server. Exercised end-to-end to
|
||||
// guarantee the enum validator is wired into the mount path.
|
||||
func TestDrive_ApplyPermissionDryRunRejectsFullAccess(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+apply-permission",
|
||||
"--token", "doxcnE2E999",
|
||||
"--type", "docx",
|
||||
"--perm", "full_access",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("full_access must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "perm") {
|
||||
t.Fatalf("expected perm-related error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
|
||||
t.Helper()
|
||||
stagePath := filepath.Join(workDir, stageName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write stage file %s: %v", stageName, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Remove(stagePath) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stageName,
|
||||
"--folder-token", folderToken,
|
||||
"--name", remoteName,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
return fileToken
|
||||
}
|
||||
|
||||
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
|
||||
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
if statusResult.ExitCode == 0 {
|
||||
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(statusResult.Stderr, `"type": "validation"`) ||
|
||||
!strings.Contains(statusResult.Stderr, "map to multiple Drive entries") {
|
||||
t.Fatalf("+status stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
|
||||
}
|
||||
|
||||
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if pullFailResult.ExitCode == 0 {
|
||||
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if !strings.Contains(pullFailResult.Stderr, `"type": "validation"`) ||
|
||||
!strings.Contains(pullFailResult.Stderr, "map to multiple Drive entries") {
|
||||
t.Fatalf("+pull stderr should be a typed validation error for duplicate rel_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
|
||||
}
|
||||
|
||||
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-duplicate-remote", "rename",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pullRenameResult.AssertExitCode(t, 0)
|
||||
pullRenameResult.AssertStdoutStatus(t, true)
|
||||
|
||||
items := gjson.Get(pullRenameResult.Stdout, "data.items")
|
||||
if items.Array() == nil || len(items.Array()) != 2 {
|
||||
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
|
||||
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
|
||||
}
|
||||
relPaths := []string{
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
|
||||
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
|
||||
}
|
||||
var renamedRel string
|
||||
for _, rel := range relPaths {
|
||||
if rel != "dup.txt" {
|
||||
renamedRel = rel
|
||||
}
|
||||
}
|
||||
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
|
||||
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
|
||||
}
|
||||
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
|
||||
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
|
||||
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
|
||||
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
|
||||
}
|
||||
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
|
||||
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
|
||||
})
|
||||
|
||||
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
|
||||
t.Fatalf("write local dup.txt: %v", err)
|
||||
}
|
||||
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
|
||||
|
||||
pushResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--if-exists", "overwrite",
|
||||
"--on-duplicate-remote", "newest",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
|
||||
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
|
||||
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
|
||||
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
|
||||
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) {
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "")
|
||||
subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken)
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local/sub: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil {
|
||||
t.Fatalf("write local/sub/keep.txt: %v", err)
|
||||
}
|
||||
|
||||
existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before")
|
||||
|
||||
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--if-exists", "overwrite",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
|
||||
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
|
||||
t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" {
|
||||
t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken {
|
||||
t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout)
|
||||
}
|
||||
|
||||
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, statusResult)
|
||||
statusResult.AssertExitCode(t, 0)
|
||||
statusResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
|
||||
t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" {
|
||||
t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout)
|
||||
}
|
||||
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
|
||||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
|
||||
t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveExportDryRun_FileNameMetadata(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", "+export",
|
||||
"--token", "docxDryRunExport",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "pdf",
|
||||
"--file-name", "custom-report",
|
||||
"--output-dir", "./exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.token").String(); got != "docxDryRunExport" {
|
||||
t.Fatalf("body.token=%q, want docxDryRunExport\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.type").String(); got != "docx" {
|
||||
t.Fatalf("body.type=%q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "pdf" {
|
||||
t.Fatalf("body.file_extension=%q, want pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.body.file_name").Exists() {
|
||||
t.Fatalf("file_name should stay local metadata, not export_tasks body\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "custom-report.pdf" {
|
||||
t.Fatalf("file_name=%q, want custom-report.pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "output_dir").String(); got != "./exports" {
|
||||
t.Fatalf("output_dir=%q, want ./exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_MarkdownFetchAPI(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", "+export",
|
||||
"--token", "docxMdDryRun",
|
||||
"--doc-type", "docx",
|
||||
"--file-extension", "markdown",
|
||||
"--file-name", "my-notes",
|
||||
"--output-dir", "./md-exports",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/docs_ai/v1/documents/docxMdDryRun/fetch" {
|
||||
t.Fatalf("url=%q, want docs_ai fetch\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.format").String(); got != "markdown" {
|
||||
t.Fatalf("body.format=%q, want markdown\nstdout:\n%s", got, out)
|
||||
}
|
||||
if gjson.Get(out, "api.0.body.extra_param").Exists() {
|
||||
t.Fatalf("markdown drive export must not enable docs fetch extra_param\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_name").String(); got != "my-notes.md" {
|
||||
t.Fatalf("file_name=%q, want my-notes.md\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "output_dir").String(); got != "./md-exports" {
|
||||
t.Fatalf("output_dir=%q, want ./md-exports\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveExportDryRun_BitableBaseOnlySchema(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", "+export",
|
||||
"--token", "bitableDryRunExport",
|
||||
"--doc-type", "bitable",
|
||||
"--file-extension", "base",
|
||||
"--only-schema",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/export_tasks" {
|
||||
t.Fatalf("url=%q, want export_tasks\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.token").String(); got != "bitableDryRunExport" {
|
||||
t.Fatalf("body.token=%q, want bitableDryRunExport\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.type").String(); got != "bitable" {
|
||||
t.Fatalf("body.type=%q, want bitable\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.file_extension").String(); got != "base" {
|
||||
t.Fatalf("body.file_extension=%q, want base\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.only_schema").Bool(); !got {
|
||||
t.Fatalf("body.only_schema=%v, want true\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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()
|
||||
parentFolderName := "lark-cli-e2e-drive-parent-" + suffix
|
||||
folderName := "lark-cli-e2e-drive-folder-" + suffix
|
||||
parentFolderToken := createDriveFolder(t, parentT, ctx, parentFolderName, "")
|
||||
|
||||
t.Run("create_folder as bot", func(t *testing.T) {
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, parentFolderToken)
|
||||
if folderToken == "" {
|
||||
t.Fatalf("folder token should be available")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.WriteFile(workDir+"/notes.md", []byte("# dry run\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+import",
|
||||
"--file", "notes.md",
|
||||
"--type", "docx",
|
||||
"--folder-token", "fldcnImportDryRunTarget",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("api.0.method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
|
||||
t.Fatalf("api.0.url = %q, want wiki get_node\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.token").String(); got != "fldcnImportDryRunTarget" {
|
||||
t.Fatalf("api.0.params.token = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
|
||||
t.Fatalf("api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
|
||||
t.Fatalf("api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// --- Happy path: all supported URL types ---
|
||||
|
||||
func TestDriveInspectDryRun_DocxURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/docx/doxcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DocURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/doc/doccnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_SheetURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/sheets/shtcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_BitableURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/base/bascnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_FileURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/file/boxcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveFileURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/file/boxcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_FolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/drive/folder/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoChatDriveFolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/chat/drive/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_DoubaoDriveShareFolderURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://feishu.doubao.com/drive/shr/fldcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_MindnoteURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/mindnote/mncnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
func TestDriveInspectDryRun_SlidesURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
result := runInspectDryRun(t, "https://xxx.feishu.cn/slides/slkcnDryRunE2E")
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
// --- Wiki URL: two-step flow ---
|
||||
|
||||
func TestDriveInspectDryRun_WikiURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "https://xxx.feishu.cn/wiki/wikcnDryRunE2E",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
require.Equal(t, int64(2), gjson.Get(result.Stdout, "api.#").Int(),
|
||||
"expected exactly 2 dry-run API steps for wiki URL, stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "/open-apis/wiki/v2/spaces/get_node",
|
||||
gjson.Get(result.Stdout, "api.0.url").String(),
|
||||
"expected get_node as first step, stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
|
||||
gjson.Get(result.Stdout, "api.1.url").String(),
|
||||
"expected batch_query as second step, stdout:\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
// --- Bare token with --type ---
|
||||
|
||||
func TestDriveInspectDryRun_BareTokenWithType(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "doxcnBareToken",
|
||||
"--type", "docx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
assertOneStepBatchQuery(t, result)
|
||||
}
|
||||
|
||||
// --- Validation errors ---
|
||||
|
||||
func TestDriveInspectValidation_EmptyURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stderr, "--url cannot be empty",
|
||||
"expected empty URL validation error, stderr:\n%s", result.Stderr)
|
||||
}
|
||||
|
||||
func TestDriveInspectValidation_UnsupportedURL(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "https://google.com/some/page",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stderr, "unsupported --url",
|
||||
"expected unsupported URL validation error, stderr:\n%s", result.Stderr)
|
||||
}
|
||||
|
||||
func TestDriveInspectValidation_BareTokenWithoutType(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "doxcnBareToken",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stderr, "--type is required when --url is a bare token",
|
||||
"expected bare-token-without-type validation error, stderr:\n%s", result.Stderr)
|
||||
}
|
||||
|
||||
func TestDriveInspectValidation_InvalidType(t *testing.T) {
|
||||
setDriveInspectE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", "someToken",
|
||||
"--type", "invalid_type",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stderr, "invalid_type",
|
||||
"expected invalid type validation error, stderr:\n%s", result.Stderr)
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func runInspectDryRun(t *testing.T, url string) *clie2e.Result {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+inspect",
|
||||
"--url", url,
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
return result
|
||||
}
|
||||
|
||||
func assertOneStepBatchQuery(t *testing.T, result *clie2e.Result) {
|
||||
t.Helper()
|
||||
|
||||
require.Equal(t, int64(1), gjson.Get(result.Stdout, "api.#").Int(),
|
||||
"expected exactly 1 dry-run API step, stdout:\n%s", result.Stdout)
|
||||
require.Equal(t, "/open-apis/drive/v1/metas/batch_query",
|
||||
gjson.Get(result.Stdout, "api.0.url").String(),
|
||||
"expected batch_query URL, stdout:\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
func setDriveInspectE2EEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_inspect_e2e_app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_inspect_e2e_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_MemberAddDryRun locks in the request shape the shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end (so flag parsing,
|
||||
// validation, and dry-run rendering all execute), and the printed request is
|
||||
// inspected to confirm
|
||||
// - HTTP method, URL template, and token path segment,
|
||||
// - type query parameter (auto-inferred from a URL input, explicit for a
|
||||
// bare token input),
|
||||
// - explicit --type overriding URL inference,
|
||||
// - member_type / member kind / perm / perm_type body fields,
|
||||
// - single-member vs batch endpoint selection.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before any
|
||||
// network call.
|
||||
func TestDrive_MemberAddDryRun(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantURL string
|
||||
wantResourceType string
|
||||
wantNeedNotification string
|
||||
wantMemberID string
|
||||
wantMemberType string
|
||||
wantPerm string
|
||||
wantMemberKind string
|
||||
wantPermType string
|
||||
wantBatch bool
|
||||
}{
|
||||
{
|
||||
name: "URL input auto-infers docx type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--need-notification=false",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E001/members",
|
||||
wantResourceType: "docx",
|
||||
wantNeedNotification: "false",
|
||||
wantMemberID: "ou_e2e_user",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "URL input auto-infers mindnote type from mindnotes path",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/mindnotes/mndE2E011?from=share",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/mndE2E011/members",
|
||||
wantResourceType: "mindnote",
|
||||
wantMemberID: "ou_e2e_user",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit wiki type defaults perm_type container",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "wikcnE2E002",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_e2e_admin",
|
||||
"--member-type", "openid",
|
||||
"--perm", "full_access",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/wikcnE2E002/members",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberID: "ou_e2e_admin",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "full_access",
|
||||
wantMemberKind: "user",
|
||||
wantPermType: "container",
|
||||
},
|
||||
{
|
||||
// Explicit --type must override URL inference: the /docx/ marker
|
||||
// would infer type=docx, but the caller asked to grant permission
|
||||
// against a wiki node. The URL token itself is still used as the
|
||||
// path token.
|
||||
name: "explicit --type overrides URL inference",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E009",
|
||||
"--type", "wiki",
|
||||
"--member-id", "ou_e2e_override",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E009/members",
|
||||
wantResourceType: "wiki",
|
||||
wantMemberID: "ou_e2e_override",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
wantPermType: "container",
|
||||
},
|
||||
{
|
||||
name: "bare token with explicit folder type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "fldE2E010",
|
||||
"--type", "folder",
|
||||
"--member-id", "ou_e2e_folder",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/fldE2E010/members",
|
||||
wantResourceType: "folder",
|
||||
wantMemberID: "ou_e2e_folder",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "email member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "shtcnE2E003",
|
||||
"--type", "sheet",
|
||||
"--member-id", "user@example.com",
|
||||
"--member-type", "email",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/shtcnE2E003/members",
|
||||
wantResourceType: "sheet",
|
||||
wantMemberID: "user@example.com",
|
||||
wantMemberType: "email",
|
||||
wantPerm: "edit",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "unionid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E006",
|
||||
"--type", "docx",
|
||||
"--member-id", "on_e2e_union",
|
||||
"--member-type", "unionid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E006/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "on_e2e_union",
|
||||
wantMemberType: "unionid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
},
|
||||
{
|
||||
name: "explicit-only groupid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E007",
|
||||
"--type", "docx",
|
||||
"--member-id", "group_e2e",
|
||||
"--member-type", "groupid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E007/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "group_e2e",
|
||||
wantMemberType: "groupid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "group",
|
||||
},
|
||||
{
|
||||
name: "batch members use batch_create endpoint",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "bascnE2E004",
|
||||
"--type", "bitable",
|
||||
"--member-id", "ou_a,ou_b",
|
||||
"--member-type", "openid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/bascnE2E004/members/batch_create",
|
||||
wantResourceType: "bitable",
|
||||
wantMemberID: "ou_a",
|
||||
wantMemberType: "openid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "user",
|
||||
wantBatch: true,
|
||||
},
|
||||
{
|
||||
name: "explicit groupid member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E005",
|
||||
"--type", "docx",
|
||||
"--member-id", "grp_abc",
|
||||
"--member-type", "groupid",
|
||||
"--perm", "edit",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E005/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "grp_abc",
|
||||
wantMemberType: "groupid",
|
||||
wantPerm: "edit",
|
||||
wantMemberKind: "group",
|
||||
},
|
||||
{
|
||||
name: "appid member-type is passed through",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E008",
|
||||
"--type", "docx",
|
||||
"--member-id", "cli_app_123",
|
||||
"--member-type", "appid",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E008/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "cli_app_123",
|
||||
wantMemberType: "appid",
|
||||
wantPerm: "view",
|
||||
},
|
||||
{
|
||||
name: "wikispaceid member-type requires wiki-space body type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E012",
|
||||
"--type", "docx",
|
||||
"--member-id", "spc_e2e_wiki",
|
||||
"--member-type", "wikispaceid",
|
||||
"--member-kind", "wiki_space_editor",
|
||||
"--perm", "view",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/drive/v1/permissions/doxcnE2E012/members",
|
||||
wantResourceType: "docx",
|
||||
wantMemberID: "spc_e2e_wiki",
|
||||
wantMemberType: "wikispaceid",
|
||||
wantPerm: "view",
|
||||
wantMemberKind: "wiki_space_editor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method = %q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != tt.wantResourceType {
|
||||
t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantResourceType, out)
|
||||
}
|
||||
notification := gjson.Get(out, "api.0.params.need_notification")
|
||||
if tt.wantNeedNotification == "" {
|
||||
if notification.Exists() {
|
||||
t.Fatalf("need_notification should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := notification.String(); got != tt.wantNeedNotification {
|
||||
t.Fatalf("need_notification = %q, want %q\nstdout:\n%s", got, tt.wantNeedNotification, out)
|
||||
}
|
||||
bodyPath := "api.0.body"
|
||||
if tt.wantBatch {
|
||||
bodyPath = "api.0.body.members.0"
|
||||
if count := len(gjson.Get(out, "api.0.body.members").Array()); count != 2 {
|
||||
t.Fatalf("body.members count = %d, want 2\nstdout:\n%s", count, out)
|
||||
}
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".member_id").String(); got != tt.wantMemberID {
|
||||
t.Fatalf("body.member_id = %q, want %q\nstdout:\n%s", got, tt.wantMemberID, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".member_type").String(); got != tt.wantMemberType {
|
||||
t.Fatalf("body.member_type = %q, want %q\nstdout:\n%s", got, tt.wantMemberType, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".perm").String(); got != tt.wantPerm {
|
||||
t.Fatalf("body.perm = %q, want %q\nstdout:\n%s", got, tt.wantPerm, out)
|
||||
}
|
||||
if got := gjson.Get(out, bodyPath+".type").String(); got != tt.wantMemberKind {
|
||||
t.Fatalf("body.type = %q, want %q\nstdout:\n%s", got, tt.wantMemberKind, out)
|
||||
}
|
||||
permType := gjson.Get(out, bodyPath+".perm_type")
|
||||
if tt.wantPermType == "" {
|
||||
if permType.Exists() {
|
||||
t.Fatalf("perm_type should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
} else if got := permType.String(); got != tt.wantPermType {
|
||||
t.Fatalf("body.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_MemberAddDryRunRejectsInvalidInputs(t *testing.T) {
|
||||
// Isolate from any local CLI state: the subprocess inherits the parent
|
||||
// test environment, and without an explicit config dir it could read a
|
||||
// developer's real credentials/profile instead of the fake ones below.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
defaultAs string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "any host accepted",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://google.com/docx/doxcnE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported URL path",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "https://example.feishu.cn/calendar/calE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "unsupported URL path",
|
||||
},
|
||||
{
|
||||
name: "bare token requires explicit type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "unknownE2E001",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--type is required when --token is a bare token",
|
||||
},
|
||||
{
|
||||
name: "duplicate member IDs are rejected",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_a,ou_b,ou_a",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "duplicate collaborator ID",
|
||||
},
|
||||
{
|
||||
name: "invalid explicit type is rejected",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "mincnE2E001",
|
||||
"--type", "invalidtype",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--type must be one of: docx, doc, sheet, bitable, file, folder, wiki, mindnote, slides, minutes",
|
||||
},
|
||||
{
|
||||
name: "member-id prefix conflicts with explicit member-type",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_e2e_user,oc_e2e_chat",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "implies --member-type openchat",
|
||||
},
|
||||
{
|
||||
name: "explicit member-type conflicts with member-id prefix",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "oc_e2e_chat",
|
||||
"--member-type", "openid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "implies --member-type openchat",
|
||||
},
|
||||
{
|
||||
name: "department collaborator requires user identity",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "od_e2e_dept",
|
||||
"--member-type", "opendepartmentid",
|
||||
"--dry-run",
|
||||
},
|
||||
defaultAs: "bot",
|
||||
wantErr: "--member-type=opendepartmentid requires --as user",
|
||||
},
|
||||
{
|
||||
name: "wikispaceid requires member-kind",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "spc_e2e_wiki",
|
||||
"--member-type", "wikispaceid",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--member-kind is required when --member-type=wikispaceid",
|
||||
},
|
||||
{
|
||||
name: "member-kind only applies to wikispaceid",
|
||||
args: []string{
|
||||
"drive", "+member-add",
|
||||
"--token", "doxcnE2E001",
|
||||
"--type", "docx",
|
||||
"--member-id", "ou_e2e_user",
|
||||
"--member-type", "openid",
|
||||
"--member-kind", "wiki_space_member",
|
||||
"--dry-run",
|
||||
},
|
||||
wantErr: "--member-kind only applies when --member-type=wikispaceid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
defaultAs := tt.defaultAs
|
||||
if defaultAs == "" {
|
||||
defaultAs = "user"
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.wantErr == "" {
|
||||
result.AssertExitCode(t, 0)
|
||||
return
|
||||
}
|
||||
|
||||
result.AssertExitCode(t, 2)
|
||||
output := result.Stdout + result.Stderr
|
||||
require.Contains(t, output, tt.wantErr, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrivePreviewDryRun_ListOnly verifies preview dry-run request structure
|
||||
// for list mode.
|
||||
func TestDrivePreviewDryRun_ListOnly(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", "+preview",
|
||||
"--file-token", "fileDryRunPreview",
|
||||
"--list-only",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_result" {
|
||||
t.Fatalf("url=%q, want preview_result endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "mode").String(); got != "list" {
|
||||
t.Fatalf("mode=%q, want list\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePreviewDryRun_Download verifies preview dry-run request structure
|
||||
// for download mode.
|
||||
func TestDrivePreviewDryRun_Download(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", "+preview",
|
||||
"--file-token", "fileDryRunPreview",
|
||||
"--type", "pdf",
|
||||
"--version", "12",
|
||||
"--output", "./artifacts/report",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.#").Int(); got != 2 {
|
||||
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.version").String(); got != "12" {
|
||||
t.Fatalf("version=%q, want 12\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.method").String(); got != "GET" {
|
||||
t.Fatalf("download method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunPreview/preview_download" {
|
||||
t.Fatalf("download url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.params.preview_type").String(); got != "<selected type_code from preview_result>" {
|
||||
t.Fatalf("preview_type=%q, want placeholder\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.1.params.version").String(); got != "12" {
|
||||
t.Fatalf("download version=%q, want 12\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "requested_type").String(); got != "pdf" {
|
||||
t.Fatalf("requested_type=%q, want pdf\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "output").String(); got != "./artifacts/report" {
|
||||
t.Fatalf("output=%q, want ./artifacts/report\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveCoverDryRun_Download verifies cover dry-run request structure for
|
||||
// download mode.
|
||||
func TestDriveCoverDryRun_Download(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", "+cover",
|
||||
"--file-token", "fileDryRunCover",
|
||||
"--spec", "square",
|
||||
"--output", "./artifacts/cover",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method=%q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/medias/fileDryRunCover/preview_download" {
|
||||
t.Fatalf("url=%q, want preview_download endpoint\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.preview_type").String(); got != "1" {
|
||||
t.Fatalf("preview_type=%q, want 1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.bus_type").Exists(); got {
|
||||
t.Fatalf("bus_type should be omitted for square crop flow\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.platform").Exists(); got {
|
||||
t.Fatalf("platform should be omitted when using default platform\nstdout:\n%s", out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.width").String(); got != "360" {
|
||||
t.Fatalf("width=%q, want 360\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.height").String(); got != "360" {
|
||||
t.Fatalf("height=%q, want 360\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.policy").String(); got != "near" {
|
||||
t.Fatalf("policy=%q, want near\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "selected_spec").String(); got != "square" {
|
||||
t.Fatalf("selected_spec=%q, want square\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_PreviewAndCoverWorkflow verifies preview and cover shortcuts against
|
||||
// a live Drive workflow, skipping when required bot scopes are unavailable.
|
||||
func TestDrive_PreviewAndCoverWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-preview-" + suffix
|
||||
folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName)
|
||||
|
||||
workDir := t.TempDir()
|
||||
sourceRelPath := "fixture/report.txt"
|
||||
sourceContent := "drive preview and cover workflow\n"
|
||||
writePreviewFixture(t, workDir, sourceRelPath, sourceContent)
|
||||
|
||||
fileToken := uploadPreviewFixture(t, parentT, ctx, workDir, folderToken, sourceRelPath, "report.txt")
|
||||
|
||||
t.Run("preview list and download", func(t *testing.T) {
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+preview",
|
||||
"--file-token", fileToken,
|
||||
"--list-only",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{
|
||||
Attempts: 8,
|
||||
InitialDelay: 2 * time.Second,
|
||||
MaxDelay: 8 * time.Second,
|
||||
BackoffMultiple: 2,
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil || result.ExitCode != 0 {
|
||||
return true
|
||||
}
|
||||
return !previewListContainsReadyType(result.Stdout, "text")
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
if !previewListContainsReadyType(listResult.Stdout, "text") {
|
||||
t.Fatalf("preview list did not expose downloadable text preview\nstdout:\n%s", listResult.Stdout)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+preview",
|
||||
"--file-token", fileToken,
|
||||
"--type", "text",
|
||||
"--output", "./artifacts/report-preview",
|
||||
},
|
||||
WorkDir: downloadDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
stdout := downloadResult.Stdout
|
||||
if got := gjson.Get(stdout, "data.selected_type").String(); got != "text" {
|
||||
t.Fatalf("selected_type=%q, want text\nstdout:\n%s", got, stdout)
|
||||
}
|
||||
outputPath := gjson.Get(stdout, "data.output_path").String()
|
||||
require.NotEmpty(t, outputPath, "preview download should return output_path")
|
||||
if ext := filepath.Ext(outputPath); ext != ".txt" {
|
||||
t.Fatalf("preview output extension=%q, want .txt\nstdout:\n%s", ext, stdout)
|
||||
}
|
||||
data, readErr := os.ReadFile(outputPath)
|
||||
require.NoError(t, readErr)
|
||||
if !strings.Contains(string(data), "drive preview and cover workflow") {
|
||||
t.Fatalf("preview artifact content mismatch: %q", string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cover list and download", func(t *testing.T) {
|
||||
listResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+cover",
|
||||
"--file-token", fileToken,
|
||||
"--list-only",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
if !gjson.Get(listResult.Stdout, `data.candidates.#(spec=="default")`).Exists() {
|
||||
t.Fatalf("cover list missing default spec\nstdout:\n%s", listResult.Stdout)
|
||||
}
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
coverResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+cover",
|
||||
"--file-token", fileToken,
|
||||
"--spec", "default",
|
||||
"--output", "./artifacts/report-cover",
|
||||
},
|
||||
WorkDir: downloadDir,
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{
|
||||
Attempts: 8,
|
||||
InitialDelay: 2 * time.Second,
|
||||
MaxDelay: 8 * time.Second,
|
||||
BackoffMultiple: 2,
|
||||
ShouldRetry: func(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return true
|
||||
}
|
||||
if result.ExitCode == 0 {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
coverResult.AssertExitCode(t, 0)
|
||||
coverResult.AssertStdoutStatus(t, true)
|
||||
|
||||
stdout := coverResult.Stdout
|
||||
if got := gjson.Get(stdout, "data.selected_spec").String(); got != "default" {
|
||||
t.Fatalf("selected_spec=%q, want default\nstdout:\n%s", got, stdout)
|
||||
}
|
||||
outputPath := gjson.Get(stdout, "data.output_path").String()
|
||||
require.NotEmpty(t, outputPath, "cover download should return output_path")
|
||||
if ext := filepath.Ext(outputPath); ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" {
|
||||
t.Fatalf("cover output extension=%q, want image extension\nstdout:\n%s", ext, stdout)
|
||||
}
|
||||
info, statErr := os.Stat(outputPath)
|
||||
require.NoError(t, statErr)
|
||||
if info.Size() <= 0 {
|
||||
t.Fatalf("cover artifact should not be empty: %s", outputPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// writePreviewFixture writes a local fixture file used by the live workflow.
|
||||
func writePreviewFixture(t *testing.T, workDir, relPath, content string) {
|
||||
t.Helper()
|
||||
|
||||
fullPath := filepath.Join(workDir, relPath)
|
||||
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
|
||||
t.Fatalf("mkdir fixture parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(fullPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// uploadPreviewFixture uploads a fixture into Drive and registers cleanup for
|
||||
// the created file token.
|
||||
func uploadPreviewFixture(t *testing.T, parentT *testing.T, ctx context.Context, workDir, folderToken, relPath, uploadName string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", relPath,
|
||||
"--folder-token", folderToken,
|
||||
"--name", uploadName,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token")
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
return fileToken
|
||||
}
|
||||
|
||||
// previewListContainsReadyType reports whether a preview list response contains
|
||||
// a downloadable candidate for the requested type.
|
||||
func previewListContainsReadyType(stdout, wantType string) bool {
|
||||
for _, candidate := range gjson.Get(stdout, "data.candidates").Array() {
|
||||
if candidate.Get("type").String() != wantType {
|
||||
continue
|
||||
}
|
||||
if candidate.Get("downloadable").Bool() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createDriveFolderOrSkipPermission creates a Drive folder for the live
|
||||
// workflow and skips when the bot lacks required folder scopes.
|
||||
func createDriveFolderOrSkipPermission(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+create-folder", "--name", name},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode != 0 {
|
||||
combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedOutput, "app scope not enabled") ||
|
||||
strings.Contains(combinedOutput, "space:folder:create") ||
|
||||
strings.Contains(combinedOutput, "99991672") {
|
||||
t.Skipf("skip drive preview/cover workflow due to missing bot scope space:folder:create: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr))
|
||||
}
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
|
||||
require.NotEmpty(t, folderToken, "drive folder token should not be empty")
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", folderToken, "--type", "folder", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
return folderToken
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_PullDryRun locks in the request shape the +pull shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
|
||||
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
|
||||
// all execute. The printed envelope is then inspected for GET method,
|
||||
// list-files URL, the folder_token parameter, and key phrases from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any real network call.
|
||||
func TestDrive_PullDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "list --folder-token") {
|
||||
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsAbsoluteLocalDir confirms the path validator
|
||||
// runs in the real binary's Validate stage and surfaces a structured error
|
||||
// referencing --local-dir.
|
||||
func TestDrive_PullDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsDeleteLocalWithoutYes locks in the safety
|
||||
// guard: --delete-local without --yes must be refused upfront, even under
|
||||
// --dry-run, so an unintended delete flag never silently slides through.
|
||||
func TestDrive_PullDryRunRejectsDeleteLocalWithoutYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("--delete-local without --yes must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--yes") {
|
||||
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PullDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"rename", "newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PullDryRunAcceptsIfExistsSmart(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--if-exists", "smart",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_PushDryRun locks in the request shape the +push shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
|
||||
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
|
||||
// all execute. The printed envelope is then inspected for GET method,
|
||||
// list-files URL, the folder_token parameter, and key phrases from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any real network call.
|
||||
func TestDrive_PushDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "list --folder-token") {
|
||||
t.Fatalf("description missing list phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
if !strings.Contains(desc, "upload") {
|
||||
t.Fatalf("description missing upload phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsAbsoluteLocalDir confirms the path validator
|
||||
// runs in the real binary's Validate stage and surfaces a structured error
|
||||
// referencing --local-dir.
|
||||
func TestDrive_PushDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Validate-stage rejection emits ExitValidation (2). A regression
|
||||
// that reclassified this as a generic api_error (1) or success (0)
|
||||
// would slip through a loose `!= 0` check, so assert the exact code.
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("absolute --local-dir must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes locks in the safety
|
||||
// guard: --delete-remote without --yes must be refused upfront, even
|
||||
// under --dry-run, so an unintended delete flag never silently slides
|
||||
// through.
|
||||
func TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-remote",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Same exact-code reasoning as the absolute-path test: this is a
|
||||
// Validate-stage rejection so it must surface as ExitValidation (2).
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("--delete-remote without --yes must be rejected with exit=2 (Validate), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--yes") {
|
||||
t.Fatalf("expected --yes hint in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunAcceptsDeleteRemoteWithYes is the symmetric guard
|
||||
// to TestDrive_PushDryRunRejectsDeleteRemoteWithoutYes: when --yes is
|
||||
// passed alongside --delete-remote, Validate must accept the run and
|
||||
// hand off to the dry-run renderer.
|
||||
//
|
||||
// Specifically pins the conditional scope pre-check added to Validate:
|
||||
// when the resolver has no token / no scope metadata (the e2e setup
|
||||
// uses fake credentials with no real auth state), runtime.EnsureScopes
|
||||
// is a silent no-op so dry-run still emits its envelope. A regression
|
||||
// where the pre-check incorrectly fired against an empty scope list
|
||||
// would surface here as a non-zero exit and a missing_scope error.
|
||||
func TestDrive_PushDryRunAcceptsDeleteRemoteWithYes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--delete-remote",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
// No structured error envelope on stdout/stderr — the conditional
|
||||
// EnsureScopes call must not trip a missing_scope here.
|
||||
if strings.Contains(out, `"type": "missing_scope"`) || strings.Contains(result.Stderr, "missing_scope") {
|
||||
t.Fatalf("conditional scope pre-check fired in a no-credential env\nstdout:\n%s\nstderr:\n%s", out, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_PushDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// A missing cobra required-flag is routed through the typed validation
|
||||
// envelope (exit 2, invalid_argument) — the same class as the explicit
|
||||
// flag/subcommand guards, not cobra's plain-text exit 1.
|
||||
if result.ExitCode != 2 {
|
||||
t.Fatalf("missing --folder-token must be rejected with exit=2 (typed validation), got exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PushDryRunAcceptsIfExistsSmart(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--if-exists", "smart",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// 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"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDriveSearchDryRun_RequestShape locks in the dry-run request body so
|
||||
// agents that key off of stdout (URL, doc_filter / wiki_filter, scalar
|
||||
// filters) don't silently regress. Run end-to-end so cobra flag parsing,
|
||||
// readDriveSearchSpec, and the dry-run renderer all execute against the
|
||||
// real binary.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any network call.
|
||||
func TestDriveSearchDryRun_RequestShape(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
// JSONPath assertions over the dry-run body.
|
||||
wantURL string
|
||||
wantQuery string
|
||||
wantDocFilter bool
|
||||
wantWikiFilter bool
|
||||
wantDocFilterFields map[string]string // gjson path under api.0.body.doc_filter -> string value (or "" to require existence only)
|
||||
wantWikiFilterFields map[string]string
|
||||
}{
|
||||
{
|
||||
name: "basic --query emits both filters",
|
||||
args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "season report",
|
||||
"--page-size", "5",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/search/v2/doc_wiki/search",
|
||||
wantQuery: "season report",
|
||||
wantDocFilter: true,
|
||||
wantWikiFilter: true,
|
||||
},
|
||||
{
|
||||
name: "--folder-tokens scopes to doc_filter only",
|
||||
args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--folder-tokens", "fld_aaa,fld_bbb",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/search/v2/doc_wiki/search",
|
||||
wantQuery: "x",
|
||||
wantDocFilter: true,
|
||||
wantDocFilterFields: map[string]string{
|
||||
"folder_tokens.0": "fld_aaa",
|
||||
"folder_tokens.1": "fld_bbb",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--space-ids scopes to wiki_filter only",
|
||||
args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--space-ids", "sp_xxx",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/search/v2/doc_wiki/search",
|
||||
wantQuery: "x",
|
||||
wantWikiFilter: true,
|
||||
wantWikiFilterFields: map[string]string{
|
||||
"space_ids.0": "sp_xxx",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "--sort default maps to DEFAULT_TYPE in body",
|
||||
args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--sort", "default",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/search/v2/doc_wiki/search",
|
||||
wantQuery: "x",
|
||||
wantDocFilter: true,
|
||||
wantWikiFilter: true,
|
||||
wantDocFilterFields: map[string]string{
|
||||
"sort_type": "DEFAULT_TYPE",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed-case --doc-types is normalized to upper case in body",
|
||||
args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--doc-types", "docx,Sheet,BITABLE",
|
||||
"--dry-run",
|
||||
},
|
||||
wantURL: "/open-apis/search/v2/doc_wiki/search",
|
||||
wantQuery: "x",
|
||||
wantDocFilter: true,
|
||||
wantWikiFilter: true,
|
||||
wantDocFilterFields: map[string]string{
|
||||
"doc_types.0": "DOCX",
|
||||
"doc_types.1": "SHEET",
|
||||
"doc_types.2": "BITABLE",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url=%q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.query").String(); got != tt.wantQuery {
|
||||
t.Fatalf("body.query=%q, want %q\nstdout:\n%s", got, tt.wantQuery, out)
|
||||
}
|
||||
if tt.wantDocFilter && !gjson.Get(out, "api.0.body.doc_filter").Exists() {
|
||||
t.Fatalf("doc_filter missing\nstdout:\n%s", out)
|
||||
}
|
||||
if !tt.wantDocFilter && gjson.Get(out, "api.0.body.doc_filter").Exists() {
|
||||
t.Fatalf("doc_filter should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
if tt.wantWikiFilter && !gjson.Get(out, "api.0.body.wiki_filter").Exists() {
|
||||
t.Fatalf("wiki_filter missing\nstdout:\n%s", out)
|
||||
}
|
||||
if !tt.wantWikiFilter && gjson.Get(out, "api.0.body.wiki_filter").Exists() {
|
||||
t.Fatalf("wiki_filter should be omitted\nstdout:\n%s", out)
|
||||
}
|
||||
for path, want := range tt.wantDocFilterFields {
|
||||
if got := gjson.Get(out, "api.0.body.doc_filter."+path).String(); got != want {
|
||||
t.Fatalf("doc_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
|
||||
}
|
||||
}
|
||||
for path, want := range tt.wantWikiFilterFields {
|
||||
if got := gjson.Get(out, "api.0.body.wiki_filter."+path).String(); got != want {
|
||||
t.Fatalf("wiki_filter.%s=%q, want %q\nstdout:\n%s", path, got, want, out)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveSearchDryRun_BotIdentity(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "season report",
|
||||
"--page-size", "5",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Contains(t, result.Args, "--as")
|
||||
require.Contains(t, result.Args, "bot")
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("method=%q, want POST\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/search/v2/doc_wiki/search" {
|
||||
t.Fatalf("url=%q, want Search v2 doc_wiki/search\nstdout:\n%s\nstderr:\n%s", got, out, result.Stderr)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.query").String(); got != "season report" {
|
||||
t.Fatalf("body.query=%q, want season report\nstdout:\n%s", got, out)
|
||||
}
|
||||
|
||||
helpResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+search", "--help"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
helpResult.AssertExitCode(t, 0)
|
||||
require.Contains(t, helpResult.Stdout, "identity type: user | bot")
|
||||
}
|
||||
|
||||
// TestDriveSearchDryRun_OpenedClamping locks in the agent-facing slice
|
||||
// notice for --opened-* spans over 90 days: the request body must carry
|
||||
// the most recent 90-day window, and stderr must list slice N's flag
|
||||
// values verbatim so the agent can re-invoke for older ranges.
|
||||
func TestDriveSearchDryRun_OpenedClamping(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--opened-since", "8m",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
// Notice goes to stderr alongside other dimension notices.
|
||||
for _, want := range []string{
|
||||
"--opened-* window spans",
|
||||
"3 slices total",
|
||||
"[slice 1/3 current]",
|
||||
"[slice 2/3]",
|
||||
"[slice 3/3]",
|
||||
"--page-token",
|
||||
} {
|
||||
if !strings.Contains(result.Stderr, want) {
|
||||
t.Fatalf("notice missing %q\nstderr:\n%s", want, result.Stderr)
|
||||
}
|
||||
}
|
||||
// Slice 1 specifically must spell out concrete --opened-* flag values
|
||||
// (not just the timestamps in arrow form): an agent paginating slice 1
|
||||
// has to copy these verbatim, otherwise reusing the original relative
|
||||
// time '8m' would drift the window against time.Now() and mismatch the
|
||||
// page_token.
|
||||
for _, label := range []string{"[slice 1/3 current]", "[slice 2/3]", "[slice 3/3]"} {
|
||||
var line string
|
||||
for _, l := range strings.Split(result.Stderr, "\n") {
|
||||
if strings.Contains(l, label) {
|
||||
line = l
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.Contains(line, "--opened-since ") || !strings.Contains(line, "--opened-until ") {
|
||||
t.Fatalf("%s line must spell out both flags, got %q\nfull stderr:\n%s", label, line, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// And the request body's open_time must reflect the clamped window
|
||||
// (start and end both present, span = 90 days exactly).
|
||||
body := result.Stdout
|
||||
start := gjson.Get(body, "api.0.body.doc_filter.open_time.start").Int()
|
||||
end := gjson.Get(body, "api.0.body.doc_filter.open_time.end").Int()
|
||||
if start == 0 || end == 0 {
|
||||
t.Fatalf("doc_filter.open_time.start/end missing\nstdout:\n%s", body)
|
||||
}
|
||||
if delta := end - start; delta != 90*86400 {
|
||||
t.Fatalf("clamped span = %d seconds, want %d (90 days)\nstdout:\n%s", delta, 90*86400, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSearchDryRun_RejectsOpenedOver1Year locks in the hard cap: a
|
||||
// --opened-* span beyond 365 days fails validation up front and never
|
||||
// reaches the API. Important because the alternative (silent slicing into
|
||||
// many windows) would produce a rate-limit / runaway request loop.
|
||||
//
|
||||
// Dry-run captures spec-level validation errors into the JSON envelope's
|
||||
// `error` field (api list comes back empty); the process still exits 0
|
||||
// because the dry-run itself succeeded — it just told you what would have
|
||||
// failed at execution time.
|
||||
func TestDriveSearchDryRun_RejectsOpenedOver1Year(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--opened-since", "2y",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
|
||||
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
errMsg := gjson.Get(result.Stdout, "error").String()
|
||||
if !strings.Contains(errMsg, "365-day") {
|
||||
t.Fatalf("expected 365-day cap message in dry-run error, got %q\nstdout:\n%s", errMsg, result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSearchDryRun_RejectsInvalidSort locks in the cobra Enum guard.
|
||||
// CLI intentionally exposes only 5 sort values (default, edit_time,
|
||||
// edit_time_asc, open_time, create_time); the deprecated /
|
||||
// not-supported server enum values must be rejected before reaching the
|
||||
// request layer.
|
||||
func TestDriveSearchDryRun_RejectsInvalidSort(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--sort", "create_time_asc",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("invalid sort must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
// Pin to the flag name (with dashes) rather than the bare word "sort",
|
||||
// which would also match "transport" / "sortable" / etc.
|
||||
if !strings.Contains(combined, "--sort") {
|
||||
t.Fatalf("expected --sort error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDriveSearchDryRun_RejectsBadDocType verifies the doc-types validator
|
||||
// is wired at the dry-run path: an unknown enum value surfaces as a
|
||||
// validation error inside the dry-run JSON envelope rather than reaching
|
||||
// the server. The process still exits 0 (see RejectsOpenedOver1Year).
|
||||
func TestDriveSearchDryRun_RejectsBadDocType(t *testing.T) {
|
||||
setDriveSearchE2EEnv(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+search",
|
||||
"--query", "x",
|
||||
"--doc-types", "docx,pie",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
if api := gjson.Get(result.Stdout, "api"); api.IsArray() && len(api.Array()) > 0 {
|
||||
t.Fatalf("dry-run api list must be empty when validation fails\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
errMsg := gjson.Get(result.Stdout, "error").String()
|
||||
if !strings.Contains(errMsg, "--doc-types") {
|
||||
t.Fatalf("expected --doc-types error in dry-run, got %q\nstdout:\n%s", errMsg, result.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func setDriveSearchE2EEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_search_e2e_app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_search_e2e_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestDrive_SecureLabelDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantMethod string
|
||||
wantURL string
|
||||
assert func(t *testing.T, out string)
|
||||
}{
|
||||
{
|
||||
name: "list available labels",
|
||||
args: []string{
|
||||
"drive", "+secure-label-list",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_1",
|
||||
"--lang", "zh",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantURL: "/open-apis/drive/v2/my_secure_labels",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 5 {
|
||||
t.Fatalf("page_size = %d, want 5\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.page_token").String(); got != "page_1" {
|
||||
t.Fatalf("page_token = %q, want page_1\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.params.lang").String(); got != "zh" {
|
||||
t.Fatalf("lang = %q, want zh\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update label with URL inference",
|
||||
args: []string{
|
||||
"drive", "+secure-label-update",
|
||||
"--token", "https://example.feishu.cn/docx/doxcnE2E001?from=share",
|
||||
"--label-id", "7217780879644737539",
|
||||
"--dry-run",
|
||||
},
|
||||
wantMethod: "PATCH",
|
||||
wantURL: "/open-apis/drive/v2/files/doxcnE2E001/secure_label",
|
||||
assert: func(t *testing.T, out string) {
|
||||
if got := gjson.Get(out, "api.0.params.type").String(); got != "docx" {
|
||||
t.Fatalf("type = %q, want docx\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.body.id").String(); got != "7217780879644737539" {
|
||||
t.Fatalf("body.id = %q, want label id\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "file_token").String(); got != "doxcnE2E001" {
|
||||
t.Fatalf("file_token = %q, want doxcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %s\nstdout:\n%s", got, tt.wantMethod, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != tt.wantURL {
|
||||
t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out)
|
||||
}
|
||||
tt.assert(t, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_StatusDryRun locks in the request shape the +status shortcut
|
||||
// emits under --dry-run: the real CLI binary is invoked end-to-end, so the
|
||||
// full flag-parsing, Validate (which still runs in dry-run mode), and the
|
||||
// dry-run renderer all execute. The printed envelope is then inspected to
|
||||
// confirm the GET method, list-files URL, and folder_token parameter, plus
|
||||
// the descriptive text from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any network call.
|
||||
func TestDrive_StatusDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
// Validate runs even under --dry-run, so we need a real --local-dir
|
||||
// inside the working directory; create one in a temp tree.
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "Walk --local-dir") || !strings.Contains(desc, "SHA-256") {
|
||||
t.Fatalf("description missing key phrases, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_StatusDryRunQuick(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--quick",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
|
||||
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
|
||||
// --local-dir path validator runs in the real binary's Validate stage and
|
||||
// surfaces a structured error referencing --local-dir (not the framework
|
||||
// default --file).
|
||||
func TestDrive_StatusDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_StatusDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error message, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_StatusWorkflow exercises +status against a real Drive folder so
|
||||
// the parts that dry-run can't reach — recursive listing pagination, the
|
||||
// download+hash leg, scope handling, and the SHA-256 comparison itself —
|
||||
// are covered against the real backend.
|
||||
//
|
||||
// Layout:
|
||||
//
|
||||
// folder/ (--folder-token target)
|
||||
// ├── unchanged.txt "match" ↔ local: "match" → unchanged
|
||||
// ├── modified.txt "remote" ↔ local: "local" → modified
|
||||
// └── remote-only.txt "remote" ↔ (none) → new_remote
|
||||
// local/ (--local-dir target)
|
||||
// ├── unchanged.txt "match"
|
||||
// ├── modified.txt "local"
|
||||
// └── local-only.txt "anything" → new_local
|
||||
//
|
||||
// Expected output: each of the four buckets contains exactly the file we
|
||||
// expect, with file_token set for the three buckets that have a Drive side.
|
||||
func TestDrive_StatusWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-status-" + suffix
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
|
||||
|
||||
// Local working directory. +status's --local-dir must be relative to
|
||||
// the binary's cwd, so each upload + the +status invocation share the
|
||||
// same WorkDir.
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
// Helper: write a local file under workDir/<rel>.
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: stage <content> into a sibling temp file then upload it as
|
||||
// <name> under folderToken. +upload reads --file relative to its cwd.
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
// Stage outside `local/` so the local-side tree only sees what
|
||||
// the test wants; +upload still reads relative to workDir.
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
|
||||
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)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
// Seed both sides. Order doesn't matter functionally, but doing the
|
||||
// uploads first lets the +status listing pick up everything in a
|
||||
// single pass.
|
||||
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
|
||||
tokModified := uploadDriveFile("modified.txt", "remote")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
|
||||
|
||||
writeLocal("local/unchanged.txt", "match") // matches remote → unchanged
|
||||
writeLocal("local/modified.txt", "local") // differs → modified
|
||||
writeLocal("local/local-only.txt", "extra") // only here → new_local
|
||||
|
||||
// Run +status against the real folder.
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
// Assert each bucket contains exactly the file we expect, with the
|
||||
// correct file_token for sides that have one.
|
||||
out := result.Stdout
|
||||
|
||||
cases := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string // empty when the bucket has no Drive side
|
||||
}{
|
||||
{"unchanged", "unchanged.txt", tokUnchanged},
|
||||
{"modified", "modified.txt", tokModified},
|
||||
{"new_local", "local-only.txt", ""},
|
||||
{"new_remote", "remote-only.txt", tokRemoteOnly},
|
||||
}
|
||||
for _, c := range cases {
|
||||
bucket := gjson.Get(out, "data."+c.bucket)
|
||||
if !bucket.IsArray() {
|
||||
t.Fatalf("data.%s must be an array, stdout:\n%s", c.bucket, out)
|
||||
}
|
||||
var found bool
|
||||
bucket.ForEach(func(_, entry gjson.Result) bool {
|
||||
if entry.Get("rel_path").String() != c.path {
|
||||
return true // continue
|
||||
}
|
||||
found = true
|
||||
if c.token != "" {
|
||||
if got := entry.Get("file_token").String(); got != c.token {
|
||||
t.Errorf("%s entry %q: file_token=%q want %q", c.bucket, c.path, got, c.token)
|
||||
}
|
||||
} else if entry.Get("file_token").String() != "" {
|
||||
t.Errorf("%s entry %q must not carry file_token (local-only), stdout:\n%s", c.bucket, c.path, out)
|
||||
}
|
||||
return false // stop
|
||||
})
|
||||
if !found {
|
||||
t.Errorf("%s bucket missing %q\nstdout:\n%s", c.bucket, c.path, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure each bucket is exactly the size we expect (4 files total,
|
||||
// no double-bucketing). +upload may attach extra metadata (e.g. a
|
||||
// folder type entry for `local/` itself) but the lister filters
|
||||
// type=file so the buckets should be clean.
|
||||
for _, b := range []struct {
|
||||
bucket string
|
||||
want int
|
||||
}{
|
||||
{"unchanged", 1},
|
||||
{"modified", 1},
|
||||
{"new_local", 1},
|
||||
{"new_remote", 1},
|
||||
} {
|
||||
got := int(gjson.Get(out, "data."+b.bucket+".#").Int())
|
||||
if got != b.want {
|
||||
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
|
||||
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
|
||||
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
|
||||
if sharedCount != 2 {
|
||||
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
|
||||
}
|
||||
for _, path := range []string{"unchanged.txt", "modified.txt"} {
|
||||
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
|
||||
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
|
||||
// semantics on the live backend instead of silently behaving like the default
|
||||
// exact hash mode.
|
||||
//
|
||||
// The fixture intentionally makes the two shared files diverge in opposite ways:
|
||||
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
|
||||
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
|
||||
//
|
||||
// This locks in the best-effort nature of quick mode with real Drive
|
||||
// modified_time values fetched from the list API, plus the expected new_local /
|
||||
// new_remote buckets.
|
||||
func TestDrive_StatusQuickWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
|
||||
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)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
|
||||
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
|
||||
|
||||
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
|
||||
sameMtimeRemote := remoteFiles["same-mtime.txt"]
|
||||
remoteNewer := remoteFiles["remote-newer.txt"]
|
||||
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
|
||||
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
|
||||
}
|
||||
|
||||
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
|
||||
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
|
||||
writeLocal("local/local-only.txt", "local only") // local-only bucket
|
||||
|
||||
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
|
||||
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
|
||||
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
|
||||
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
|
||||
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
|
||||
t.Fatalf("chtimes same-mtime.txt: %v", err)
|
||||
}
|
||||
localOlder := remoteNewerAt.Add(-2 * time.Second)
|
||||
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
|
||||
t.Fatalf("chtimes remote-newer.txt: %v", err)
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, quickOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, quickOut, "modified", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_remote", 1)
|
||||
|
||||
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, exactResult)
|
||||
exactResult.AssertExitCode(t, 0)
|
||||
exactResult.AssertStdoutStatus(t, true)
|
||||
|
||||
exactOut := exactResult.Stdout
|
||||
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
|
||||
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, exactOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, exactOut, "modified", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_remote", 1)
|
||||
}
|
||||
|
||||
type driveStatusListedFile struct {
|
||||
Token string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
|
||||
t.Helper()
|
||||
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "list", "--params", params},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
files := make(map[string]driveStatusListedFile)
|
||||
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
|
||||
name := entry.Get("name").String()
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
files[name] = driveStatusListedFile{
|
||||
Token: entry.Get("token").String(),
|
||||
ModifiedTime: entry.Get("modified_time").String(),
|
||||
}
|
||||
return true
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Drive epoch %q: %v", raw, err)
|
||||
}
|
||||
switch {
|
||||
case v > 1e14 || v < -1e14:
|
||||
return time.UnixMicro(v)
|
||||
case v > 1e11 || v < -1e11:
|
||||
return time.UnixMilli(v)
|
||||
default:
|
||||
return time.Unix(v, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
|
||||
t.Helper()
|
||||
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
|
||||
if !entry.Exists() {
|
||||
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
|
||||
}
|
||||
if fileToken == "" {
|
||||
if got := entry.Get("file_token").String(); got != "" {
|
||||
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := entry.Get("file_token").String(); got != fileToken {
|
||||
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
|
||||
t.Helper()
|
||||
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
|
||||
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func skipDriveStatusExactIfMissingDownloadScope(t *testing.T, result *clie2e.Result) {
|
||||
t.Helper()
|
||||
if result == nil || result.ExitCode == 0 {
|
||||
return
|
||||
}
|
||||
combinedLower := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combinedLower, "missing_scope") && strings.Contains(combinedLower, "drive:file:download") {
|
||||
t.Skipf("skip drive +status exact live workflow due to missing drive:file:download scope: stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_SyncDryRun locks in the request shape the +sync shortcut emits
|
||||
// under --dry-run: the real CLI binary is invoked end-to-end, so flag
|
||||
// parsing, Validate (still runs in dry-run mode), and the dry-run renderer
|
||||
// all execute. The printed envelope is then inspected for GET method,
|
||||
// list-files URL, the folder_token parameter, and key phrases from Desc.
|
||||
//
|
||||
// Fake credentials are sufficient because --dry-run short-circuits before
|
||||
// any real network call.
|
||||
func TestDrive_SyncDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "diff") {
|
||||
t.Fatalf("description missing diff phrase, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SyncDryRunRejectsAbsoluteLocalDir confirms the path validator
|
||||
// runs in the real binary's Validate stage and surfaces a structured error
|
||||
// referencing --local-dir.
|
||||
func TestDrive_SyncDryRunRejectsAbsoluteLocalDir(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "/etc",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: t.TempDir(),
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("absolute --local-dir must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "--local-dir") {
|
||||
t.Fatalf("expected --local-dir in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SyncDryRunRejectsMissingFolderToken confirms cobra's
|
||||
// required-flag enforcement runs before our custom Validate.
|
||||
func TestDrive_SyncDryRunRejectsMissingFolderToken(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
if result.ExitCode == 0 {
|
||||
t.Fatalf("missing --folder-token must be rejected, got exit=0\nstdout:\n%s", result.Stdout)
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
if !strings.Contains(combined, "folder-token") {
|
||||
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SyncDryRunAcceptsConflictStrategies verifies that all valid
|
||||
// --on-conflict values pass Validate and produce a well-formed dry-run
|
||||
// envelope.
|
||||
func TestDrive_SyncDryRunAcceptsConflictStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"remote-wins", "local-wins", "keep-both", "ask"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-conflict", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies verifies that all
|
||||
// valid --on-duplicate-remote values pass Validate.
|
||||
func TestDrive_SyncDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
for _, strategy := range []string{"fail", "newest", "oldest"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--on-duplicate-remote", strategy,
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SyncDryRunAcceptsQuickFlag verifies that --quick passes Validate
|
||||
// and produces a well-formed dry-run envelope.
|
||||
func TestDrive_SyncDryRunAcceptsQuickFlag(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--quick",
|
||||
"--dry-run",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
|
||||
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
|
||||
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestDrive_SyncWorkflow exercises +sync against a real Drive folder, proving
|
||||
// that new_remote files are pulled, new_local files are pushed, and conflicts
|
||||
// are resolved according to --on-conflict.
|
||||
//
|
||||
// Layout (before sync):
|
||||
//
|
||||
// folder/ (--folder-token target)
|
||||
// ├── remote-only.txt "remote" ↔ (none) → new_remote → pull
|
||||
// ├── conflict.txt "remote" ↔ local: "local" → modified → resolve
|
||||
// └── unchanged.txt "match" ↔ local: "match" → unchanged → skip
|
||||
// local/ (--local-dir target)
|
||||
// ├── local-only.txt "local" → new_local → push
|
||||
// ├── conflict.txt "local" → modified → resolve
|
||||
// └── unchanged.txt "match" → unchanged → skip
|
||||
func TestDrive_SyncWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-sync-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
|
||||
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)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
// --- Subtest: remote-wins (default) ---
|
||||
t.Run("remote-wins pulls new_remote and overwrites on conflict", func(t *testing.T) {
|
||||
tokUnchanged := uploadDriveFile("unchanged.txt", "match")
|
||||
tokConflict := uploadDriveFile("conflict.txt", "remote")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote")
|
||||
|
||||
writeLocal("local/unchanged.txt", "match")
|
||||
writeLocal("local/conflict.txt", "local")
|
||||
writeLocal("local/local-only.txt", "local")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-conflict", "remote-wins",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// Summary checks.
|
||||
if got := gjson.Get(out, "data.summary.pulled").Int(); got != 2 {
|
||||
t.Fatalf("pulled=%d want 2 (remote-only + conflict resolved by pull)\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.summary.pushed").Int(); got < 1 {
|
||||
t.Fatalf("pushed=%d want >=1 (local-only)\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.summary.failed").Int(); got != 0 {
|
||||
t.Fatalf("failed=%d want 0\nstdout:\n%s", got, out)
|
||||
}
|
||||
|
||||
// Item-level checks.
|
||||
assertSyncItem(t, out, "downloaded", "pull", "remote-only.txt", tokRemoteOnly)
|
||||
assertSyncItem(t, out, "downloaded", "pull", "conflict.txt", tokConflict)
|
||||
assertSyncItem(t, out, "uploaded", "push", "local-only.txt", "")
|
||||
|
||||
// Verify local file content after sync.
|
||||
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read conflict.txt: %v", err)
|
||||
}
|
||||
if string(conflictContent) != "remote" {
|
||||
t.Fatalf("conflict.txt content=%q want %q", string(conflictContent), "remote")
|
||||
}
|
||||
require.FileExists(t, filepath.Join(workDir, "local", "remote-only.txt"))
|
||||
|
||||
// Convergence: +status should now show all files as unchanged.
|
||||
assertSyncConverges(t, ctx, workDir, folderToken, tokUnchanged)
|
||||
})
|
||||
|
||||
// --- Subtest: local-wins ---
|
||||
t.Run("local-wins pushes new_local and overwrites remote on conflict", func(t *testing.T) {
|
||||
tokConflict := uploadDriveFile("conflict-lw.txt", "remote")
|
||||
_ = uploadDriveFile("remote-only-lw.txt", "remote")
|
||||
|
||||
writeLocal("local/conflict-lw.txt", "local-wins")
|
||||
writeLocal("local/local-only-lw.txt", "local")
|
||||
writeLocal("local/remote-only-lw.txt", "already-here")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-conflict", "local-wins",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// Conflict file should be overwritten with local version.
|
||||
assertSyncItem(t, out, "overwritten", "push", "conflict-lw.txt", tokConflict)
|
||||
|
||||
// Verify local content is unchanged (local-wins).
|
||||
conflictContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-lw.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read conflict-lw.txt: %v", err)
|
||||
}
|
||||
if string(conflictContent) != "local-wins" {
|
||||
t.Fatalf("conflict-lw.txt content=%q want %q", string(conflictContent), "local-wins")
|
||||
}
|
||||
})
|
||||
|
||||
// --- Subtest: keep-both ---
|
||||
t.Run("keep-both renames local and pulls remote", func(t *testing.T) {
|
||||
uploadDriveFile("conflict-kb.txt", "remote-kb")
|
||||
|
||||
writeLocal("local/conflict-kb.txt", "local-kb")
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--on-conflict", "keep-both",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
out := result.Stdout
|
||||
|
||||
// Should have a renamed_local item and a downloaded item.
|
||||
assertSyncItem(t, out, "renamed_local", "conflict", "conflict-kb.txt", "")
|
||||
assertSyncItem(t, out, "downloaded", "pull", "conflict-kb.txt", "")
|
||||
|
||||
// Original path now has remote content.
|
||||
origContent, err := os.ReadFile(filepath.Join(workDir, "local", "conflict-kb.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read conflict-kb.txt: %v", err)
|
||||
}
|
||||
if string(origContent) != "remote-kb" {
|
||||
t.Fatalf("conflict-kb.txt content=%q want %q", string(origContent), "remote-kb")
|
||||
}
|
||||
|
||||
// A suffixed sibling should exist with the local content.
|
||||
entries, err := os.ReadDir(filepath.Join(workDir, "local"))
|
||||
if err != nil {
|
||||
t.Fatalf("readdir local: %v", err)
|
||||
}
|
||||
var foundSuffixed bool
|
||||
for _, e := range entries {
|
||||
if strings.HasPrefix(e.Name(), "conflict-kb__lark_") && strings.HasSuffix(e.Name(), ".txt") {
|
||||
foundSuffixed = true
|
||||
suffixedContent, readErr := os.ReadFile(filepath.Join(workDir, "local", e.Name()))
|
||||
if readErr != nil {
|
||||
t.Fatalf("read suffixed file: %v", readErr)
|
||||
}
|
||||
if string(suffixedContent) != "local-kb" {
|
||||
t.Fatalf("suffixed file content=%q want %q", string(suffixedContent), "local-kb")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundSuffixed {
|
||||
t.Fatalf("expected suffixed sibling conflict-kb__lark_*.txt, entries: %v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDrive_SyncEmptyDirWorkflow proves that empty local directories are
|
||||
// created on Drive during +sync, and that a subsequent +status converges.
|
||||
func TestDrive_SyncEmptyDirWorkflow(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-sync-emptydir-"+suffix, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
// Create an empty subdirectory under local.
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local", "empty_sub"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local/empty_sub: %v", err)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+sync",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
out := result.Stdout
|
||||
// Should report folder_created for the empty directory.
|
||||
if !strings.Contains(out, `"action": "folder_created"`) {
|
||||
t.Fatalf("expected folder_created action for empty directory, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"empty_sub"`) {
|
||||
t.Fatalf("expected empty_sub in items, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSyncItem checks that a sync item with the given action, direction,
|
||||
// and rel_path exists in the output. If fileToken is non-empty, it also
|
||||
// verifies the item carries that token.
|
||||
func assertSyncItem(t *testing.T, stdout, action, direction, relPath, fileToken string) {
|
||||
t.Helper()
|
||||
items := gjson.Get(stdout, "data.items")
|
||||
if !items.IsArray() {
|
||||
t.Fatalf("data.items is not an array\nstdout:\n%s", stdout)
|
||||
}
|
||||
var found bool
|
||||
items.ForEach(func(_, item gjson.Result) bool {
|
||||
if item.Get("action").String() != action || item.Get("direction").String() != direction || item.Get("rel_path").String() != relPath {
|
||||
return true
|
||||
}
|
||||
found = true
|
||||
if fileToken != "" {
|
||||
if got := item.Get("file_token").String(); got != fileToken {
|
||||
t.Errorf("item %s/%s/%s file_token=%q want %q", action, direction, relPath, got, fileToken)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if !found {
|
||||
t.Fatalf("missing sync item action=%s direction=%s rel_path=%s\nstdout:\n%s", action, direction, relPath, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// assertSyncConverges runs +status after a sync and asserts that all shared
|
||||
// files are unchanged (i.e. the mirror has converged).
|
||||
func assertSyncConverges(t *testing.T, ctx context.Context, workDir, folderToken, unchangedToken string) {
|
||||
t.Helper()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
skipDriveStatusExactIfMissingDownloadScope(t, result)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
out := result.Stdout
|
||||
if got := gjson.Get(out, "data.modified.#").Int(); got != 0 {
|
||||
t.Fatalf("post-sync +status modified=%d want 0\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.new_local.#").Int(); got != 0 {
|
||||
t.Fatalf("post-sync +status new_local=%d want 0\nstdout:\n%s", got, out)
|
||||
}
|
||||
if got := gjson.Get(out, "data.new_remote.#").Int(); got != 0 {
|
||||
t.Fatalf("post-sync +status new_remote=%d want 0\nstdout:\n%s", got, out)
|
||||
}
|
||||
if unchangedToken != "" {
|
||||
assertStatusBucketEntry(t, out, "unchanged", "unchanged.txt", unchangedToken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDriveUploadDryRun_WikiTarget(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", "+upload",
|
||||
"--file", "./report.pdf",
|
||||
"--wiki-token", "wikcnDryRunUploadTarget",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
|
||||
assert.Contains(t, output, `"with_url": true`)
|
||||
assert.Contains(t, output, "parent_type")
|
||||
assert.Contains(t, output, "parent_node")
|
||||
assert.Contains(t, output, "wikcnDryRunUploadTarget")
|
||||
assert.Contains(t, output, `"parent_type": "wiki"`)
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRun_WithFileToken(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", "+upload",
|
||||
"--file", "./report.pdf",
|
||||
"--folder-token", "fldDryRunUploadTarget",
|
||||
"--file-token", "boxcnDryRunOverwriteTarget",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all")
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/metas/batch_query")
|
||||
assert.Contains(t, output, `"with_url": true`)
|
||||
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
|
||||
assert.Contains(t, output, `"file_token": "boxcnDryRunOverwriteTarget"`)
|
||||
}
|
||||
|
||||
func TestDriveUploadDryRunRejectsEmptyWikiToken(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", "+upload",
|
||||
"--file", "./report.pdf",
|
||||
"--wiki-token", "",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
assert.Contains(t, result.Stderr, "--wiki-token cannot be empty")
|
||||
}
|
||||
|
||||
func setDriveDryRunConfigEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "drive_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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_UploadWorkflow(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-upload-"+suffix, "")
|
||||
workDir := t.TempDir()
|
||||
|
||||
cleanupTokens := map[string]struct{}{}
|
||||
scheduleDelete := func(fileToken string) {
|
||||
t.Helper()
|
||||
if fileToken == "" {
|
||||
return
|
||||
}
|
||||
if _, seen := cleanupTokens[fileToken]; seen {
|
||||
return
|
||||
}
|
||||
cleanupTokens[fileToken] = struct{}{}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
uploadFile := func(stageName, remoteName, content, fileToken string) string {
|
||||
t.Helper()
|
||||
stagePath := filepath.Join(workDir, stageName)
|
||||
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write stage file %s: %v", stageName, err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"drive", "+upload",
|
||||
"--file", stageName,
|
||||
"--folder-token", folderToken,
|
||||
"--name", remoteName,
|
||||
}
|
||||
if fileToken != "" {
|
||||
args = append(args, "--file-token", fileToken)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
|
||||
if got := gjson.Get(result.Stdout, "data.file_name").String(); got != remoteName {
|
||||
t.Fatalf("data.file_name=%q want %q\nstdout:\n%s", got, remoteName, result.Stdout)
|
||||
}
|
||||
if got := gjson.Get(result.Stdout, "data.size").Int(); got != int64(len(content)) {
|
||||
t.Fatalf("data.size=%d want %d\nstdout:\n%s", got, len(content), result.Stdout)
|
||||
}
|
||||
return gotToken
|
||||
}
|
||||
|
||||
initialContent := "drive upload e2e: initial content\n"
|
||||
initialToken := uploadFile("_upload_initial.txt", "overwrite.txt", initialContent, "")
|
||||
scheduleDelete(initialToken)
|
||||
|
||||
updatedContent := "drive upload e2e: overwritten via file-token\n"
|
||||
overwriteToken := uploadFile("_upload_overwrite.txt", "overwrite.txt", updatedContent, initialToken)
|
||||
scheduleDelete(overwriteToken)
|
||||
|
||||
if overwriteToken != initialToken {
|
||||
t.Fatalf("overwrite token=%q want original token=%q", overwriteToken, initialToken)
|
||||
}
|
||||
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+download",
|
||||
"--file-token", overwriteToken,
|
||||
"--output", "downloaded.txt",
|
||||
"--overwrite",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(workDir, "downloaded.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != updatedContent {
|
||||
t.Fatalf("downloaded content=%q want %q", string(data), updatedContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDriveVersionHistoryDryRun(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", "+version-history",
|
||||
"--file-token", "boxcnHistoryDryRun",
|
||||
"--limit", "5",
|
||||
"--cursor", "1777013761763",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnHistoryDryRun/history")
|
||||
assert.Contains(t, output, `"only_tag": true`)
|
||||
assert.Contains(t, output, `"page_size": 5`)
|
||||
assert.Contains(t, output, `"last_edit_time": "1777013761763"`)
|
||||
}
|
||||
|
||||
func TestDriveVersionGetDryRun(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", "+version-get",
|
||||
"--file-token", "boxcnVersionDryRun",
|
||||
"--version", "7633658129540910621",
|
||||
"--output", "./artifact.bin",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
|
||||
assert.Contains(t, output, `"version": "7633658129540910621"`)
|
||||
assert.Contains(t, output, `"output": "./artifact.bin"`)
|
||||
}
|
||||
|
||||
func TestDriveVersionGetDryRunWithoutOutputUsesCurrentDirectory(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", "+version-get",
|
||||
"--file-token", "boxcnVersionDryRun",
|
||||
"--version", "7633658129540910621",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download")
|
||||
assert.Contains(t, output, `"version": "7633658129540910621"`)
|
||||
assert.Contains(t, output, `"output": "."`)
|
||||
}
|
||||
|
||||
func TestDriveVersionRevertDryRun(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", "+version-revert",
|
||||
"--file-token", "boxcnVersionDryRun",
|
||||
"--version", "7633658129540910621",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/revert")
|
||||
assert.Contains(t, output, `"version": "7633658129540910621"`)
|
||||
}
|
||||
|
||||
func TestDriveVersionDeleteDryRun(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", "+version-delete",
|
||||
"--file-token", "boxcnVersionDryRun",
|
||||
"--version", "7633658129540910621",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/version_del")
|
||||
assert.Contains(t, output, `"version": "7633658129540910621"`)
|
||||
}
|
||||
|
||||
func TestDriveVersionDryRunSupportsUser(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
setDriveDryRunConfigEnv(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: "history",
|
||||
args: []string{
|
||||
"drive", "+version-history",
|
||||
"--file-token", "boxcnHistoryDryRunUser",
|
||||
"--limit", "5",
|
||||
"--cursor", "1777013761763",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{
|
||||
"/open-apis/drive/v1/files/boxcnHistoryDryRunUser/history",
|
||||
`"only_tag": true`,
|
||||
`"page_size": 5`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get",
|
||||
args: []string{
|
||||
"drive", "+version-get",
|
||||
"--file-token", "boxcnVersionDryRunUser",
|
||||
"--version", "7633658129540910621",
|
||||
"--output", "./artifact-user.bin",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{
|
||||
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/download",
|
||||
`"version": "7633658129540910621"`,
|
||||
`"output": "./artifact-user.bin"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revert",
|
||||
args: []string{
|
||||
"drive", "+version-revert",
|
||||
"--file-token", "boxcnVersionDryRunUser",
|
||||
"--version", "7633658129540910621",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{
|
||||
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/revert",
|
||||
`"version": "7633658129540910621"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete",
|
||||
args: []string{
|
||||
"drive", "+version-delete",
|
||||
"--file-token", "boxcnVersionDryRunUser",
|
||||
"--version", "7633658129540910621",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{
|
||||
"/open-apis/drive/v1/files/boxcnVersionDryRunUser/version_del",
|
||||
`"version": "7633658129540910621"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: tt.args,
|
||||
DefaultAs: "user",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := strings.TrimSpace(result.Stdout)
|
||||
for _, needle := range tt.wantContains {
|
||||
assert.Contains(t, output, needle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// 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 TestDriveVersionWorkflow(t *testing.T) {
|
||||
if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" {
|
||||
t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow")
|
||||
}
|
||||
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
fileName := "lark-cli-version-workflow-" + suffix + ".md"
|
||||
|
||||
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"markdown", "+create",
|
||||
"--name", fileName,
|
||||
"--content", "# v1\n",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
createResult.AssertExitCode(t, 0)
|
||||
createResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fileToken := gjson.Get(createResult.Stdout, "data.file_token").String()
|
||||
require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+delete",
|
||||
"--file-token", fileToken,
|
||||
"--type", "file",
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete version workflow file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"markdown", "+overwrite",
|
||||
"--file-token", fileToken,
|
||||
"--content", "# v2\n",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
overwriteResult.AssertExitCode(t, 0)
|
||||
overwriteResult.AssertStdoutStatus(t, true)
|
||||
|
||||
overwriteResult, err = clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"markdown", "+overwrite",
|
||||
"--file-token", fileToken,
|
||||
"--content", "# v3\n",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
overwriteResult.AssertExitCode(t, 0)
|
||||
overwriteResult.AssertStdoutStatus(t, true)
|
||||
|
||||
historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-history",
|
||||
"--file-token", fileToken,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
historyResult.AssertExitCode(t, 0)
|
||||
historyResult.AssertStdoutStatus(t, true)
|
||||
|
||||
versions := gjson.Get(historyResult.Stdout, "data.versions").Array()
|
||||
require.GreaterOrEqual(t, len(versions), 3, "stdout:\n%s", historyResult.Stdout)
|
||||
|
||||
var (
|
||||
versionToDownload string
|
||||
versionV1 string
|
||||
versionV2 string
|
||||
)
|
||||
for _, version := range versions {
|
||||
versionID := version.Get("version").String()
|
||||
if versionID == "" {
|
||||
continue
|
||||
}
|
||||
downloadDir := t.TempDir()
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-get",
|
||||
"--file-token", fileToken,
|
||||
"--version", versionID,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
WorkDir: downloadDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
downloadedPath := filepath.Join(downloadDir, fileName)
|
||||
body, err := os.ReadFile(downloadedPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
switch string(body) {
|
||||
case "# v1\n":
|
||||
versionV1 = versionID
|
||||
case "# v2\n":
|
||||
versionV2 = versionID
|
||||
}
|
||||
if versionToDownload == "" {
|
||||
versionToDownload = versionID
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, versionToDownload, "stdout:\n%s", historyResult.Stdout)
|
||||
require.NotEmpty(t, versionV1, "stdout:\n%s", historyResult.Stdout)
|
||||
require.NotEmpty(t, versionV2, "stdout:\n%s", historyResult.Stdout)
|
||||
|
||||
downloadDir := t.TempDir()
|
||||
downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-get",
|
||||
"--file-token", fileToken,
|
||||
"--version", versionToDownload,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
WorkDir: downloadDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
downloadResult.AssertExitCode(t, 0)
|
||||
downloadResult.AssertStdoutStatus(t, true)
|
||||
|
||||
downloadedPath := filepath.Join(downloadDir, fileName)
|
||||
if _, err := os.Stat(downloadedPath); err != nil {
|
||||
t.Fatalf("expected downloaded version at %q: %v", downloadedPath, err)
|
||||
}
|
||||
|
||||
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-revert",
|
||||
"--file-token", fileToken,
|
||||
"--version", versionV1,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
revertResult.AssertExitCode(t, 0)
|
||||
revertResult.AssertStdoutStatus(t, true)
|
||||
|
||||
fetchResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"markdown", "+fetch",
|
||||
"--file-token", fileToken,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
fetchResult.AssertExitCode(t, 0)
|
||||
fetchResult.AssertStdoutStatus(t, true)
|
||||
if got := gjson.Get(fetchResult.Stdout, "data.content").String(); got != "# v1\n" {
|
||||
t.Fatalf("markdown content after revert = %q, want %q\nstdout:\n%s", got, "# v1\n", fetchResult.Stdout)
|
||||
}
|
||||
|
||||
deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-delete",
|
||||
"--file-token", fileToken,
|
||||
"--version", versionV2,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
Yes: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
deleteResult.AssertExitCode(t, 0)
|
||||
deleteResult.AssertStdoutStatus(t, true)
|
||||
|
||||
historyAfterDelete, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+version-history",
|
||||
"--file-token", fileToken,
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
BinaryPath: "../../../lark-cli",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
historyAfterDelete.AssertExitCode(t, 0)
|
||||
historyAfterDelete.AssertStdoutStatus(t, true)
|
||||
|
||||
foundDeletedVersion := false
|
||||
for _, version := range gjson.Get(historyAfterDelete.Stdout, "data.versions").Array() {
|
||||
if version.Get("version").String() != versionV2 {
|
||||
continue
|
||||
}
|
||||
foundDeletedVersion = true
|
||||
if !version.Get("is_deleted").Bool() {
|
||||
t.Fatalf("version %s should be marked deleted after +version-delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
|
||||
}
|
||||
}
|
||||
if !foundDeletedVersion {
|
||||
t.Fatalf("deleted version %s not found in history after delete\nstdout:\n%s", versionV2, historyAfterDelete.Stdout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
driveDeleteVisibilityTimeout = 30 * time.Second
|
||||
driveDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
var driveDeleteVisibilityWait = clie2e.WaitOptions{
|
||||
// This wait only covers the post-delete visibility lag after Drive accepts
|
||||
// deletion. The delete command itself is bounded by clie2e.CleanupContext.
|
||||
Timeout: driveDeleteVisibilityTimeout,
|
||||
Interval: driveDeleteVisibilityPoll,
|
||||
}
|
||||
|
||||
// CreateDriveFolder creates a Drive folder, optionally under a parent folder, and
|
||||
// deletes it during parent cleanup.
|
||||
func CreateDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string, parentFolderToken string) string {
|
||||
t.Helper()
|
||||
|
||||
if defaultAs == "" {
|
||||
defaultAs = "bot"
|
||||
}
|
||||
|
||||
args := []string{"drive", "+create-folder", "--name", name}
|
||||
if parentFolderToken != "" {
|
||||
args = append(args, "--folder-token", parentFolderToken)
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: args,
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create drive folder %q: %v", name, err)
|
||||
}
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
folderToken := gjson.Get(result.Stdout, "data.folder_token").String()
|
||||
if folderToken == "" {
|
||||
t.Fatalf("drive folder token should not be empty, stdout:\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, folderToken, "folder", defaultAs)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive folder "+folderToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
return folderToken
|
||||
}
|
||||
|
||||
// DeleteDriveResourceAndVerify deletes a Drive-backed resource, then polls
|
||||
// drive meta until the token is either gone or no longer has an accessible URL.
|
||||
// This prevents cleanup from looking successful when the delete command
|
||||
// returned a suppressed not_found or partial API error but the resource still
|
||||
// exists.
|
||||
func DeleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string) (*clie2e.Result, error) {
|
||||
return deleteDriveResourceAndVerify(ctx, token, docType, defaultAs, driveDeleteVisibilityWait)
|
||||
}
|
||||
|
||||
func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs string, visibilityWait clie2e.WaitOptions) (*clie2e.Result, error) {
|
||||
if defaultAs == "" {
|
||||
defaultAs = "bot"
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
if deleteErr != nil || deleteResult == nil {
|
||||
return deleteResult, deleteErr
|
||||
}
|
||||
if deleteResult.ExitCode != 0 {
|
||||
deleted, verifyErr := IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
if verifyErr != nil {
|
||||
return deleteResult, verifyErr
|
||||
}
|
||||
if deleted {
|
||||
deleteResult.ExitCode = 0
|
||||
return deleteResult, nil
|
||||
}
|
||||
return deleteResult, fmt.Errorf("drive resource %s/%s still exists after delete failed: exit=%d stdout=%s stderr=%s", docType, token, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
|
||||
}
|
||||
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
|
||||
return deleteResult, clie2e.CleanupWarning(
|
||||
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
|
||||
)
|
||||
}
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
func waitDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string, opts clie2e.WaitOptions) error {
|
||||
opts.TimeoutError = func() error {
|
||||
return fmt.Errorf("drive resource %s/%s still exists %s after delete", docType, token, opts.Timeout)
|
||||
}
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
return IsDriveResourceDeleted(ctx, token, docType, defaultAs)
|
||||
})
|
||||
}
|
||||
|
||||
func IsDriveResourceDeleted(ctx context.Context, token, docType, defaultAs string) (bool, error) {
|
||||
if defaultAs == "" {
|
||||
defaultAs = "bot"
|
||||
}
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "post", "/open-apis/drive/v1/metas/batch_query"},
|
||||
DefaultAs: defaultAs,
|
||||
Data: map[string]any{
|
||||
"request_docs": []map[string]any{{
|
||||
"doc_token": token,
|
||||
"doc_type": docType,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if result.ExitCode != 0 {
|
||||
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
if strings.Contains(combined, "not found") || strings.Contains(combined, "404") {
|
||||
return true, nil
|
||||
}
|
||||
return false, fmt.Errorf("verify drive resource %s/%s after delete: exit=%d stdout=%s stderr=%s", docType, token, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
if !isDriveMetaQuerySuccessful(result.Stdout) {
|
||||
return false, fmt.Errorf("verify drive resource %s/%s after delete returned unsuccessful envelope: stdout=%s stderr=%s", docType, token, result.Stdout, result.Stderr)
|
||||
}
|
||||
metas := gjson.Get(result.Stdout, "data.metas").Array()
|
||||
if len(metas) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
if docType != "folder" {
|
||||
allURLsCleared := true
|
||||
for _, meta := range metas {
|
||||
if meta.Get("url").String() != "" {
|
||||
allURLsCleared = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allURLsCleared {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isDriveMetaQuerySuccessful(stdout string) bool {
|
||||
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
|
||||
return ok.Bool()
|
||||
}
|
||||
if code := gjson.Get(stdout, "code"); code.Exists() {
|
||||
return code.Int() == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func createDriveFolder(t *testing.T, parentT *testing.T, ctx context.Context, name string, parentFolderToken string) string {
|
||||
t.Helper()
|
||||
folderToken := CreateDriveFolder(t, parentT, ctx, name, "bot", parentFolderToken)
|
||||
require.NotEmpty(t, folderToken)
|
||||
return folderToken
|
||||
}
|
||||
|
||||
func TestDeleteDriveResourceAndVerify(t *testing.T) {
|
||||
t.Run("successful delete with stale meta returns cleanup warning", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
|
||||
result, err := deleteDriveResourceAndVerify(context.Background(), "fld_stale", "folder", "bot", clie2e.WaitOptions{
|
||||
Timeout: 10 * time.Millisecond,
|
||||
Interval: time.Millisecond,
|
||||
})
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 0, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.True(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
})
|
||||
|
||||
t.Run("failed delete with existing meta remains fatal", func(t *testing.T) {
|
||||
fake := mustWriteDriveCleanupFakeCLI(t)
|
||||
t.Setenv(clie2e.EnvBinaryPath, fake)
|
||||
t.Setenv("FAKE_DRIVE_DELETE_EXIT", "1")
|
||||
|
||||
result, err := DeleteDriveResourceAndVerify(context.Background(), "fld_existing", "folder", "bot")
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, 1, result.ExitCode)
|
||||
require.Error(t, err)
|
||||
assert.False(t, clie2e.IsCleanupWarning(err), "err: %v", err)
|
||||
assert.Contains(t, err.Error(), "still exists after delete failed")
|
||||
})
|
||||
}
|
||||
|
||||
func mustWriteDriveCleanupFakeCLI(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
script := `#!/bin/sh
|
||||
if [ "$1" = "drive" ] && [ "$2" = "+delete" ]; then
|
||||
if [ "${FAKE_DRIVE_DELETE_EXIT:-0}" != "0" ]; then
|
||||
echo '{"ok":false,"error":{"type":"api","message":"delete failed"}}' >&2
|
||||
exit "$FAKE_DRIVE_DELETE_EXIT"
|
||||
fi
|
||||
echo '{"ok":true}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$1" = "api" ] && [ "$2" = "post" ] && [ "$3" = "/open-apis/drive/v1/metas/batch_query" ]; then
|
||||
echo '{"ok":true,"data":{"metas":[{"url":"https://example.com/still-visible"}]}}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "unexpected fake CLI args: $*" >&2
|
||||
exit 2
|
||||
`
|
||||
|
||||
binaryPath := filepath.Join(t.TempDir(), "fake-lark-cli")
|
||||
require.NoError(t, os.WriteFile(binaryPath, []byte(script), 0o755))
|
||||
return binaryPath
|
||||
}
|
||||
Reference in New Issue
Block a user