From 9d59550890bed789b70021f2b57c9f67c36669d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Wed, 29 Jul 2026 13:29:22 +0800 Subject: [PATCH] test(helpers): cover new drive/doc-style/sheet/chat commands to 100% changed-code coverage The CI platform coverage gate enforces 100% coverage of changed statements via tests named TestCrossPlatformCoverage*/TestAllShortcuts. Add unit tests for drive list --depth BFS (pagination, rate-limit retry, dedup, truncation, SIGINT, anomalies), drive list --versions/transfer- owner/cover/revert paths, doc style cover upload flow, sheet formula-verify target parsing, and chat group user-settings validation. Also drop an unreachable resourceID guard in uploadDocStyleImage. --- internal/helpers/chat_user_settings_test.go | 61 ++ internal/helpers/doc_style.go | 3 - internal/helpers/doc_style_test.go | 137 +++ internal/helpers/drive_depth_test.go | 784 ++++++++++++++++++ internal/helpers/drive_list_modes_test.go | 215 +++++ internal/helpers/sheet_formula_verify_test.go | 91 ++ 6 files changed, 1288 insertions(+), 3 deletions(-) create mode 100644 internal/helpers/chat_user_settings_test.go create mode 100644 internal/helpers/doc_style_test.go create mode 100644 internal/helpers/drive_depth_test.go create mode 100644 internal/helpers/drive_list_modes_test.go create mode 100644 internal/helpers/sheet_formula_verify_test.go diff --git a/internal/helpers/chat_user_settings_test.go b/internal/helpers/chat_user_settings_test.go new file mode 100644 index 00000000..546f547a --- /dev/null +++ b/internal/helpers/chat_user_settings_test.go @@ -0,0 +1,61 @@ +package helpers + +import ( + "strings" + "testing" +) + +func TestCrossPlatformCoverageChatGroupUserSettingsQueryValidation(t *testing.T) { + caller := &guardedMutationCaller{} + if err := executeGuardedMutationCommand(t, caller, newChatCommand, + "group", "user-settings", "query", "--groups", ","); err == nil { + t.Fatal("empty groups returned nil") + } + + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = "cid" + } + if err := executeGuardedMutationCommand(t, caller, newChatCommand, + "group", "user-settings", "query", "--groups", strings.Join(tooMany, ",")); err == nil { + t.Fatal("101 groups returned nil") + } +} + +func TestCrossPlatformCoverageChatGroupUserSettingsSetValidation(t *testing.T) { + caller := &guardedMutationCaller{} + if err := executeGuardedMutationCommand(t, caller, newChatCommand, + "group", "user-settings", "set", "--items", "[]"); err == nil { + t.Fatal("empty items returned nil") + } + + var sb strings.Builder + sb.WriteString("[") + for i := 0; i < 101; i++ { + if i > 0 { + sb.WriteString(",") + } + sb.WriteString(`{"openConversationId":"cid"}`) + } + sb.WriteString("]") + if err := executeGuardedMutationCommand(t, caller, newChatCommand, + "group", "user-settings", "set", "--items", sb.String()); err == nil { + t.Fatal("101 items returned nil") + } +} + +func TestCrossPlatformCoverageChatGroupUserSettingsSetHappyPath(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newChatCommand, + "group", "user-settings", "set", "--items", `[{"openConversationId":"cid1","top":true}]`) + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v", caller.calls) + } + call := caller.calls[0] + if call.productID != "im" || call.toolName != "batch_update_group_chat_settings" { + t.Fatalf("call = %#v", call) + } +} diff --git a/internal/helpers/doc_style.go b/internal/helpers/doc_style.go index 8cdfea31..624b8b4b 100644 --- a/internal/helpers/doc_style.go +++ b/internal/helpers/doc_style.go @@ -305,8 +305,5 @@ func uploadDocStyleImage(ctx context.Context, nodeID, filePath, fileName, mimeTy if err := httpPutFile(ctx, uploadURL, ossHeaders, filePath, fileSize); err != nil { return "", err } - if resourceID == "" { - return "", fmt.Errorf("upload succeeded but no resourceId returned for %s", fileName) - } return resourceID, nil } diff --git a/internal/helpers/doc_style_test.go b/internal/helpers/doc_style_test.go new file mode 100644 index 00000000..5eecb0d0 --- /dev/null +++ b/internal/helpers/doc_style_test.go @@ -0,0 +1,137 @@ +package helpers + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTempImage(t *testing.T, name string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte("fake-image-bytes"), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestCrossPlatformCoverageValidateCoverImageFile(t *testing.T) { + if _, _, err := validateCoverImageFile(t.TempDir()); err == nil { + t.Fatal("directory returned nil") + } + path := writeTempImage(t, "cover.png") + mimeType, size, err := validateCoverImageFile(path) + if err != nil { + t.Fatal(err) + } + if mimeType != "image/png" || size != int64(len("fake-image-bytes")) { + t.Fatalf("mimeType=%q size=%d", mimeType, size) + } +} + +func TestCrossPlatformCoverageIsHexColorEdge(t *testing.T) { + for _, valid := range []string{"#E8F2FE", "#e8f2fe", "#000000", "#aB1234"} { + if !isHexColor(valid) { + t.Fatalf("%s should be valid", valid) + } + } + for _, invalid := range []string{"#GG0000", "E8F2FE", "#E8F2F", "#E8F2FEE", ""} { + if isHexColor(invalid) { + t.Fatalf("%s should be invalid", invalid) + } + } +} + +func executeDocStyleCommand(t *testing.T, caller *scriptedToolCaller, args ...string) error { + t.Helper() + installScriptedCaller(t, caller) + root := newDocStyleCommand() + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageDocStyleCoverSetMutualExclusion(t *testing.T) { + err := executeDocStyleCommand(t, &scriptedToolCaller{}, + "cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--file-path", "a.png") + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err = %v, want mutual exclusion", err) + } +} + +func TestCrossPlatformCoverageDocStyleCoverSetPositionValidation(t *testing.T) { + err := executeDocStyleCommand(t, &scriptedToolCaller{}, + "cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "1.5") + if err == nil || !strings.Contains(err.Error(), "--position must be within [0,1]") { + t.Fatalf("err = %v, want position range error", err) + } + + caller := &scriptedToolCaller{} + if err := executeDocStyleCommand(t, caller, + "cover", "set", "--node", "n1", "--image", "https://img.test/a.png", "--position", "0.5"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageDocStyleCoverSetFileDryRun(t *testing.T) { + path := writeTempImage(t, "cover.png") + caller := &scriptedToolCaller{dry: true} + if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil { + t.Fatal(err) + } +} + +func stubHTTPPutFile(t *testing.T, err error) { + t.Helper() + old := httpPutFile + httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return err } + t.Cleanup(func() { httpPutFile = old }) +} + +func TestCrossPlatformCoverageDocStyleCoverSetFileUpload(t *testing.T) { + path := writeTempImage(t, "cover.png") + stubHTTPPutFile(t, nil) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`}, + {text: `{}`}, + }} + if err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path); err != nil { + t.Fatal(err) + } + if caller.calls != 2 { + t.Fatalf("calls = %d, want 2 (upload info + style set)", caller.calls) + } +} + +func TestCrossPlatformCoverageDocStyleCoverSetFileUploadFailure(t *testing.T) { + path := writeTempImage(t, "cover.png") + stubHTTPPutFile(t, errors.New("oss down")) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"uploadUrl":"https://oss.test/put","resourceId":"res-1"}`}, + }} + err := executeDocStyleCommand(t, caller, "cover", "set", "--node", "n1", "--file", path) + if err == nil || !strings.Contains(err.Error(), "oss down") { + t.Fatalf("err = %v, want oss failure", err) + } +} + +func TestCrossPlatformCoverageUploadDocStyleImageFailures(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("cred failed")}}} + installScriptedCaller(t, caller) + if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil { + t.Fatal("cred failure returned nil") + } + + caller = &scriptedToolCaller{steps: []scriptedToolStep{{text: `not json`}}} + installScriptedCaller(t, caller) + if _, err := uploadDocStyleImage(context.Background(), "n1", "a.png", "a.png", "image/png", 10); err == nil { + t.Fatal("invalid upload info returned nil") + } +} diff --git a/internal/helpers/drive_depth_test.go b/internal/helpers/drive_depth_test.go new file mode 100644 index 00000000..9a950d4e --- /dev/null +++ b/internal/helpers/drive_depth_test.go @@ -0,0 +1,784 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func driveDepthTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "list"} + cmd.Flags().String("cursor", "", "") + cmd.Flags().String("next-token", "", "") + cmd.Flags().Int("limit", 0, "") + cmd.Flags().Int("max", 0, "") + return cmd +} + +func TestCrossPlatformCoverageDriveDepthCancelledError(t *testing.T) { + err := &driveDepthCancelledError{} + if err.Error() != driveDepthCancelledMsg || err.RawStderr() != driveDepthCancelledMsg { + t.Fatalf("messages = %q/%q", err.Error(), err.RawStderr()) + } + if err.ExitCode() != 130 { + t.Fatalf("ExitCode = %d, want 130", err.ExitCode()) + } +} + +func TestCrossPlatformCoverageValidateDriveListDepth(t *testing.T) { + newCmd := func() *cobra.Command { return driveDepthTestCmd() } + + if err := validateDriveListDepth(newCmd(), 0); err == nil { + t.Fatal("depth 0 returned nil") + } + if err := validateDriveListDepth(newCmd(), driveDepthMax+1); err == nil { + t.Fatal("depth above max returned nil") + } + + shallow := newCmd() + _ = shallow.Flags().Set("cursor", "c1") + _ = shallow.Flags().Set("limit", "5") + if err := validateDriveListDepth(shallow, 1); err != nil { + t.Fatalf("depth 1 with cursor/limit returned %v", err) + } + + withCursor := newCmd() + _ = withCursor.Flags().Set("cursor", "c1") + if err := validateDriveListDepth(withCursor, 2); err == nil { + t.Fatal("depth 2 with cursor returned nil") + } + + withNextToken := newCmd() + _ = withNextToken.Flags().Set("next-token", "t1") + if err := validateDriveListDepth(withNextToken, 2); err == nil { + t.Fatal("depth 2 with next-token returned nil") + } + + withLimit := newCmd() + _ = withLimit.Flags().Set("limit", "5") + if err := validateDriveListDepth(withLimit, 2); err == nil { + t.Fatal("depth 2 with limit returned nil") + } + + withMax := newCmd() + _ = withMax.Flags().Set("max", "5") + if err := validateDriveListDepth(withMax, 2); err == nil { + t.Fatal("depth 2 with max returned nil") + } + + if err := validateDriveListDepth(newCmd(), 2); err != nil { + t.Fatalf("depth 2 clean returned %v", err) + } + if err := validateDriveListDepth(newCmd(), driveDepthMax); err != nil { + t.Fatalf("depth max clean returned %v", err) + } +} + +func TestCrossPlatformCoverageDriveDepthRoutesBuildArgs(t *testing.T) { + pan := newDrivePanDepthRoute() + args := pan.buildArgs(map[string]any{"spaceId": "s1"}, "folder1", "tok") + if args["maxResults"] != float64(driveDepthPageSize) || args["spaceId"] != "s1" || + args["parentId"] != "folder1" || args["nextToken"] != "tok" { + t.Fatalf("pan args = %#v", args) + } + empty := pan.buildArgs(nil, "", "") + if _, ok := empty["parentId"]; ok { + t.Fatalf("pan empty folder args = %#v", empty) + } + if _, ok := empty["nextToken"]; ok { + t.Fatalf("pan empty token args = %#v", empty) + } + if id := pan.itemID(map[string]any{"fileId": "f1"}); id != "f1" { + t.Fatalf("pan itemID = %q", id) + } + if id := pan.itemID(map[string]any{}); id != "" { + t.Fatalf("pan missing itemID = %q", id) + } + + doc := newDocDepthRoute() + dargs := doc.buildArgs(map[string]any{"workspaceId": "w1"}, "node1", "pt") + if dargs["pageSize"] != float64(docDepthPageSize) || dargs["workspaceId"] != "w1" || + dargs["folderId"] != "node1" || dargs["pageToken"] != "pt" { + t.Fatalf("doc args = %#v", dargs) + } + dempty := doc.buildArgs(nil, "", "") + if _, ok := dempty["folderId"]; ok { + t.Fatalf("doc empty folder args = %#v", dempty) + } + if id := doc.itemID(map[string]any{"nodeId": "n1"}); id != "n1" { + t.Fatalf("doc itemID = %q", id) + } + if id := doc.itemID(map[string]any{}); id != "" { + t.Fatalf("doc missing itemID = %q", id) + } +} + +func TestCrossPlatformCoverageParseDriveDepthPage(t *testing.T) { + if items, next, hasMore := parseDriveDepthPage(`{invalid`); items != nil || next != "" || hasMore { + t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore) + } + if items, _, _ := parseDriveDepthPage(`{"foo":1}`); items != nil { + t.Fatalf("missing items key = %#v", items) + } + items, next, hasMore := parseDriveDepthPage(`{"items":[{"name":"a"},42],"nextToken":"t","hasMore":true}`) + if len(items) != 1 || items[0]["name"] != "a" || next != "t" || !hasMore { + t.Fatalf("top-level items = %#v %q %v", items, next, hasMore) + } + items, next, _ = parseDriveDepthPage(`{"result":{"dentryList":[{"name":"b"}],"nextToken":"t2"}}`) + if len(items) != 1 || items[0]["name"] != "b" || next != "t2" { + t.Fatalf("result dentryList = %#v %q", items, next) + } + if items, _, _ := parseDriveDepthPage(`{"result":{"other":1}}`); items != nil { + t.Fatalf("result without items = %#v", items) + } +} + +func TestCrossPlatformCoverageDriveDepthListItemsKey(t *testing.T) { + if k := driveDepthListItemsKey(map[string]any{"items": []any{}}); k != "items" { + t.Fatalf("items key = %q", k) + } + if k := driveDepthListItemsKey(map[string]any{"dentryList": []any{1}}); k != "dentryList" { + t.Fatalf("dentryList key = %q", k) + } + if k := driveDepthListItemsKey(map[string]any{"items": nil}); k != "" { + t.Fatalf("nil items key = %q", k) + } + if k := driveDepthListItemsKey(map[string]any{}); k != "" { + t.Fatalf("missing key = %q", k) + } +} + +func TestCrossPlatformCoverageParseDocDepthPage(t *testing.T) { + if items, next, hasMore := parseDocDepthPage(`{bad`); items != nil || next != "" || hasMore { + t.Fatalf("invalid JSON = %#v %q %v", items, next, hasMore) + } + items, next, hasMore := parseDocDepthPage(`{"nodes":[{"name":"a"},"x"],"nextPageToken":"nt","hasMore":true}`) + if len(items) != 1 || next != "nt" || !hasMore { + t.Fatalf("nodes = %#v %q %v", items, next, hasMore) + } +} + +func TestCrossPlatformCoverageMatchDriveNamePattern(t *testing.T) { + if !matchDriveNamePattern("anything", "") { + t.Fatal("empty pattern should match") + } + if !matchDriveNamePattern("report.xlsx", "*.xlsx") || matchDriveNamePattern("report.csv", "*.xlsx") { + t.Fatal("glob match failed") + } + if !matchDriveNamePattern("weekly-report.xlsx", "report") || matchDriveNamePattern("a.txt", "report") { + t.Fatal("substring wrap failed") + } + if !matchDriveNamePattern("a[b", "[") { + t.Fatal("invalid pattern should fall back to substring") + } +} + +func TestCrossPlatformCoverageDriveDepthItemClassifiers(t *testing.T) { + if !isDriveDepthFolder(map[string]any{"type": "FOLDER"}) { + t.Fatal("type FOLDER not detected") + } + if !isDriveDepthFolder(map[string]any{"dentryType": "folder"}) { + t.Fatal("dentryType folder not detected") + } + if isDriveDepthFolder(map[string]any{"type": "FILE"}) || isDriveDepthFolder(map[string]any{}) { + t.Fatal("non-folder detected as folder") + } + if !isDocDepthFolder(map[string]any{"nodeType": "Folder"}) { + t.Fatal("doc folder not detected") + } + if isDocDepthFolder(map[string]any{"nodeType": "doc"}) { + t.Fatal("doc detected as folder") + } + if id := driveDepthItemID(map[string]any{"fileId": "f"}); id != "f" { + t.Fatalf("fileId = %q", id) + } + if id := driveDepthItemID(map[string]any{"nodeId": "n"}); id != "n" { + t.Fatalf("nodeId = %q", id) + } + if id := driveDepthItemID(map[string]any{"fileId": ""}); id != "" { + t.Fatalf("empty = %q", id) + } +} + +func TestCrossPlatformCoverageDriveDepthUnrecoverable(t *testing.T) { + for _, code := range []string{CodeAuthTokenExpired, CodeAuthNotConfigured, CodeNetworkTimeout, CodeNetworkUnreachable} { + if !driveDepthUnrecoverable(&CLIError{Code: code}) { + t.Fatalf("%s not unrecoverable", code) + } + } + if driveDepthUnrecoverable(&CLIError{Code: CodeMCPToolError}) { + t.Fatal("tool error marked unrecoverable") + } + if driveDepthUnrecoverable(errors.New("plain")) { + t.Fatal("plain error marked unrecoverable") + } +} + +func TestCrossPlatformCoverageDriveDepthErrorCode(t *testing.T) { + if c := driveDepthErrorCode(errors.New("plain")); c != "" { + t.Fatalf("plain = %q", c) + } + if c := driveDepthErrorCode(&CLIError{Message: "not json"}); c != "" { + t.Fatalf("non-JSON = %q", c) + } + for _, tc := range []struct{ body, want string }{ + {`{"errorCode":"a.b"}`, "a.b"}, + {`{"error_code":"c.d"}`, "c.d"}, + {`{"code":"e.f"}`, "e.f"}, + {`{"errorCode":""}`, ""}, + } { + if c := driveDepthErrorCode(&CLIError{Message: tc.body}); c != tc.want { + t.Fatalf("%s = %q, want %q", tc.body, c, tc.want) + } + } +} + +func TestCrossPlatformCoverageClassifyDriveDepthReason(t *testing.T) { + if r := classifyDriveDepthReason(driveDepthRateLimitedCode); r != "rate_limited" { + t.Fatalf("rate limit = %q", r) + } + if r := classifyDriveDepthReason("forbidden.noPermission"); r != "permission_denied" { + t.Fatalf("forbidden = %q", r) + } + if r := classifyDriveDepthReason("other.x"); r != "api_error" { + t.Fatalf("other = %q", r) + } +} + +func TestCrossPlatformCoverageDriveDepthErrorMessage(t *testing.T) { + if m := driveDepthErrorMessage(&CLIError{Message: `{"errorMsg":"boom"}`}); m != "boom" { + t.Fatalf("errorMsg = %q", m) + } + if m := driveDepthErrorMessage(&CLIError{Message: `{"message":"m2"}`}); m != "m2" { + t.Fatalf("message = %q", m) + } + if m := driveDepthErrorMessage(&CLIError{Message: "raw text"}); m != "raw text" { + t.Fatalf("raw = %q", m) + } + if m := driveDepthErrorMessage(errors.New("plain boom")); m != "plain boom" { + t.Fatalf("plain = %q", m) + } +} + +func TestCrossPlatformCoverageNewDriveDepthError(t *testing.T) { + folder := driveDepthFolder{id: "f1", name: "folder one", depth: 2} + derr := newDriveDepthError(folder, &CLIError{Code: CodeMCPToolError, Message: `{"errorCode":"forbidden.x","errorMsg":"denied"}`}) + if derr.Depth != 2 || derr.FolderID != "f1" || derr.FolderName != "folder one" || + derr.Reason != "permission_denied" || derr.Message != "denied" { + t.Fatalf("driveDepthError = %#v", derr) + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } + +func installDepthCaller(t *testing.T, caller *scriptedToolCaller) *bytes.Buffer { + t.Helper() + installScriptedCaller(t, caller) + buf := &bytes.Buffer{} + deps.Out.w = buf + return buf +} + +func decodeDepthResult(t *testing.T, buf *bytes.Buffer) map[string]any { + t.Helper() + var out map[string]any + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("output is not JSON: %v\n%s", err, buf.String()) + } + return out +} + +func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) { + out := installDepthCaller(t, &scriptedToolCaller{}) + items := []map[string]any{ + {"fileName": "b-file.xlsx", "rel_path": "b", "depth": 1, "fileId": "f2"}, + {"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"}, + {"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"}, + } + if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil { + t.Fatal(err) + } + result := decodeDepthResult(t, out) + got := result["items"].([]any) + if len(got) != 2 { + t.Fatalf("filtered items = %#v", got) + } + if got[0].(map[string]any)["rel_path"] != "a" || got[1].(map[string]any)["rel_path"] != "b" { + t.Fatalf("sort order = %#v", got) + } + if result["maxDepth"] != float64(2) { + t.Fatalf("maxDepth = %#v", result["maxDepth"]) + } + if result["truncated"] != false { + t.Fatalf("truncated = %#v", result["truncated"]) + } + + out.Reset() + if err := emitDriveDepthResult(nil, nil, false, ""); err != nil { + t.Fatal(err) + } + result = decodeDepthResult(t, out) + if items, ok := result["items"].([]any); !ok || len(items) != 0 { + t.Fatalf("nil items = %#v", result["items"]) + } + + samePath := []map[string]any{ + {"name": "dup", "rel_path": "p/dup", "fileId": "z9"}, + {"name": "dup", "rel_path": "p/dup", "fileId": "a1"}, + } + out.Reset() + if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil { + t.Fatal(err) + } + got = decodeDepthResult(t, out)["items"].([]any) + if got[0].(map[string]any)["fileId"] != "a1" { + t.Fatalf("tie-break order = %#v", got) + } + + deps.Out.w = failingWriter{} + if err := emitDriveDepthResult(nil, nil, false, ""); err == nil { + t.Fatal("failing writer returned nil") + } +} + +func TestCrossPlatformCoveragePrintDriveDepthDryRun(t *testing.T) { + jsonOut := installDepthCaller(t, &scriptedToolCaller{format: "json", dry: true}) + if err := printDriveDepthDryRun(newDrivePanDepthRoute(), map[string]any{"spaceId": "s1"}, 3); err != nil { + t.Fatal(err) + } + var payload map[string]any + if err := json.Unmarshal(jsonOut.Bytes(), &payload); err != nil { + t.Fatalf("json dry-run output: %v", err) + } + if payload["dry_run"] != true || payload["tool"] != "list_files" || payload["maxDepth"] != float64(3) { + t.Fatalf("json dry-run = %#v", payload) + } + + installDepthCaller(t, &scriptedToolCaller{format: "table", dry: true}) + if err := printDriveDepthDryRun(newDocDepthRoute(), map[string]any{"workspaceId": "w1"}, 2); err != nil { + t.Fatal(err) + } +} + +func useDriveDepthArgs(t *testing.T) { + t.Helper() + old := os.Args + os.Args = []string{"dws", "drive", "list", "--depth", "2"} + t.Cleanup(func() { os.Args = old }) +} + +func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute, root string, maxDepth int, pattern string) (map[string]any, error) { + t.Helper() + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true) + return decodeDepthResult(t, out), err +} + +func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) { + caller := &scriptedToolCaller{format: "json", dry: true} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil { + t.Fatal(err) + } + if caller.calls != 0 { + t.Fatalf("dry-run calls = %d", caller.calls) + } + var payload map[string]any + if err := json.Unmarshal(out.Bytes(), &payload); err != nil { + t.Fatalf("dry-run output: %v", err) + } + if payload["dry_run"] != true { + t.Fatalf("payload = %#v", payload) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`}, + {text: `{"items":[{"fileId":"fY","name":"y.txt","type":"FILE"}]}`}, + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil { + t.Fatal(err) + } + if caller.calls != 2 { + t.Fatalf("calls = %d, want 2", caller.calls) + } + items := decodeDepthResult(t, out)["items"].([]any) + if len(items) != 3 { + t.Fatalf("items = %#v", items) + } + var dirA, nested map[string]any + for _, raw := range items { + item := raw.(map[string]any) + switch item["rel_path"] { + case "dirA": + dirA = item + case "dirA/y.txt": + nested = item + } + } + if dirA == nil || nested == nil { + t.Fatalf("rel_path tree = %#v", items) + } + if dirA["depth"] != float64(1) || dirA["parentId"] != "" { + t.Fatalf("root item = %#v", dirA) + } + if nested["depth"] != float64(2) || nested["parentId"] != "fA" { + t.Fatalf("nested item = %#v", nested) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthMaxDepthSkipsEnqueue(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`}, + }} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 1, "") + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1", caller.calls) + } + if len(result["items"].([]any)) != 1 { + t.Fatalf("items = %#v", result["items"]) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthDedup(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[ + {"fileId":"fA","name":"dirA","type":"FOLDER"}, + {"fileId":"fA","name":"dirA-copy","type":"FOLDER"}, + {"fileId":"","name":"no-id","type":"FOLDER"}, + {"fileId":"fB","name":"dirB","type":"FOLDER"}]}`}, + {text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`}, + {text: `{"items":[{"fileId":"f2","name":"b.txt","type":"FILE"}]}`}, + }} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "") + if err != nil { + t.Fatal(err) + } + if caller.calls != 3 { + t.Fatalf("calls = %d, want 3 (dedup + empty id skipped)", caller.calls) + } + if len(result["items"].([]any)) != 6 { + t.Fatalf("items = %#v", result["items"]) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthDocRouteAnomaly(t *testing.T) { + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[{"nodeId":"nB","name":"folderB","nodeType":"folder"},{"nodeId":"nD","name":"doc1","nodeType":"doc"}],"hasMore":false,"nextPageToken":"ignored"}`}, + {text: `{"nodes":[{"nodeId":"nE","name":"inner","nodeType":"doc"}],"hasMore":true}`}, + }} + result, err := runDepthBFS(t, caller, newDocDepthRoute(), "root1", 3, "") + if err != nil { + t.Fatal(err) + } + if caller.calls != 2 { + t.Fatalf("calls = %d, want 2 (hasMore=false stops authoritative route)", caller.calls) + } + errs := result["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("errors = %#v", result["errors"]) + } + entry := errs[0].(map[string]any) + if entry["folderId"] != "nB" || entry["reason"] != "api_error" { + t.Fatalf("anomaly entry = %#v", entry) + } + if !strings.Contains(entry["message"].(string), "hasMore=true but nextToken is empty") { + t.Fatalf("anomaly message = %#v", entry) + } + if len(result["items"].([]any)) != 3 { + t.Fatalf("items = %#v", result["items"]) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthRateLimitRetry(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`}, + {text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`}, + {text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`}, + }} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "") + if err != nil { + t.Fatal(err) + } + if caller.calls != 3 { + t.Fatalf("calls = %d, want 3", caller.calls) + } + if errs := result["errors"].([]any); len(errs) != 0 { + t.Fatalf("errors = %#v", errs) + } + if len(result["items"].([]any)) != 2 { + t.Fatalf("items = %#v", result["items"]) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthRateLimitExhausted(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"}]}`}, + {text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`}, + }} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "") + if err != nil { + t.Fatal(err) + } + if caller.calls != 3 { + t.Fatalf("calls = %d, want 3 (one retry)", caller.calls) + } + errs := result["errors"].([]any) + if len(errs) != 1 { + t.Fatalf("errors = %#v", errs) + } + entry := errs[0].(map[string]any) + if entry["reason"] != "rate_limited" || entry["folderId"] != "fA" { + t.Fatalf("rate limit entry = %#v", entry) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`}, + }} + installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil { + t.Fatal("root failure returned nil") + } + if caller.calls != 2 { + t.Fatalf("calls = %d, want 2 (single retry then fail)", caller.calls) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`}, + {text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`}, + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil { + t.Fatal("unrecoverable returned nil") + } + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired { + t.Fatalf("err = %T %v", err, err) + } + result := decodeDepthResult(t, out) + if len(result["items"].([]any)) != 2 { + t.Fatalf("partial items = %#v", result["items"]) + } + if len(result["errors"].([]any)) != 1 { + t.Fatalf("errors = %#v", result["errors"]) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`}, + }} + installDepthCaller(t, caller) + deps.Out.w = failingWriter{} + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("err = %v, want emit failure", err) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[],"nextToken":"t","hasMore":true}`}, + }} + installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") { + t.Fatalf("err = %v, want pagination anomaly", err) + } + maxPages := driveDepthMaxItems/driveDepthPageSize + 1 + if caller.calls != maxPages { + t.Fatalf("calls = %d, want %d", caller.calls, maxPages) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthTruncation(t *testing.T) { + useDriveDepthArgs(t) + var sb strings.Builder + sb.WriteString(`{"items":[`) + for i := 0; i < driveDepthMaxItems; i++ { + if i > 0 { + sb.WriteString(",") + } + fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE"}`, i, i) + } + sb.WriteString(`]}`) + caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "") + if err != nil { + t.Fatal(err) + } + if result["truncated"] != true { + t.Fatalf("truncated = %#v", result["truncated"]) + } + if len(result["items"].([]any)) != driveDepthMaxItems { + t.Fatalf("items len = %d", len(result["items"].([]any))) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{} + out := installDepthCaller(t, caller) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd := &cobra.Command{Use: "list"} + cmd.SetContext(ctx) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + var cancelErr *driveDepthCancelledError + if !errors.As(err, &cancelErr) { + t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) + } + result := decodeDepthResult(t, out) + if result["truncated"] != true { + t.Fatalf("cancelled result = %#v", result) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{} + installDepthCaller(t, caller) + deps.Out.w = failingWriter{} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd := &cobra.Command{Use: "list"} + cmd.SetContext(ctx) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("err = %v, want emit failure", err) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`}, + }} + installDepthCaller(t, caller) + deps.Out.w = failingWriter{} + cmd := &cobra.Command{Use: "list"} + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + if err == nil || !strings.Contains(err.Error(), "write failed") { + t.Fatalf("err = %v, want emit failure", err) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthPatternFilter(t *testing.T) { + useDriveDepthArgs(t) + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE"}]}`}, + {text: `{"items":[{"fileId":"fY","name":"keep.xlsx","type":"FILE"}]}`}, + }} + result, err := runDepthBFS(t, caller, newDrivePanDepthRoute(), "", 3, "*.xlsx") + if err != nil { + t.Fatal(err) + } + items := result["items"].([]any) + if len(items) != 1 || items[0].(map[string]any)["name"] != "keep.xlsx" { + t.Fatalf("filtered = %#v", items) + } +} + +type cancelOnCallCaller struct { + scriptedToolCaller + cancel context.CancelFunc +} + +func (c *cancelOnCallCaller) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.cancel() + return c.scriptedToolCaller.CallTool(ctx, productID, toolName, args) +} + +func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *testing.T) { + useDriveDepthArgs(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + route := newDrivePanDepthRoute() + baseParse := route.parsePage + route.parsePage = func(text string) ([]map[string]any, string, bool) { + cancel() + return baseParse(text) + } + caller := &scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}],"nextToken":"more"}`}, + }} + out := installDepthCaller(t, caller) + cmd := &cobra.Command{Use: "list"} + cmd.SetContext(ctx) + err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true) + var cancelErr *driveDepthCancelledError + if !errors.As(err, &cancelErr) { + t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) + } + result := decodeDepthResult(t, out) + if len(result["items"].([]any)) != 1 || result["truncated"] != true { + t.Fatalf("cancelled partial = %#v", result) + } +} + +func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T) { + useDriveDepthArgs(t) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + caller := &cancelOnCallCaller{ + scriptedToolCaller: scriptedToolCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"fX","name":"x.txt","type":"FILE"}]}`}, + }}, + cancel: cancel, + } + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + out := &bytes.Buffer{} + deps.Out.w = out + deps.Out.errW = io.Discard + cmd := &cobra.Command{Use: "list"} + cmd.SetContext(ctx) + err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true) + var cancelErr *driveDepthCancelledError + if !errors.As(err, &cancelErr) { + t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err) + } + result := decodeDepthResult(t, out) + if result["truncated"] != true { + t.Fatalf("cancelled result = %#v", result) + } +} diff --git a/internal/helpers/drive_list_modes_test.go b/internal/helpers/drive_list_modes_test.go new file mode 100644 index 00000000..39b7c6b8 --- /dev/null +++ b/internal/helpers/drive_list_modes_test.go @@ -0,0 +1,215 @@ +package helpers + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type depthArgsRecordingCaller struct { + steps []scriptedToolStep + index int + calls []map[string]any +} + +func (c *depthArgsRecordingCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, args) + index := c.index + if index >= len(c.steps) { + index = len(c.steps) - 1 + } + c.index++ + return textToolResult(c.steps[index].text), nil +} + +func (*depthArgsRecordingCaller) Format() string { return "json" } +func (*depthArgsRecordingCaller) DryRun() bool { return false } +func (*depthArgsRecordingCaller) Fields() string { return "" } +func (*depthArgsRecordingCaller) JQ() string { return "" } + +func executeDriveCommand(t *testing.T, caller edition.ToolCaller, args ...string) error { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + InitDeps(caller) + deps.Out.w = io.Discard + deps.Out.errW = io.Discard + root := newDriveCommand() + root.SilenceErrors = true + root.SilenceUsage = true + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageDriveListVersionsMode(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "list", "--versions", "--node", "node-1", "--limit", "5", "--cursor", "cur-1") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v", caller.calls) + } + call := caller.calls[0] + if call.productID != "drive" || call.toolName != "list_file_versions" { + t.Fatalf("call = %#v", call) + } + if call.args["nodeId"] != "node-1" || call.args["maxResults"] != 5 || call.args["nextCursor"] != "cur-1" { + t.Fatalf("args = %#v", call.args) + } +} + +func TestCrossPlatformCoverageDriveListVersionsModeRequiresNode(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, "list", "--versions") + if err == nil { + t.Fatal("versions without --node returned nil") + } + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageDriveListWorkspaceDepthRoutesDocBFS(t *testing.T) { + caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{ + {text: `{"nodes":[{"nodeId":"n1","name":"doc1","nodeType":"doc"}],"hasMore":false}`}, + }} + err := executeDriveCommand(t, caller, + "list", "--workspace", "ws-1", "--depth", "2", "--node", "folder-1") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want 1", caller.calls) + } + args := caller.calls[0] + if args["workspaceId"] != "ws-1" || args["folderId"] != "folder-1" || args["pageSize"] != float64(docDepthPageSize) { + t.Fatalf("doc BFS args = %#v", args) + } +} + +func TestCrossPlatformCoverageDriveListWorkspaceDepthRejectsNumericFolder(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "list", "--workspace", "ws-1", "--depth", "2", "--node", "12345") + if err == nil { + t.Fatal("numeric doc folder returned nil") + } +} + +func TestCrossPlatformCoverageDriveListPanDepthBuildsBaseArgs(t *testing.T) { + useDriveDepthArgs(t) + caller := &depthArgsRecordingCaller{steps: []scriptedToolStep{ + {text: `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE"}]}`}, + }} + err := executeDriveCommand(t, caller, + "list", "--depth", "2", "--space-id", "sp-1", "--order-by", "name", "--order", "asc", "--thumbnail", "--folder", "root-folder") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want 1", caller.calls) + } + args := caller.calls[0] + if args["spaceId"] != "sp-1" || args["orderBy"] != "name" || args["order"] != "asc" || + args["withThumbnail"] != true || args["parentId"] != "root-folder" || args["maxResults"] != float64(driveDepthPageSize) { + t.Fatalf("pan BFS args = %#v", args) + } +} + +func TestCrossPlatformCoverageDriveListPanDepthRejectsNumericFolder(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "list", "--depth", "2", "--folder", "12345") + if err == nil { + t.Fatal("numeric drive folder returned nil") + } +} + +func TestCrossPlatformCoverageDriveTransferOwnerDryRun(t *testing.T) { + caller := &guardedMutationCaller{dryRun: true} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--dry-run") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageDriveTransferOwnerYesRequiresRecursive(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--yes") + if err == nil || !strings.Contains(err.Error(), "--recursive is required") { + t.Fatalf("err = %v, want --recursive required", err) + } +} + +func TestCrossPlatformCoverageDriveTransferOwnerWorkspaceTarget(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, + "permission", "transfer-owner", "--workspace", "ws-1", "--new-owner", "user-1", "--reserve-role", "EDITOR", "--recursive", "--yes") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v", caller.calls) + } + call := caller.calls[0] + if call.productID != "doc" || call.toolName != "transfer_owner" { + t.Fatalf("call = %#v", call) + } + if call.args["workspaceId"] != "ws-1" || call.args["newOwnerId"] != "user-1" || + call.args["reserveOldOwnerRole"] != "EDITOR" || call.args["recursiveChange"] != true { + t.Fatalf("args = %#v", call.args) + } + if _, ok := call.args["nodeId"]; ok { + t.Fatalf("unexpected nodeId in %#v", call.args) + } +} + +func TestCrossPlatformCoverageDriveTransferOwnerDeclined(t *testing.T) { + caller := &guardedMutationCaller{} + root := newDriveCommand() + root.SetIn(strings.NewReader("no\n")) + err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root }, + "permission", "transfer-owner", "--node", "node-1", "--new-owner", "user-1") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("declined calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageDriveCoverRequiresNode(t *testing.T) { + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newDriveCommand, "cover") + if err == nil { + t.Fatal("cover without --node returned nil") + } + if len(caller.calls) != 0 { + t.Fatalf("calls = %#v", caller.calls) + } +} + +func TestCrossPlatformCoverageDriveRevertDeclined(t *testing.T) { + caller := &guardedMutationCaller{} + root := newDriveCommand() + root.SetIn(strings.NewReader("no\n")) + err := executeGuardedMutationCommand(t, caller, func() *cobra.Command { return root }, + "revert", "--node", "node-1", "--version", "3") + if err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("declined revert calls = %#v", caller.calls) + } +} diff --git a/internal/helpers/sheet_formula_verify_test.go b/internal/helpers/sheet_formula_verify_test.go new file mode 100644 index 00000000..15b9e489 --- /dev/null +++ b/internal/helpers/sheet_formula_verify_test.go @@ -0,0 +1,91 @@ +package helpers + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func executeFormulaVerify(t *testing.T, caller *scriptedToolCaller, stdin *strings.Reader, args ...string) error { + t.Helper() + installScriptedCaller(t, caller) + cmd := newSheetFormulaVerifyCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + if stdin != nil { + cmd.SetIn(stdin) + } + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestCrossPlatformCoverageSheetFormulaVerifyRejectsNonPositiveLimits(t *testing.T) { + if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil, + "--node", "n1", "--max-locations-per-error", "0"); err == nil { + t.Fatal("max-locations-per-error 0 returned nil") + } + if err := executeFormulaVerify(t, &scriptedToolCaller{}, nil, + "--node", "n1", "--max-cells", "-1"); err == nil { + t.Fatal("max-cells -1 returned nil") + } +} + +func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeFormulaVerify(t, caller, nil, + "--node", "n1", "--max-locations-per-error", "3", "--max-cells", "100", + "--targets", `[{"sheetId":"Sheet1","range":"A1:D10"}]`) + if err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "targets.json") + if err := os.WriteFile(path, []byte(`[{"sheetId":"Sheet1"}]`), 0o600); err != nil { + t.Fatal(err) + } + caller := &scriptedToolCaller{} + if err := executeFormulaVerify(t, caller, nil, "--node", "n1", "--targets", "@"+path); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFileMissing(t *testing.T) { + err := executeFormulaVerify(t, &scriptedToolCaller{}, nil, + "--node", "n1", "--targets", "@/nonexistent/targets.json") + if err == nil || !strings.Contains(err.Error(), "读取 --targets 文件失败") { + t.Fatalf("err = %v, want file read failure", err) + } +} + +func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromStdin(t *testing.T) { + caller := &scriptedToolCaller{} + if err := executeFormulaVerify(t, caller, strings.NewReader(`[{"sheetId":"S1"}]`), + "--node", "n1", "--targets", "-"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("calls = %d, want 1", caller.calls) + } +} + +func TestCrossPlatformCoverageSheetFormulaVerifyTargetsStdinFailure(t *testing.T) { + installScriptedCaller(t, &scriptedToolCaller{}) + cmd := newSheetFormulaVerifyCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetIn(failingReader{}) + cmd.SetArgs([]string{"--node", "n1", "--targets", "-"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") { + t.Fatalf("err = %v, want stdin failure", err) + } +}