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

361 lines
13 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 (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// ──────────────────────────────────────────────────────────
// splitRel — rel_path 拆父路径 / 末段名
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageSplitRel(t *testing.T) {
cases := []struct {
in string
parent, base string
}{
{"a/b/c", "a/b", "c"},
{"a/b", "a", "b"},
{"c", "", "c"},
{"", "", ""},
}
for _, c := range cases {
p, b := splitRel(c.in)
if p != c.parent || b != c.base {
t.Errorf("splitRel(%q) = (%q,%q), want (%q,%q)", c.in, p, b, c.parent, c.base)
}
}
}
// ──────────────────────────────────────────────────────────
// parseNodeID — create_folder / commit 返回里抽 fileId
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageParseNodeID(t *testing.T) {
cases := []struct {
name string
text string
want string
}{
{"result.fileId", `{"result":{"fileId":"F1","name":"x"},"success":true}`, "F1"},
{"top-level fileId", `{"fileId":"F2"}`, "F2"},
{"dentryUuid fallback", `{"result":{"dentryUuid":"D1"}}`, "D1"},
{"dentryId fallback", `{"result":{"dentryId":"D2"}}`, "D2"},
{"missing", `{"result":{"name":"x"}}`, ""},
{"invalid json", `not json`, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := parseNodeID(c.text); got != c.want {
t.Errorf("parseNodeID = %q, want %q", got, c.want)
}
})
}
}
// ──────────────────────────────────────────────────────────
// walkLocalForPush — 本地遍历(含空目录,父目录先于子目录)
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageWalkLocalForPush(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "a.txt"), "hello") // 5 bytes
mustWrite(t, filepath.Join(root, "sub1", "b.txt"), "worl") // 4 bytes, 建出 sub1
if err := os.MkdirAll(filepath.Join(root, "sub1", "sub2"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
t.Fatal(err)
}
dirs, files, err := walkLocalForPush(root)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 目录:含空目录,不含根本身,且父目录先于子目录(字典序即可保证)。
wantDirs := []string{"empty", "sub1", "sub1/sub2"}
if !sort.StringsAreSorted(dirs) {
t.Errorf("dirs should be sorted (parent before child): %v", dirs)
}
if !equalStrings(dirs, wantDirs) {
t.Errorf("dirs = %v, want %v", dirs, wantDirs)
}
// 文件:rel_path 用 /,记录 size 与 mtime。
byRel := map[string]localPushFile{}
for _, f := range files {
byRel[f.RelPath] = f
}
if len(byRel) != 2 {
t.Fatalf("expected 2 files, got %d: %v", len(byRel), files)
}
a, ok := byRel["a.txt"]
if !ok || a.Size != 5 {
t.Errorf("a.txt size = %d, want 5 (present=%v)", a.Size, ok)
}
if a.AbsPath == "" || a.ModTimeMillis == 0 {
t.Error("file should record AbsPath and mtime")
}
b, ok := byRel["sub1/b.txt"]
if !ok || b.Size != 4 {
t.Errorf("sub1/b.txt size = %d, want 4 (present=%v)", b.Size, ok)
}
}
// ──────────────────────────────────────────────────────────
// drivePushFailure — push 部分失败错误:exit=1
// ──────────────────────────────────────────────────────────
func TestCrossPlatformCoverageDrivePushFailure(t *testing.T) {
e := &drivePushFailure{failed: 3}
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(), "3") {
t.Errorf("Error() 应包含失败数, got %q", e.Error())
}
}
// ──────────────────────────────────────────────────────────
// push 覆盖流程 — get_upload_info / commit_upload 两阶段的 MCP 参数
// ──────────────────────────────────────────────────────────
// driveMCPCall 记录一次 MCP 工具调用。
type driveMCPCall struct {
toolName string
args map[string]any
}
// driveSyncMockCaller 按工具名分发返回,并记录全部调用,供断言 MCP 参数。
type driveSyncMockCaller struct {
calls []driveMCPCall
listJSON string // list_files 返回体
commitJSON *string // 非 nil 时覆盖 commit_upload 返回体(包括空响应)
dryRun bool // --dry-run:只算差异,不执行任何写操作
}
func (m *driveSyncMockCaller) CallTool(_ context.Context, _ string, toolName string, args map[string]any) (*edition.ToolResult, error) {
m.calls = append(m.calls, driveMCPCall{toolName: toolName, args: args})
var text string
switch toolName {
case "list_files":
text = m.listJSON
case "get_upload_info":
// 返回可被 parseDriveUploadInfo 解析的凭证。
text = `{"result":{"resourceUrls":[{"url":"https://oss.example.com/put","headers":{}}],"uploadId":"U1"}}`
case "download_file":
// 返回可被 parseDriveDownloadInfo 解析的预签名下载链接。
text = `{"result":{"downloadUrl":"https://oss.example.com/get","headers":{}}}`
case "commit_upload":
if m.commitJSON != nil {
text = *m.commitJSON
} else {
text = `{"result":{"fileId":"NEW_FID"},"success":true}`
}
default:
text = `{"result":{"fileId":"NEW_FID"},"success":true}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (m *driveSyncMockCaller) CallReadTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
return m.CallTool(ctx, productID, toolName, args)
}
func (m *driveSyncMockCaller) Format() string { return "raw" }
func (m *driveSyncMockCaller) DryRun() bool { return m.dryRun }
func (m *driveSyncMockCaller) Fields() string { return "" }
func (m *driveSyncMockCaller) JQ() string { return "" }
func (m *driveSyncMockCaller) callsFor(tool string) []driveMCPCall {
var out []driveMCPCall
for _, c := range m.calls {
if c.toolName == tool {
out = append(out, c)
}
}
return out
}
// runDrivePushTest 用 mock caller 执行 `drive push`,返回捕获的调用。
// 注入已打开文件句柄的 PUT seam 为 no-op,避免真实 OSS 上传。
func runDrivePushTest(t *testing.T, caller *driveSyncMockCaller, localDir string, args ...string) error {
t.Helper()
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error { return nil })
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())
// pull/push 是 user_required 叶子,非交互环境需 --yes 才能越过统一确认门。
full := append([]string{"push", "--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()
}
func runDriveMirrorUploadCommandCapture(t *testing.T, command string, caller *driveSyncMockCaller, localDir string) (error, string, int) {
t.Helper()
putCalls := 0
testseam.Swap(t, &pushPutOpenedFile, func(context.Context, string, map[string]string, *os.File, int64) error {
putCalls++
return nil
})
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().BoolP("yes", "y", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.AddCommand(newDriveCommand())
full := []string{command, "--local-folder", localDir, "--remote-folder", "ROOT", "--yes"}
testseam.Swap(t, &os.Args, append([]string{"dws", "drive"}, full...))
root.SetArgs(append([]string{"drive"}, full...))
return root.Execute(), out.String(), putCalls
}
func assertMirrorUploadFailedJSON(t *testing.T, output string) {
t.Helper()
var payload struct {
Summary struct {
Failed int `json:"failed"`
} `json:"summary"`
Items []struct {
Action string `json:"action"`
Error string `json:"error"`
} `json:"items"`
}
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("output is not structured JSON: %q: %v", output, err)
}
if payload.Summary.Failed != 1 || len(payload.Items) != 1 || payload.Items[0].Action != pushActionFailed || payload.Items[0].Error == "" {
t.Fatalf("structured failure=%#v", payload)
}
}
// 覆盖分支:get_upload_info 与 commit_upload 两阶段都必须传 overwriteFileId、都不传 parentId。
func TestCrossPlatformCoverageDrivePush_overwriteUsesOverwriteFileId(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "a.txt"), "new-content")
caller := &driveSyncMockCaller{
// 远端根目录下已存在同名 a.txt,fileId=REMOTE_FID。
listJSON: `{"result":{"items":[{"name":"a.txt","type":"file","fileId":"REMOTE_FID","md5":"x","modifyTime":1000}],"nextToken":""}}`,
}
if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); 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 != "REMOTE_FID" {
t.Errorf("get_upload_info overwriteFileId = %v, want REMOTE_FID", got)
}
if _, ok := up[0].args["parentId"]; ok {
t.Error("覆盖时 get_upload_info 不应传 parentId")
}
commit := caller.callsFor("commit_upload")
if len(commit) != 1 {
t.Fatalf("expected 1 commit_upload call, got %d", len(commit))
}
if got := commit[0].args["overwriteFileId"]; got != "REMOTE_FID" {
t.Errorf("commit_upload overwriteFileId = %v, want REMOTE_FID", got)
}
if _, ok := commit[0].args["parentId"]; ok {
t.Error("覆盖时 commit_upload 不应传 parentId")
}
}
// 新建分支:远端不存在同名文件时走普通上传——两阶段都传 parentId、都不传 overwriteFileId。
func TestCrossPlatformCoverageDrivePush_newUploadUsesParentId(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "b.txt"), "brand-new")
caller := &driveSyncMockCaller{
listJSON: `{"result":{"items":[],"nextToken":""}}`, // 远端空
}
if err := runDrivePushTest(t, caller, dir, "--if-exists", "overwrite"); 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 _, ok := up[0].args["overwriteFileId"]; ok {
t.Error("新建上传不应传 overwriteFileId")
}
if got := up[0].args["parentId"]; got != "ROOT" {
t.Errorf("get_upload_info parentId = %v, want ROOT", got)
}
commit := caller.callsFor("commit_upload")
if len(commit) != 1 || commit[0].args["parentId"] != "ROOT" {
t.Errorf("commit_upload should carry parentId=ROOT, got %v", commit)
}
}
func TestCrossPlatformCoverageDrivePushRejectsUnknownCommitEffect(t *testing.T) {
for _, response := range []string{"", `{}`} {
t.Run(fmt.Sprintf("response-%q", response), func(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "a.txt"), "payload")
caller := &driveSyncMockCaller{
listJSON: `{"result":{"items":[]}}`,
commitJSON: &response,
}
err, output, putCalls := runDriveMirrorUploadCommandCapture(t, "push", caller, dir)
if err == nil {
t.Fatal("empty commit result must make push exit non-zero")
}
if failure, ok := err.(*drivePushFailure); !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)
}
})
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}