Files
larksuite-cli/tests/cli_e2e/drive/drive_sync_workflow_test.go
fangshuyu-768 df4b657737 feat(drive): add +sync workflow for Drive directories (#873)
Bidirectional sync between a local directory and a Drive folder with
diff detection (new_local, new_remote, modified, unchanged) and
conflict resolution strategies (--on-conflict: remote-wins, local-wins,
keep-both, ask).

Key behaviors:
- Type conflict detection: hard-fail when local file vs remote non-file
  or local directory vs remote file
- Keep-both: rename local with __lark_<hash> suffix, then pull remote;
  occupied map includes localDirs to prevent suffix collision
- Local-wins partial-success: prefer returned file_token on upload failure
- Empty directory mirroring: pre-create local dirs on Drive via
  drivePushWalkLocal before scope preflight
- Structured errors throughout (output.Errorf / output.ErrWithHint)

Includes unit tests and E2E tests (dry-run + live workflow).
2026-05-18 19:56:43 +08:00

347 lines
12 KiB
Go

// 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)
}
}