528 lines
22 KiB
Go
528 lines
22 KiB
Go
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
)
|
|
|
|
// pull / push / sync 声明 Safety.Confirmation = user_required。本文件断言两件事:
|
|
// 1. 未确认(无 --yes、非 dry-run)时命令必须在任何写操作之前拒绝执行;
|
|
// 2. 明确确认(--yes)后才发出精确的工具调用并落盘。
|
|
// status 是只读叶子(not_required),不受确认门约束。
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// 1. 未确认即拒绝,且一个写操作都不发生
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
func TestCrossPlatformCoverageDriveSyncFamily_refusesWithoutConfirmation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
setup func(t *testing.T, dir string)
|
|
listJSON string
|
|
}{
|
|
{
|
|
name: "pull",
|
|
args: []string{"pull"},
|
|
// 远端有文件、本地为空:即便是纯新增也必须先确认。
|
|
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
|
},
|
|
{
|
|
name: "push",
|
|
args: []string{"push"},
|
|
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
|
|
listJSON: `{"result":{"items":[],"nextToken":""}}`,
|
|
},
|
|
{
|
|
name: "sync",
|
|
args: []string{"sync", "--quick", "--on-conflict", "remote-wins"},
|
|
setup: func(t *testing.T, dir string) { mustWrite(t, filepath.Join(dir, "a.txt"), "local") },
|
|
listJSON: `{"result":{"items":[{"name":"b.txt","type":"file","fileId":"B","modifyTime":2}],"nextToken":""}}`,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if tc.setup != nil {
|
|
tc.setup(t, dir)
|
|
}
|
|
|
|
caller := &driveScriptCaller{reply: func(tool string, _ map[string]any, nth int) (string, error) {
|
|
switch tool {
|
|
case "list_files":
|
|
if nth > 0 {
|
|
return `{"result":{"items":[],"nextToken":""}}`, nil
|
|
}
|
|
return tc.listJSON, nil
|
|
case "download_file":
|
|
return `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`, nil
|
|
case "get_upload_info":
|
|
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
|
}
|
|
return `{"result":{"fileId":"NEW"},"success":true}`, nil
|
|
}}
|
|
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
|
t.Error("no download may happen before confirmation")
|
|
return os.WriteFile(dest, []byte("leaked"), 0o644)
|
|
})
|
|
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
|
t.Error("no upload may happen before confirmation")
|
|
return nil
|
|
})
|
|
|
|
args := append(append([]string{}, tc.args...), "--local-folder", dir, "--remote-folder", "ROOT")
|
|
err := runDriveCmdWithoutConfirm(t, caller, args...)
|
|
if err == nil {
|
|
t.Fatal("expected the command to refuse without --yes")
|
|
}
|
|
if !strings.Contains(err.Error(), "--yes") {
|
|
t.Errorf("error must tell the user to confirm with --yes, got %q", err.Error())
|
|
}
|
|
// 拒绝必须发生在任何写工具调用之前。
|
|
for _, tool := range []string{"download_file", "get_upload_info", "create_folder", "ln"} {
|
|
if got := caller.callsFor(tool); len(got) != 0 {
|
|
t.Errorf("%s must not be called before confirmation, got %v", tool, got)
|
|
}
|
|
}
|
|
// 本地目录不得新增任何文件。
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if e.Name() == "b.txt" || e.Name() == "a.txt" && tc.name == "pull" {
|
|
t.Errorf("%s must not be materialized before confirmation", e.Name())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --dry-run 无需 --yes 也能预览:这是让用户看清将发生什么的必要出口,且不写任何东西。
|
|
func TestCrossPlatformCoverageDriveSyncFamily_dryRunNeedsNoConfirmation(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "local.txt"), "l")
|
|
|
|
caller := syncCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}],"nextToken":""}}`,
|
|
})
|
|
caller.dryRun = true
|
|
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
|
t.Error("dry-run must not download")
|
|
return nil
|
|
})
|
|
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
|
t.Error("dry-run must not upload")
|
|
return nil
|
|
})
|
|
|
|
if err := runDriveCmdWithoutConfirm(t, caller, "sync",
|
|
"--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
|
t.Fatalf("dry-run must not require confirmation: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) {
|
|
t.Error("dry-run must not write remote files locally")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDrivePull_dryRunPlansWithoutLocalWrites(t *testing.T) {
|
|
parent := t.TempDir()
|
|
target := filepath.Join(parent, "not-created")
|
|
caller := pullListingCaller(`{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2}`)
|
|
caller.dryRun = true
|
|
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
|
t.Error("dry-run must not download")
|
|
return nil
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
|
testseam.Swap(t, &os.Args, []string{"dws", "drive", "pull"})
|
|
cmd := findDriveSubcommand(t, "pull")
|
|
mustSetFlags(t, cmd, map[string]string{"local-folder": target, "remote-folder": "ROOT"})
|
|
if err := cmd.RunE(cmd, nil); err != nil {
|
|
t.Fatalf("dry-run pull: %v", err)
|
|
}
|
|
if _, err := os.Stat(target); !os.IsNotExist(err) {
|
|
t.Fatalf("dry-run must not create local root: %v", err)
|
|
}
|
|
if got := len(caller.callsFor("download_file")); got != 0 {
|
|
t.Fatalf("download_file calls = %d, want 0", got)
|
|
}
|
|
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"remote.txt"`) {
|
|
t.Fatalf("missing pull plan: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mustWrite(t, filepath.Join(dir, "sub", "local.txt"), "local")
|
|
caller := syncCaller(nil)
|
|
caller.dryRun = true
|
|
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
|
|
t.Error("dry-run must not upload")
|
|
return nil
|
|
})
|
|
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
|
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
|
cmd := findDriveSubcommand(t, "push")
|
|
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
|
if err := cmd.RunE(cmd, nil); err != nil {
|
|
t.Fatalf("dry-run push: %v", err)
|
|
}
|
|
for _, tool := range []string{"create_folder", "get_upload_info", "commit_upload"} {
|
|
if got := len(caller.callsFor(tool)); got != 0 {
|
|
t.Fatalf("%s calls = %d, want 0", tool, got)
|
|
}
|
|
}
|
|
if text := out.String(); !strings.Contains(text, `"dry_run": true`) || !strings.Contains(text, `"sub/local.txt"`) {
|
|
t.Fatalf("missing push plan: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
|
caller := syncCaller(nil)
|
|
caller.dryRun = true
|
|
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
|
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
|
cmd := findDriveSubcommand(t, "push")
|
|
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
|
if err := cmd.RunE(cmd, nil); err != nil {
|
|
t.Fatalf("dry-run push: %v", err)
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
|
|
}
|
|
plan := payload["plan"].(map[string]any)
|
|
summary := plan["summary"].(map[string]any)
|
|
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
|
|
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
|
|
}
|
|
items := plan["items"].([]any)
|
|
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
|
|
t.Fatalf("dry-run action = %v, want planned_upload", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
|
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
|
|
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
caller := syncCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
|
|
})
|
|
caller.dryRun = true
|
|
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
|
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
|
cmd := findDriveSubcommand(t, "sync")
|
|
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
|
if err := cmd.RunE(cmd, nil); err != nil {
|
|
t.Fatalf("dry-run sync: %v", err)
|
|
}
|
|
|
|
var payload driveSyncDryRunResult
|
|
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
|
|
}
|
|
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
|
|
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
|
|
}
|
|
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
|
|
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
|
|
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
|
|
}
|
|
actions := map[string]int{}
|
|
for _, item := range payload.Plan.Items {
|
|
actions[item.Action]++
|
|
}
|
|
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
|
|
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
|
|
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
|
|
}
|
|
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
|
|
if calls := caller.callsFor(tool); len(calls) != 0 {
|
|
t.Fatalf("dry-run called %s: %#v", tool, calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
|
|
remoteFiles := map[string]*remoteFile{
|
|
"conflict.txt": {FileID: "remote-file"},
|
|
}
|
|
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
|
|
localDirs := []string{"existing", "new", "missing/child"}
|
|
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
|
|
|
|
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
|
|
t.Run(policy, func(t *testing.T) {
|
|
res := &driveSyncResult{}
|
|
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
|
|
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
|
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
|
|
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
|
|
})
|
|
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
|
|
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
|
|
}
|
|
switch policy {
|
|
case syncConflictRemoteWins, syncConflictKeepBoth:
|
|
if res.Summary.PlannedPulls < 2 {
|
|
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
|
|
}
|
|
case syncConflictLocalWins:
|
|
if res.Summary.PlannedPushes < 2 {
|
|
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
|
root := t.TempDir()
|
|
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
|
mustWrite(t, filepath.Join(root, "smart.txt"), "smart")
|
|
remote := map[string]*remoteFile{
|
|
"A.txt": {RelPath: "A.txt"},
|
|
"a.txt": {RelPath: "a.txt"},
|
|
"../escape": {RelPath: "../escape"},
|
|
"skip.txt": {RelPath: "skip.txt"},
|
|
"smart.txt": {RelPath: "smart.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
|
"new.txt": {RelPath: "new.txt"},
|
|
}
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
|
|
|
|
if err := printDrivePullDryRun(root, ifExistsSkip, remote,
|
|
[]string{"A.txt", "a.txt", "../escape", "skip.txt", "new.txt"}, true); err != nil {
|
|
t.Fatalf("skip plan: %v", err)
|
|
}
|
|
if text := out.String(); !strings.Contains(text, pullActionFailed) || !strings.Contains(text, pullActionSkipped) || !strings.Contains(text, pullActionDownloaded) {
|
|
t.Fatalf("skip plan did not cover failed/skipped/downloaded: %s", text)
|
|
}
|
|
out.Reset()
|
|
if err := printDrivePullDryRun(root, ifExistsSmart, remote, []string{"smart.txt"}, false); err != nil {
|
|
t.Fatalf("smart plan: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), pullActionSkipped) {
|
|
t.Fatalf("smart plan must skip newer local file: %s", out.String())
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
|
var out bytes.Buffer
|
|
testseam.Swap(t, &deps, &Deps{Out: &Formatter{w: &out}})
|
|
remoteFolders := map[string]string{"": "ROOT", "existing": "EXISTING"}
|
|
remoteFiles := map[string]*remoteFile{
|
|
"skip.txt": {RelPath: "skip.txt"},
|
|
"smart-skip.txt": {RelPath: "smart-skip.txt", ModifiedTime: 20, ModifiedTimeValid: true},
|
|
"smart-overwrite.txt": {RelPath: "smart-overwrite.txt", ModifiedTime: 1, ModifiedTimeValid: true},
|
|
"overwrite.txt": {RelPath: "overwrite.txt"},
|
|
}
|
|
files := func(paths ...string) []localPushFile {
|
|
result := make([]localPushFile, 0, len(paths))
|
|
for _, path := range paths {
|
|
result = append(result, localPushFile{RelPath: path, Size: 1, ModTimeMillis: 10})
|
|
}
|
|
return result
|
|
}
|
|
|
|
if err := printDrivePushDryRun(ifExistsSkip, remoteFiles, remoteFolders,
|
|
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
|
t.Fatalf("skip plan: %v", err)
|
|
}
|
|
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
|
|
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
|
}
|
|
out.Reset()
|
|
if err := printDrivePushDryRun(ifExistsSmart, remoteFiles, remoteFolders, nil,
|
|
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
|
t.Fatalf("smart plan: %v", err)
|
|
}
|
|
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
|
|
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
|
}
|
|
out.Reset()
|
|
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
|
t.Fatalf("overwrite plan: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
|
|
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// 2. 明确确认后才发出精确的工具调用
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// sync --on-conflict remote-wins --yes:确认后只下载指定远端文件,且不上传。
|
|
func TestCrossPlatformCoverageDriveSync_confirmedRemoteWinsIssuesExactCalls(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "f.txt")
|
|
mustWrite(t, p, "local-version")
|
|
local := readFileMTimeMillis(t, p)
|
|
withSyncTransport(t, "remote-version")
|
|
|
|
caller := syncCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
|
differentMillis(local) + `}],"nextToken":""}}`,
|
|
})
|
|
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
|
|
"--remote-folder", "ROOT", "--quick", "--on-conflict", "remote-wins", "--yes"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
dl := caller.callsFor("download_file")
|
|
if len(dl) != 1 || dl[0].args["fileId"] != "F" {
|
|
t.Fatalf("expected exactly one download_file(fileId=F), got %v", dl)
|
|
}
|
|
if up := caller.callsFor("get_upload_info"); len(up) != 0 {
|
|
t.Errorf("remote-wins must not upload, got %v", up)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "remote-version" {
|
|
t.Errorf("remote-wins must replace the local content, got %q", string(b))
|
|
}
|
|
}
|
|
|
|
// pull --if-exists overwrite --yes:确认后才 download_file(fileId=A) 并覆盖本地内容。
|
|
func TestCrossPlatformCoverageDrivePull_confirmedOverwriteIssuesExactCalls(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "a.txt")
|
|
mustWrite(t, p, "local-old")
|
|
|
|
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
|
|
swapPullDownloadPath(t, func(_ context.Context, _ string, _ map[string]string, dest string) error {
|
|
return os.WriteFile(dest, []byte("remote-new"), 0o644)
|
|
})
|
|
|
|
if err := runDriveCmdWithoutConfirm(t, caller, "pull", "--local-folder", dir,
|
|
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
dl := caller.callsFor("download_file")
|
|
if len(dl) != 1 || dl[0].args["fileId"] != "A" {
|
|
t.Fatalf("expected exactly one download_file(fileId=A), got %v", dl)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "remote-new" {
|
|
t.Errorf("confirmed overwrite must replace the local file, got %q", string(b))
|
|
}
|
|
}
|
|
|
|
// push --if-exists overwrite --yes:确认后原地覆盖,必须带 overwriteFileId 且不带 parentId。
|
|
func TestCrossPlatformCoverageDrivePush_confirmedOverwriteIssuesExactCalls(t *testing.T) {
|
|
dir := t.TempDir()
|
|
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
|
withNoopPut(t)
|
|
|
|
caller := pushOKCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":1}],"nextToken":""}}`,
|
|
})
|
|
if err := runDriveCmdWithoutConfirm(t, caller, "push", "--local-folder", dir,
|
|
"--remote-folder", "ROOT", "--if-exists", "overwrite", "--yes"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
up := caller.callsFor("get_upload_info")
|
|
if len(up) != 1 || up[0].args["overwriteFileId"] != "A" {
|
|
t.Fatalf("expected get_upload_info(overwriteFileId=A), got %v", up)
|
|
}
|
|
if _, has := up[0].args["parentId"]; has {
|
|
t.Error("in-place overwrite must not carry parentId")
|
|
}
|
|
}
|
|
|
|
// sync --on-conflict local-wins --yes:确认后覆盖上传远端,且不下载。
|
|
func TestCrossPlatformCoverageDriveSync_confirmedLocalWinsIssuesExactCalls(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "f.txt")
|
|
mustWrite(t, p, "local-version")
|
|
local := readFileMTimeMillis(t, p)
|
|
withSyncTransport(t, "unused")
|
|
|
|
caller := syncCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
|
differentMillis(local) + `}],"nextToken":""}}`,
|
|
})
|
|
if err := runDriveCmdWithoutConfirm(t, caller, "sync", "--local-folder", dir,
|
|
"--remote-folder", "ROOT", "--quick", "--on-conflict", "local-wins", "--yes"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
up := caller.callsFor("get_upload_info")
|
|
if len(up) != 1 || up[0].args["overwriteFileId"] != "F" {
|
|
t.Fatalf("expected get_upload_info(overwriteFileId=F), got %v", up)
|
|
}
|
|
if dl := caller.callsFor("download_file"); len(dl) != 0 {
|
|
t.Errorf("local-wins must not download, got %v", dl)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "local-version" {
|
|
t.Errorf("local-wins must keep the local content, got %q", string(b))
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// 3. 安全默认值本身
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// sync 默认 --on-conflict=skip:两侧都变更时两边内容都保留,且不发出任何传输调用。
|
|
func TestCrossPlatformCoverageDriveSync_defaultSkipsConflictsEntirely(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "f.txt")
|
|
mustWrite(t, p, "local-version")
|
|
local := readFileMTimeMillis(t, p)
|
|
withSyncTransport(t, "remote-version")
|
|
|
|
caller := syncCaller(map[string]string{
|
|
"ROOT": `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F","modifyTime":` +
|
|
differentMillis(local) + `}],"nextToken":""}}`,
|
|
})
|
|
if err := runDriveCmd(t, caller, "sync", "--local-folder", dir, "--remote-folder", "ROOT", "--quick"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got := len(caller.callsFor("download_file")) + len(caller.callsFor("get_upload_info")); got != 0 {
|
|
t.Errorf("default skip must not transfer anything, got %d calls", got)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "local-version" {
|
|
t.Errorf("default skip must keep the local content, got %q", string(b))
|
|
}
|
|
}
|
|
|
|
// pull 默认 --if-exists=skip:本地已存在的文件不被覆盖。
|
|
func TestCrossPlatformCoverageDrivePull_defaultSkipsExistingLocalFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
p := filepath.Join(dir, "a.txt")
|
|
mustWrite(t, p, "local-old")
|
|
|
|
caller := pullListingCaller(`{"name":"a.txt","type":"file","fileId":"A","modifyTime":9}`)
|
|
swapPullDownloadPath(t, func(context.Context, string, map[string]string, string) error {
|
|
t.Error("default skip must not download over an existing local file")
|
|
return nil
|
|
})
|
|
|
|
if err := runDriveCmd(t, caller, "pull", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if b, _ := os.ReadFile(p); string(b) != "local-old" {
|
|
t.Errorf("default skip must keep the local file, got %q", string(b))
|
|
}
|
|
}
|