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

450 lines
18 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"
)
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)
}
}