450 lines
18 KiB
Go
450 lines
18 KiB
Go
package helpers
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||
)
|
||
|
||
var errTestUpload = errors.New("simulated upload failure")
|
||
|
||
// pushOKCaller 是 push 的“一切正常”mock:list_files 按 parentId 返回远端现状,
|
||
// create_folder 返回新 fileId,get_upload_info 返回可解析凭证,ln 提交成功。
|
||
func pushOKCaller(listing map[string]string) *driveScriptCaller {
|
||
return &driveScriptCaller{reply: func(tool string, args map[string]any, _ int) (string, error) {
|
||
switch tool {
|
||
case "list_files":
|
||
parent, _ := args["parentId"].(string)
|
||
if body, ok := listing[parent]; ok {
|
||
return body, nil
|
||
}
|
||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||
case "create_folder":
|
||
return `{"result":{"fileId":"NEWDIR"},"success":true}`, nil
|
||
case "get_upload_info":
|
||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||
}
|
||
return `{"result":{"fileId":"NEWFILE"},"success":true}`, nil
|
||
}}
|
||
}
|
||
|
||
func withNoopPut(t *testing.T) {
|
||
t.Helper()
|
||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// runDrivePush — 端到端
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
// 本地子目录在远端缺失 → 按需 create_folder 并留 folder_created 痕迹,再上传其中文件。
|
||
func TestCrossPlatformCoverageDrivePush_createsMissingRemoteFolders(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", "deep.txt"), "d")
|
||
withNoopPut(t)
|
||
|
||
caller := pushOKCaller(map[string]string{"ROOT": `{"result":{"items":[],"nextToken":""}}`})
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
cf := caller.callsFor("create_folder")
|
||
if len(cf) != 1 || cf[0].args["name"] != "sub" {
|
||
t.Fatalf("expected create_folder(sub), got %v", cf)
|
||
}
|
||
if cf[0].args["parentId"] != "ROOT" {
|
||
t.Errorf("create_folder parentId = %v, want ROOT", cf[0].args["parentId"])
|
||
}
|
||
// 新建目录的 fileId 必须成为其中文件的 parentId。
|
||
up := caller.callsFor("get_upload_info")
|
||
if len(up) != 1 || up[0].args["parentId"] != "NEWDIR" {
|
||
t.Errorf("upload parentId = %v, want NEWDIR", up[0].args["parentId"])
|
||
}
|
||
}
|
||
|
||
// 远端已存在同名目录 → 复用其 fileId,不重建、不出现在 items[]。
|
||
func TestCrossPlatformCoverageDrivePush_reusesExistingRemoteFolder(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", "deep.txt"), "d")
|
||
withNoopPut(t)
|
||
|
||
caller := pushOKCaller(map[string]string{
|
||
"ROOT": `{"result":{"items":[{"name":"sub","type":"folder","fileId":"EXISTING"}],"nextToken":""}}`,
|
||
})
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if got := caller.callsFor("create_folder"); len(got) != 0 {
|
||
t.Errorf("existing remote folder must not be recreated, got %v", got)
|
||
}
|
||
if up := caller.callsFor("get_upload_info"); up[0].args["parentId"] != "EXISTING" {
|
||
t.Errorf("upload parentId = %v, want EXISTING", up[0].args["parentId"])
|
||
}
|
||
}
|
||
|
||
// create_folder 失败 / 未返回 fileId → 该目录记 failed,其中文件因父目录缺失也 failed。
|
||
func TestCrossPlatformCoverageDrivePush_folderCreationFailureCascades(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
reply func(string, map[string]any, int) (string, error)
|
||
}{
|
||
{"tool error", func(tool string, _ map[string]any, _ int) (string, error) {
|
||
if tool == "create_folder" {
|
||
return "", errors.New("create_folder boom")
|
||
}
|
||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||
}},
|
||
{"no fileId", func(tool string, _ map[string]any, _ int) (string, error) {
|
||
if tool == "create_folder" {
|
||
return `{"result":{},"success":true}`, nil
|
||
}
|
||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||
}},
|
||
} {
|
||
t.Run(tc.name, func(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", "deep.txt"), "d")
|
||
withNoopPut(t)
|
||
|
||
caller := &driveScriptCaller{reply: tc.reply}
|
||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||
var pf *drivePushFailure
|
||
if !errors.As(err, &pf) {
|
||
t.Fatalf("expected drivePushFailure, got %T %v", err, err)
|
||
}
|
||
// 目录 + 其中文件各记一次失败。
|
||
if pf.failed != 2 {
|
||
t.Errorf("failed = %d, want 2 (folder + orphaned file)", pf.failed)
|
||
}
|
||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||
t.Errorf("no upload may happen without a parent folder, got %v", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// --if-exists skip(默认):远端已存在 → 跳过,不上传。
|
||
func TestCrossPlatformCoverageDrivePush_ifExistsSkipIsDefault(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 := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||
t.Errorf("default skip must not upload, got %v", got)
|
||
}
|
||
}
|
||
|
||
// --if-exists smart:远端时间已 ≥ 本地 → 跳过;远端更旧 → 覆盖上传。
|
||
func TestCrossPlatformCoverageDrivePush_ifExistsSmart(t *testing.T) {
|
||
t.Run("remote newer skips", func(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "a.txt")
|
||
mustWrite(t, p, "local")
|
||
local := readFileMTimeMillis(t, p)
|
||
withNoopPut(t)
|
||
|
||
caller := pushOKCaller(map[string]string{
|
||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` +
|
||
strconv.FormatInt(local+60000, 10) + `}],"nextToken":""}}`,
|
||
})
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if got := caller.callsFor("get_upload_info"); len(got) != 0 {
|
||
t.Errorf("smart must skip when remote is newer, got %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("remote older overwrites in place", func(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "a.txt")
|
||
mustWrite(t, p, "local")
|
||
local := readFileMTimeMillis(t, p)
|
||
withNoopPut(t)
|
||
|
||
caller := pushOKCaller(map[string]string{
|
||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A","modifyTime":` +
|
||
strconv.FormatInt(local-60000, 10) + `}],"nextToken":""}}`,
|
||
})
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); 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("smart overwrite must pass overwriteFileId=A, got %v", up)
|
||
}
|
||
if _, has := up[0].args["parentId"]; has {
|
||
t.Error("overwrite upload must not carry parentId")
|
||
}
|
||
})
|
||
|
||
t.Run("remote time invalid overwrites", func(t *testing.T) {
|
||
dir := t.TempDir()
|
||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||
withNoopPut(t)
|
||
|
||
// 远端无 modifyTime → 时间不可信,smart 不敢跳过,走覆盖。
|
||
caller := pushOKCaller(map[string]string{
|
||
"ROOT": `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"A"}],"nextToken":""}}`,
|
||
})
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--if-exists", "smart"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if up := caller.callsFor("get_upload_info"); len(up) != 1 {
|
||
t.Fatalf("expected overwrite upload, got %v", up)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 上传失败 → 记 failed 并以非零退出码退出,结构化结果仍打印。
|
||
func TestCrossPlatformCoverageDrivePush_uploadFailureExitsNonZero(t *testing.T) {
|
||
dir := t.TempDir()
|
||
mustWrite(t, filepath.Join(dir, "a.txt"), "local")
|
||
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return errTestUpload })
|
||
|
||
caller := pushOKCaller(nil)
|
||
err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT")
|
||
var pf *drivePushFailure
|
||
if !errors.As(err, &pf) || pf.failed != 1 {
|
||
t.Fatalf("expected 1 push failure, got %T %v", err, err)
|
||
}
|
||
}
|
||
|
||
// --if-exists 非法值直接拒绝。
|
||
func TestCrossPlatformCoverageDrivePush_invalidIfExistsRejected(t *testing.T) {
|
||
err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", t.TempDir(), "--remote-folder", "ROOT", "--if-exists", "bogus")
|
||
if err == nil || !strings.Contains(err.Error(), "--if-exists") {
|
||
t.Fatalf("expected --if-exists rejection, got %v", err)
|
||
}
|
||
}
|
||
|
||
// space-id 必须透传到 create_folder / get_upload_info / ln 三处。
|
||
func TestCrossPlatformCoverageDrivePush_passesSpaceIDEverywhere(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", "a.txt"), "x")
|
||
withNoopPut(t)
|
||
|
||
caller := pushOKCaller(nil)
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT", "--space-id", "SP7"); err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
for _, tool := range []string{"list_files", "create_folder", "get_upload_info"} {
|
||
calls := caller.callsFor(tool)
|
||
if len(calls) == 0 {
|
||
t.Fatalf("%s was never called", tool)
|
||
}
|
||
if calls[0].args["spaceId"] != "SP7" {
|
||
t.Errorf("%s spaceId = %v, want SP7", tool, calls[0].args["spaceId"])
|
||
}
|
||
}
|
||
}
|
||
|
||
// 远端列举失败 → push 直接报错,不做任何上传。
|
||
func TestCrossPlatformCoverageDrivePush_remoteListFailurePropagates(t *testing.T) {
|
||
dir := t.TempDir()
|
||
mustWrite(t, filepath.Join(dir, "a.txt"), "x")
|
||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) { return "", errTestList }}
|
||
if err := runDriveCmd(t, caller, "push", "--local-folder", dir, "--remote-folder", "ROOT"); err == nil {
|
||
t.Fatal("expected remote listing failure to propagate")
|
||
}
|
||
}
|
||
|
||
// 本地根目录不存在 → 扫描失败。
|
||
func TestCrossPlatformCoverageDrivePush_missingLocalRootFails(t *testing.T) {
|
||
missing := filepath.Join(t.TempDir(), "absent")
|
||
err := runDriveCmd(t, pushOKCaller(nil), "push", "--local-folder", missing, "--remote-folder", "ROOT")
|
||
if err == nil || !strings.Contains(err.Error(), "扫描本地目录失败") {
|
||
t.Fatalf("expected local scan failure, got %v", err)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// walkRemoteForPush — 分页、递归、深度上限、非镜像类型跳过
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageWalkRemoteForPush_paginationRecursionAndSkips(t *testing.T) {
|
||
// 递归会插在 ROOT 的两页之间,所以按 (parentId, nextToken) 判定而非调用序号。
|
||
caller := &driveScriptCaller{reply: func(_ string, args map[string]any, _ int) (string, error) {
|
||
parent, _ := args["parentId"].(string)
|
||
token, _ := args["nextToken"].(string)
|
||
if parent == "SUB" {
|
||
return `{"result":{"items":[{"name":"deep.txt","type":"file","fileId":"D"}],"nextToken":""}}`, nil
|
||
}
|
||
if parent == "ROOT" && token == "" {
|
||
return `{"result":{"items":[{"name":"sub","type":"folder","fileId":"SUB"}],"nextToken":"P2"}}`, nil
|
||
}
|
||
if parent == "ROOT" && token == "P2" {
|
||
return `{"result":{"items":[` +
|
||
`{"name":"online.adoc","type":"document","fileId":"DOC"},` +
|
||
`{"name":"ok.txt","type":"file","fileId":"OK"}` +
|
||
`],"nextToken":""}}`, nil
|
||
}
|
||
return `{"result":{"items":[],"nextToken":""}}`, nil
|
||
}}
|
||
|
||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||
|
||
files, folders, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT")
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if _, ok := folders["sub"]; !ok {
|
||
t.Error("sub folder must be indexed")
|
||
}
|
||
if _, ok := files["sub/deep.txt"]; !ok {
|
||
t.Error("nested file must be indexed with joined rel_path")
|
||
}
|
||
if _, ok := files["ok.txt"]; !ok {
|
||
t.Error("second page file must be indexed")
|
||
}
|
||
if _, ok := files["online.adoc"]; ok {
|
||
t.Error("non-file document must remain outside the folder mirror index")
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageWalkRemoteForPush_depthLimitAborts(t *testing.T) {
|
||
caller := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||
return `{"result":{"items":[{"name":"loop","type":"folder","fileId":"LOOP"}],"nextToken":""}}`, nil
|
||
}}
|
||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: io.Discard}})
|
||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||
|
||
if _, _, err := fetchRemoteTreeForPush(context.Background(), "", "ROOT"); err == nil ||
|
||
!strings.Contains(err.Error(), "循环引用") {
|
||
t.Fatalf("expected depth-limit abort, got %v", err)
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// walkLocalForPush — 非常规文件与错误分支
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageWalkLocalForPush_skipsIrregularAndMissingRoot(t *testing.T) {
|
||
if _, _, err := walkLocalForPush(filepath.Join(t.TempDir(), "absent")); err == nil {
|
||
t.Fatal("expected error for missing root")
|
||
}
|
||
|
||
dir := t.TempDir()
|
||
mustWrite(t, filepath.Join(dir, "real.txt"), "x")
|
||
if err := os.Symlink(filepath.Join(dir, "absent"), filepath.Join(dir, "dangling")); err != nil {
|
||
t.Skipf("symlink unsupported: %v", err)
|
||
}
|
||
_, files, err := walkLocalForPush(dir)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
for _, f := range files {
|
||
if f.RelPath == "dangling" {
|
||
t.Error("dangling symlink must not be pushed")
|
||
}
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// pushUploadFile — 凭证解析与提交失败分支
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoveragePushUploadFile_failureBranches(t *testing.T) {
|
||
dir := t.TempDir()
|
||
p := filepath.Join(dir, "a.txt")
|
||
mustWrite(t, p, "payload")
|
||
|
||
cases := []struct {
|
||
name string
|
||
reply func(string, map[string]any, int) (string, error)
|
||
put func(context.Context, string, map[string]string, string, int64) error
|
||
}{
|
||
{"get_upload_info tool error",
|
||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||
if tool == "get_upload_info" {
|
||
return "", errTestUpload
|
||
}
|
||
return `{"result":{},"success":true}`, nil
|
||
}, nil},
|
||
{"unparsable upload credential",
|
||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||
if tool == "get_upload_info" {
|
||
return `{"result":{}}`, nil // 无 resourceUrls
|
||
}
|
||
return `{"result":{},"success":true}`, nil
|
||
}, nil},
|
||
{"oss put fails",
|
||
func(string, map[string]any, int) (string, error) {
|
||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||
},
|
||
func(context.Context, string, map[string]string, string, int64) error { return errTestUpload }},
|
||
{"commit fails",
|
||
func(tool string, _ map[string]any, _ int) (string, error) {
|
||
if tool == "get_upload_info" {
|
||
return `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`, nil
|
||
}
|
||
return "", errTestUpload
|
||
}, nil},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: tc.reply}, Out: &Formatter{w: io.Discard}})
|
||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||
put := tc.put
|
||
if put == nil {
|
||
put = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||
}
|
||
testseam.Swap(t, &httpPutFile, put)
|
||
|
||
if err := pushUploadFile(context.Background(), "SP", "PARENT", "", "a.txt", p, 7); err == nil {
|
||
t.Fatal("expected upload failure")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// pushCreateFolder:MCP 失败上抛,成功时从返回体提取 fileId。
|
||
func TestCrossPlatformCoveragePushCreateFolder_errorAndSuccess(t *testing.T) {
|
||
testseam.Swap(t, &os.Args, []string{"dws", "drive"})
|
||
|
||
testseam.Swap(t, &deps, &Deps{Caller: &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||
return "", errTestUpload
|
||
}}, Out: &Formatter{w: io.Discard}})
|
||
if _, err := pushCreateFolder(context.Background(), "", "P", "n"); err == nil {
|
||
t.Fatal("expected create_folder error to propagate")
|
||
}
|
||
|
||
ok := &driveScriptCaller{reply: func(string, map[string]any, int) (string, error) {
|
||
return `{"result":{"fileId":"FID42"},"success":true}`, nil
|
||
}}
|
||
testseam.Swap(t, &deps, &Deps{Caller: ok, Out: &Formatter{w: io.Discard}})
|
||
got, err := pushCreateFolder(context.Background(), "SP", "P", "n")
|
||
if err != nil || got != "FID42" {
|
||
t.Fatalf("pushCreateFolder = (%q, %v), want (FID42, nil)", got, err)
|
||
}
|
||
if ok.calls[0].args["spaceId"] != "SP" || ok.calls[0].args["parentId"] != "P" {
|
||
t.Errorf("create_folder args = %v", ok.calls[0].args)
|
||
}
|
||
}
|