Files
dingtalk-workspace-cli/internal/helpers/drive_sync_test.go

444 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 分钟,足以区分
}