package helpers import ( "context" "errors" "io" "os" "path/filepath" "strconv" "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) // errTestDownload 用于模拟 httpGetFile 下载失败。 var errTestDownload = errors.New("simulated download failure") // swapPullDownloadPath 为存量测试保留基于路径的传输模拟写法。 // 生产 pull/sync 始终把已由 os.Root 安全打开的句柄交给 pullDownloadFile; // 只有测试适配层才把该句柄的名称传给旧 callback。 func swapPullDownloadPath(t *testing.T, fn func(context.Context, string, map[string]string, string) error) { t.Helper() testseam.Swap(t, &pullDownloadFile, func(ctx context.Context, url string, headers map[string]string, destination *os.File) error { return fn(ctx, url, headers, destination.Name()) }) } // ────────────────────────────────────────────────────────── // driveSyncFailure — sync 部分失败错误:exit=1 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDriveSyncFailure(t *testing.T) { e := &driveSyncFailure{failed: 2} if e.ExitCode() != 1 { t.Errorf("ExitCode() = %d, want 1", e.ExitCode()) } if e.Error() != e.RawStderr() { t.Error("Error() 与 RawStderr() 应一致") } if !strings.Contains(e.Error(), "2") { t.Errorf("Error() 应包含失败数, got %q", e.Error()) } } // ────────────────────────────────────────────────────────── // driveSyncSuffixedRel — keep-both 本地重命名目标生成 // ────────────────────────────────────────────────────────── func TestCrossPlatformCoverageDriveSyncSuffixedRel(t *testing.T) { // 基本:扩展名前插入基于 fileId 末 8 位的后缀。 got := driveSyncSuffixedRel("a/b.txt", "0123456789ABCDEF", map[string]bool{}) if want := "a/b.conflict-89ABCDEF.txt"; got != want { t.Errorf("suffixed = %q, want %q", got, want) } // 根目录下、无扩展名。 if got := driveSyncSuffixedRel("readme", "", map[string]bool{}); got != "readme.conflict" { t.Errorf("suffixed = %q, want readme.conflict", got) } // 首选目标已被占用时追加序号,直到不冲突。 occupied := map[string]bool{ "x.conflict-FID.log": true, "x.conflict-FID.0.log": true, } got = driveSyncSuffixedRel("x.log", "FID", occupied) if want := "x.conflict-FID.1.log"; got != want { t.Errorf("suffixed with collisions = %q, want %q", got, want) } } // ────────────────────────────────────────────────────────── // sync 双向流程 — new_local 上传 / new_remote 下载 // ────────────────────────────────────────────────────────── // runDriveSyncTest 用 mock caller 执行 `drive sync`,注入 opened-file PUT / GET 为 // 可控 no-op,避免真实 OSS 传输。返回 root.Execute() 的结果。 func runDriveSyncTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error { // 默认下载:把远端内容写到目标路径,模拟成功落盘。 return runDriveSyncTestWithGet(t, caller, func(_ context.Context, _ string, _ map[string]string, dest string) error { return os.WriteFile(dest, []byte("remote-content"), 0o644) }, localDir, args...) } // runDriveSyncTestWithGet 同上,但允许自定义 httpGetFile 行为(例如模拟下载失败)。 func runDriveSyncTestWithGet(t *testing.T, caller *driveSyncMockCaller, getFn func(context.Context, string, map[string]string, string) error, localDir string, args ...string) error { t.Helper() testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil }) swapPullDownloadPath(t, getFn) testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}}) root := &cobra.Command{Use: "dws"} root.PersistentFlags().BoolP("yes", "y", false, "") root.PersistentFlags().Bool("dry-run", false, "") root.AddCommand(newDriveCommand()) // sync 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。 full := append([]string{"sync", "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}, args...) testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...)) root.SetArgs(append([]string{"drive"}, full...)) return root.Execute() } // 双向:本地独有 local.txt 应上传;远端独有 remote.txt 应下载到本地。 func TestCrossPlatformCoverageDriveSync_bidirectional(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") caller := &driveSyncMockCaller{ // 远端仅有 remote.txt(本地不存在)→ new_remote,应被下载。 listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","md5":"z","modifyTime":2000}],"nextToken":""}}`, } if err := runDriveSyncTest(t, caller, dir); err != nil { t.Fatalf("unexpected error: %v", err) } // new_local:local.txt 走 get_upload_info(新建上传,传 parentId=ROOT、不传 overwriteFileId)。 up := caller.callsFor("get_upload_info") if len(up) != 1 { t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) } if got := up[0].args["parentId"]; got != "ROOT" { t.Errorf("get_upload_info parentId = %v, want ROOT", got) } if _, ok := up[0].args["overwriteFileId"]; ok { t.Error("新建上传不应传 overwriteFileId") } // new_remote:remote.txt 走 download_file,并已落盘到本地。 dl := caller.callsFor("download_file") if len(dl) != 1 || dl[0].args["fileId"] != "R_FID" { t.Fatalf("expected 1 download_file(fileId=R_FID), got %v", dl) } if _, err := os.Stat(filepath.Join(dir, "remote.txt")); err != nil { t.Errorf("remote.txt 应被下载到本地: %v", err) } } // modified + remote-wins:quick 模式下两侧 mtime 不同判为 modified,并显式拉取远端覆盖本地。 func TestCrossPlatformCoverageDriveSync_modifiedRemoteWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-old") // 本地 mtime 设为与远端不同,quick 模式判为 modified。 local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "remote-wins"); err != nil { t.Fatalf("unexpected error: %v", err) } // remote-wins → 下载覆盖本地,且不应发生上传。 if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "F_FID" { t.Errorf("expected download_file(fileId=F_FID), got %v", dl) } if up := caller.callsFor("get_upload_info"); len(up) != 0 { t.Errorf("remote-wins 不应上传, got %d get_upload_info calls", len(up)) } if b, _ := os.ReadFile(p); string(b) != "remote-content" { t.Errorf("本地文件应被远端内容覆盖, got %q", string(b)) } } // modified + local-wins:改用 local-wins 时应覆盖上传远端(传 overwriteFileId、不传 parentId)。 func TestCrossPlatformCoverageDriveSync_modifiedLocalWins(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-new") local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "local-wins"); err != nil { t.Fatalf("unexpected error: %v", err) } up := caller.callsFor("get_upload_info") if len(up) != 1 { t.Fatalf("expected 1 get_upload_info call, got %d", len(up)) } if got := up[0].args["overwriteFileId"]; got != "F_FID" { t.Errorf("local-wins 覆盖上传 overwriteFileId = %v, want F_FID", got) } if _, ok := up[0].args["parentId"]; ok { t.Error("覆盖上传不应传 parentId") } if dl := caller.callsFor("download_file"); len(dl) != 0 { t.Errorf("local-wins 不应下载, got %d download_file calls", len(dl)) } } func TestCrossPlatformCoverageDriveSyncRejectsUnknownCommitEffect(t *testing.T) { for _, response := range []string{"", `{}`} { t.Run(response, func(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "payload") caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[]}}`, commitJSON: &response, } err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "sync", caller, dir) if err == nil { t.Fatal("empty commit result must make sync exit non-zero") } if failure, ok := err.(*driveSyncFailure); !ok || failure.ExitCode() != 1 { t.Fatalf("error=%T %v", err, err) } assertMirrorUploadFailedJSON(t, output) if putCalls != 1 { t.Fatalf("OSS PUT calls=%d, want exactly 1", putCalls) } if got := len(caller.callsFor("get_upload_info")); got != 1 { t.Fatalf("get_upload_info calls=%d, want 1", got) } if got := len(caller.callsFor("commit_upload")); got != 1 { t.Fatalf("commit_upload calls=%d, want 1", got) } }) } } // --dry-run:只计算差异,不触发任何 MCP 写操作与本地落盘。 func TestCrossPlatformCoverageDriveSync_dryRun(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "x") caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}],"nextToken":""}}`, dryRun: true, } if err := runDriveSyncTest(t, caller, dir); 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("dry-run 不应触发下载/上传, got %d 次写操作调用", got) } if _, err := os.Stat(filepath.Join(dir, "remote.txt")); !os.IsNotExist(err) { t.Error("dry-run 不应把远端文件落盘到本地") } } // remote-wins 下载失败:本地原文件必须原样保留(pullOneFile 先写临时文件、成功才原子 // rename 覆盖),且命令以非零退出码退出。 func TestCrossPlatformCoverageDriveSync_pullFailureKeepsLocal(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-original") local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } failGet := func(context.Context, string, map[string]string, string) error { return errTestDownload } err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "remote-wins") if err == nil { t.Fatal("下载失败时应以非零退出码退出") } if ec, ok := err.(*driveSyncFailure); !ok || ec.ExitCode() != 1 { t.Fatalf("期望 driveSyncFailure(exit=1), got %T %v", err, err) } // 关键:原文件内容与存在性都不被破坏。 if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" { t.Errorf("下载失败后本地原文件应原样保留, got %q err=%v", string(b), rerr) } } // keep-both 成功路径:先以 no-clobber 硬链接保留本地版本,再把远端拉取到原路径。 func TestCrossPlatformCoverageDriveSync_keepBothSuccess(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-new") local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "keep-both"); err != nil { t.Fatalf("keep-both 成功路径不应报错: %v", err) } // 原路径 f.txt 应为远端内容(拉取落盘)。 if b, _ := os.ReadFile(p); string(b) != "remote-content" { t.Errorf("f.txt 应为远端内容, got %q", string(b)) } // 本地版本改名保留为 f.conflict-F_FID.txt,内容不变。 aside := filepath.Join(dir, "f.conflict-F_FID.txt") if b, err := os.ReadFile(aside); err != nil || string(b) != "local-new" { t.Errorf("本地版本应改名保留为 %s 且内容不变, got %q err=%v", filepath.Base(aside), string(b), err) } // 下载发生一次(拉取远端到原路径),不应有上传。 if dl := caller.callsFor("download_file"); len(dl) != 1 { t.Errorf("keep-both 应拉取远端一次, got %d download_file calls", len(dl)) } if up := caller.callsFor("get_upload_info"); len(up) != 0 { t.Errorf("keep-both 不应上传, got %d get_upload_info calls", len(up)) } } // keep-both 下载失败:不做可能误删并发文件的回滚,原名与本地保留候选都应保留。 func TestCrossPlatformCoverageDriveSync_keepBothPreservesLinkOnPullFailure(t *testing.T) { dir := t.TempDir() p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-original") local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `}],"nextToken":""}}`, } failGet := func(context.Context, string, map[string]string, string) error { return errTestDownload } err := runDriveSyncTestWithGet(t, caller, failGet, dir, "--quick", "--on-conflict", "keep-both") if err == nil { t.Fatal("下载失败时应以非零退出码退出") } // 原名尚未被远端发布,保持原内容。 if b, rerr := os.ReadFile(p); rerr != nil || string(b) != "local-original" { t.Errorf("keep-both 拉取失败后原文件应保留: got %q err=%v", string(b), rerr) } aside := filepath.Join(dir, "f.conflict-F_FID.txt") if b, rerr := os.ReadFile(aside); rerr != nil || string(b) != "local-original" { t.Errorf("keep-both 拉取失败后候选应保留: got %q err=%v", string(b), rerr) } } // reserveSyncKeepBothTarget 不得占用「精确同名」的既有文件(occupied 未记录时), // 应原子占位到下一个后缀,且既有文件内容不被破坏。此断言在任何文件系统上都成立。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteExact(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "f.txt"), "local-data") // 预置一个与首选候选精确同名的既有文件(模拟未被 occupied 记录的外部残留)。 first := "f.conflict-ABCD1234.txt" mustWrite(t, filepath.Join(dir, first), "existing-data") rel, abs, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000ABCD1234", map[string]bool{}) if err != nil { t.Fatal(err) } if rel == first { t.Fatalf("不应占用既有文件 %s", first) } if want := "f.conflict-ABCD1234.1.txt"; rel != want { t.Errorf("rel = %q, want %q", rel, want) } // 既有文件内容原样保留(未被覆盖)。 if b, _ := os.ReadFile(filepath.Join(dir, first)); string(b) != "existing-data" { t.Error("既有文件被覆盖,数据丢失") } // 返回的目标是原始本地版本的硬链接,不是空占位。 if b, e := os.ReadFile(abs); e != nil || string(b) != "local-data" { t.Errorf("候选硬链接内容错误: content=%q err=%v", b, e) } } // reserveSyncKeepBothTarget 在大小写不敏感文件系统上,不得覆盖与候选「大小写等价」的 // 既有文件——O_EXCL 由 OS 兜底判等价性,occupied 的精确字符串键覆盖不到这种情况。 func TestCrossPlatformCoverageReserveSyncKeepBothTarget_noOverwriteCaseEquivalent(t *testing.T) { dir := t.TempDir() mustWrite(t, filepath.Join(dir, "f.txt"), "local-data") if !isCaseInsensitiveFS(dir) { t.Skip("文件系统大小写敏感,跳过大小写等价覆盖测试") } // 既有文件用大写变体,occupied 为空(模拟精确键查重覆盖不到的等价文件)。 existing := "f.CONFLICT-ABCD1234.TXT" mustWrite(t, filepath.Join(dir, existing), "case-variant-data") rel, _, err := reserveSyncKeepBothTarget(dir, "f.txt", "0000abcd1234", map[string]bool{}) if err != nil { t.Fatal(err) } // 生成的小写候选与既有大写文件在不敏感 FS 上等价,本不应被占用。 if strings.EqualFold(rel, "f.conflict-abcd1234.txt") { t.Errorf("首选候选与既有文件等价,不应被占用: %q", rel) } // 既有文件内容原样保留。 if b, _ := os.ReadFile(filepath.Join(dir, existing)); string(b) != "case-variant-data" { t.Error("大小写等价的既有文件被覆盖,数据丢失") } } // 非交互 ask(stdin 立即 EOF):冲突项应被跳过而非中止整个同步, // new_local 仍上传、new_remote 仍下载,且被跳过的 modified 本地文件不被改动。 func TestCrossPlatformCoverageDriveSync_askNonInteractiveSkipsConflict(t *testing.T) { testseam.Swap(t, &syncAskStdin, io.Reader(strings.NewReader(""))) // 非交互:立即 EOF dir := t.TempDir() mustWrite(t, filepath.Join(dir, "local.txt"), "local-only") // new_local → 应上传 p := filepath.Join(dir, "f.txt") mustWrite(t, p, "local-old") // modified → ask 非交互跳过 local := readFileMTimeMillis(t, p) caller := &driveSyncMockCaller{ listJSON: `{"result":{"items":[` + `{"name":"f.txt","type":"file","fileId":"F_FID","modifyTime":` + differentMillis(local) + `},` + `{"name":"remote.txt","type":"file","fileId":"R_FID","modifyTime":2000}` + `],"nextToken":""}}`, } if err := runDriveSyncTest(t, caller, dir, "--quick", "--on-conflict", "ask"); err != nil { t.Fatalf("非交互 ask 不应中止同步: %v", err) } // new_local 上传、new_remote 下载都应照常发生。 if up := caller.callsFor("get_upload_info"); len(up) != 1 { t.Errorf("new_local 应被上传, got %d get_upload_info calls", len(up)) } if dl := caller.callsFor("download_file"); len(dl) != 1 || dl[0].args["fileId"] != "R_FID" { t.Errorf("new_remote 应被下载, got %v", dl) } // modified 冲突被跳过:本地 f.txt 未被覆盖。 if b, _ := os.ReadFile(p); string(b) != "local-old" { t.Errorf("ask 跳过的 modified 文件不应被改动, got %q", string(b)) } } // readFileMTimeMillis 返回文件的 mtime(Unix 毫秒)。 func readFileMTimeMillis(t *testing.T, path string) int64 { t.Helper() fi, err := os.Stat(path) if err != nil { t.Fatal(err) } return fi.ModTime().UnixMilli() } // differentMillis 返回一个与 base 不同的毫秒数字符串,保证 quick 模式判为 modified。 func differentMillis(base int64) string { return strconv.FormatInt(base+60000, 10) // 差 1 分钟,足以区分 }