Outside --dry-run, list_doc_versions uses the normal CallTool channel; CallReadTool is reserved for the dry-run read path. Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
14 KiB
Go
391 lines
14 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type contractDefectCaller struct {
|
|
dryRun bool
|
|
calls []guardedMutationCall
|
|
readCalls []guardedMutationCall
|
|
responses map[string]string
|
|
errors map[string]error
|
|
}
|
|
|
|
func (c *contractDefectCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
|
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
|
|
key := productID + "/" + toolName
|
|
if err := c.errors[key]; err != nil {
|
|
return nil, err
|
|
}
|
|
text := c.responses[key]
|
|
if text == "" {
|
|
text = `{}`
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
|
}
|
|
|
|
func (c *contractDefectCaller) CallReadTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
|
c.readCalls = append(c.readCalls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
|
|
key := productID + "/" + toolName
|
|
if err := c.errors[key]; err != nil {
|
|
return nil, err
|
|
}
|
|
text := c.responses[key]
|
|
if text == "" {
|
|
text = `{}`
|
|
}
|
|
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
|
}
|
|
|
|
func (*contractDefectCaller) Format() string { return "json" }
|
|
func (c *contractDefectCaller) DryRun() bool { return c.dryRun }
|
|
func (*contractDefectCaller) Fields() string { return "" }
|
|
func (*contractDefectCaller) JQ() string { return "" }
|
|
|
|
func executeContractDefectCommand(t *testing.T, caller *contractDefectCaller, build func() *cobra.Command, args ...string) (string, error) {
|
|
t.Helper()
|
|
previousDeps := deps
|
|
t.Cleanup(func() { deps = previousDeps })
|
|
|
|
InitDeps(caller)
|
|
var stdout bytes.Buffer
|
|
deps.Out.w = &stdout
|
|
deps.Out.errW = io.Discard
|
|
|
|
root := build()
|
|
if root.PersistentFlags().Lookup("yes") == nil {
|
|
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
|
}
|
|
if root.PersistentFlags().Lookup("dry-run") == nil {
|
|
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
|
}
|
|
root.SilenceErrors = true
|
|
root.SilenceUsage = true
|
|
root.SetIn(strings.NewReader(""))
|
|
root.SetArgs(args)
|
|
err := root.Execute()
|
|
return stdout.String(), err
|
|
}
|
|
|
|
func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
|
|
caller := &contractDefectCaller{dryRun: true}
|
|
output, err := executeContractDefectCommand(t, caller, newOaCommand,
|
|
"approval", "revoke", "--instance-id", "instance-dry-run", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("approval revoke dry-run returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
|
|
}
|
|
if !strings.Contains(output, `"tool": "revoke_processInstance"`) ||
|
|
!strings.Contains(output, `"processInstanceId": "instance-dry-run"`) {
|
|
t.Fatalf("dry-run output = %q, want revoke preview", output)
|
|
}
|
|
}
|
|
|
|
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
|
|
caller := &contractDefectCaller{dryRun: true}
|
|
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
|
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("doc version revert dry-run returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
|
|
}
|
|
if len(caller.readCalls) != 0 {
|
|
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
|
|
}
|
|
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
|
|
!strings.Contains(output, `"version": 7`) {
|
|
t.Fatalf("dry-run output = %q, want version revert preview", output)
|
|
}
|
|
}
|
|
|
|
func TestDocVersionRevertDryRunDoesNotRejectMissingVersionRemotely(t *testing.T) {
|
|
caller := &contractDefectCaller{dryRun: true}
|
|
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
|
"version", "revert", "--node", "node-dry-run", "--version", "999", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("doc version revert dry-run returned error: %v", err)
|
|
}
|
|
if len(caller.readCalls) != 0 {
|
|
t.Fatalf("dry-run read calls = %#v, want none (no remote version preflight)", caller.readCalls)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
|
|
}
|
|
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
|
|
!strings.Contains(output, `"version": 999`) {
|
|
t.Fatalf("dry-run output = %q, want preview without remote missing-version rejection", output)
|
|
}
|
|
}
|
|
|
|
func TestDocVersionRevertNonDryRunPreflightsVersion(t *testing.T) {
|
|
caller := &contractDefectCaller{
|
|
responses: map[string]string{
|
|
"doc/list_doc_versions": `{"versions":[{"version":7}]}`,
|
|
"doc/revert_doc_version": `{}`,
|
|
},
|
|
}
|
|
output, err := executeContractDefectCommand(t, caller, newDocCommand,
|
|
"version", "revert", "--node", "node-live", "--version", "7", "--yes")
|
|
if err != nil {
|
|
t.Fatalf("doc version revert returned error: %v", err)
|
|
}
|
|
// Outside --dry-run, list_doc_versions rides the normal CallTool channel
|
|
// (CallReadTool is dry-run-only). Expect preflight then mutation.
|
|
if len(caller.calls) != 2 ||
|
|
caller.calls[0].productID != "doc" || caller.calls[0].toolName != "list_doc_versions" ||
|
|
caller.calls[1].productID != "doc" || caller.calls[1].toolName != "revert_doc_version" {
|
|
t.Fatalf("non-dry-run calls = %#v, want list_doc_versions then revert_doc_version", caller.calls)
|
|
}
|
|
if strings.Contains(output, `"dry_run": true`) {
|
|
t.Fatalf("non-dry-run output unexpectedly previewed: %q", output)
|
|
}
|
|
}
|
|
|
|
func TestDocVersionRevertPublishesRuntimeSafety(t *testing.T) {
|
|
cmd, remaining, err := newDocCommand().Find([]string{"version", "revert"})
|
|
if err != nil || len(remaining) != 0 {
|
|
t.Fatalf("find doc version revert: command=%v remaining=%v err=%v", cmd, remaining, err)
|
|
}
|
|
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
|
if !ok || final.Safety == nil {
|
|
t.Fatal("doc version revert must publish ContractFinal Safety")
|
|
}
|
|
if safety := *final.Safety; safety.Effect != "write" || safety.Risk != "medium" ||
|
|
safety.Confirmation != "user_required" || safety.Idempotency != "unknown" {
|
|
t.Fatalf("doc version revert Safety = %#v, want write/medium/user_required/unknown", safety)
|
|
}
|
|
}
|
|
|
|
func TestDriveDeleteDryRunSkipsConfirmationAndEOFIsObservable(t *testing.T) {
|
|
caller := &contractDefectCaller{dryRun: true}
|
|
output, err := executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"delete", "--node", "node-dry-run", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("drive delete dry-run returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("dry-run mutation calls = %#v, want none", caller.calls)
|
|
}
|
|
if !strings.Contains(output, `"tool": "delete_document"`) ||
|
|
!strings.Contains(output, `"nodeId": "node-dry-run"`) {
|
|
t.Fatalf("dry-run output = %q, want delete preview", output)
|
|
}
|
|
|
|
caller = &contractDefectCaller{}
|
|
_, err = executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"delete", "--node", "node-eof")
|
|
var appErr *apperrors.Error
|
|
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
|
t.Fatalf("closed-stdin error = %#v, want typed confirmation_required", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("closed-stdin mutation calls = %#v, want none", caller.calls)
|
|
}
|
|
}
|
|
|
|
func TestDocRenamePreservesCallerProvidedDisplayName(t *testing.T) {
|
|
renameCmd, remaining, err := newDocCommand().Find([]string{"rename"})
|
|
if err != nil || len(remaining) != 0 {
|
|
t.Fatalf("find doc rename: command=%v remaining=%v err=%v", renameCmd, remaining, err)
|
|
}
|
|
nameFlag := renameCmd.Flags().Lookup("name")
|
|
if nameFlag == nil {
|
|
t.Fatal("doc rename --name flag is missing")
|
|
}
|
|
if !strings.Contains(nameFlag.Usage, "原样传给服务端") ||
|
|
!strings.Contains(nameFlag.Usage, "drive rename") ||
|
|
strings.Contains(nameFlag.Usage, "自动去掉") {
|
|
t.Fatalf("doc rename --name usage = %q, want verbatim forwarding and drive rename routing", nameFlag.Usage)
|
|
}
|
|
|
|
caller := &contractDefectCaller{}
|
|
if _, err = executeContractDefectCommand(t, caller, newDocCommand,
|
|
"rename", "--node", "node-1", "--name", "release.v2"); err != nil {
|
|
t.Fatalf("doc rename returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 1 {
|
|
t.Fatalf("tool calls = %#v, want one rename call", caller.calls)
|
|
}
|
|
if got := caller.calls[0].args["newName"]; got != "release.v2" {
|
|
t.Fatalf("newName = %#v, want release.v2", got)
|
|
}
|
|
}
|
|
|
|
func TestDriveRenameUsesNodeTypeAndCurrentExtension(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
metadata string
|
|
nodeID string
|
|
wantFileID string
|
|
requested string
|
|
wantNewName string
|
|
}{
|
|
{
|
|
name: "file strips matching extension outside old whitelist",
|
|
metadata: `{"result":{"type":"file","extension":"heic"}}`,
|
|
nodeID: "https://alidocs.dingtalk.com/i/nodes/node-1",
|
|
wantFileID: "node-1",
|
|
requested: "photo.HEIC",
|
|
wantNewName: "photo",
|
|
},
|
|
{
|
|
name: "folder preserves dotted display name",
|
|
metadata: `{"result":{"type":"folder","extension":"v2"}}`,
|
|
requested: "release.v2",
|
|
wantNewName: "release.v2",
|
|
},
|
|
{
|
|
name: "different current extension is preserved",
|
|
metadata: `{"result":{"nodeType":"file","fileExtension":"txt"}}`,
|
|
requested: "report.final",
|
|
wantNewName: "report.final",
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := &contractDefectCaller{responses: map[string]string{
|
|
"drive/get_file_info": test.metadata,
|
|
}}
|
|
nodeID := test.nodeID
|
|
if nodeID == "" {
|
|
nodeID = "node-1"
|
|
}
|
|
if _, err := executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"rename", "--node", nodeID, "--name", test.requested); err != nil {
|
|
t.Fatalf("drive rename returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 2 {
|
|
t.Fatalf("tool calls = %#v, want metadata read plus rename", caller.calls)
|
|
}
|
|
if caller.calls[0].productID != "drive" || caller.calls[0].toolName != "get_file_info" {
|
|
t.Fatalf("first call = %#v, want drive/get_file_info", caller.calls[0])
|
|
}
|
|
wantFileID := test.wantFileID
|
|
if wantFileID == "" {
|
|
wantFileID = nodeID
|
|
}
|
|
if got := caller.calls[0].args["fileId"]; got != wantFileID {
|
|
t.Fatalf("metadata fileId = %#v, want %q", got, wantFileID)
|
|
}
|
|
if caller.calls[1].productID != "doc" || caller.calls[1].toolName != "rename_document" {
|
|
t.Fatalf("second call = %#v, want doc/rename_document", caller.calls[1])
|
|
}
|
|
if got := caller.calls[1].args["newName"]; got != test.wantNewName {
|
|
t.Fatalf("newName = %#v, want %q", got, test.wantNewName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDriveRenameMetadataFailuresDoNotMutate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
responses map[string]string
|
|
errors map[string]error
|
|
}{
|
|
{
|
|
name: "metadata call error",
|
|
errors: map[string]error{"drive/get_file_info": errors.New("metadata unavailable")},
|
|
},
|
|
{
|
|
name: "invalid metadata JSON",
|
|
responses: map[string]string{"drive/get_file_info": `{`},
|
|
},
|
|
{
|
|
name: "metadata result missing",
|
|
responses: map[string]string{"drive/get_file_info": `{}`},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
caller := &contractDefectCaller{responses: test.responses, errors: test.errors}
|
|
_, err := executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"rename", "--node", "node-1", "--name", "report.txt")
|
|
if err == nil {
|
|
t.Fatal("drive rename returned nil error")
|
|
}
|
|
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_file_info" {
|
|
t.Fatalf("tool calls = %#v, want metadata read only", caller.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDriveRenameDryRunDefersMetadataNormalization(t *testing.T) {
|
|
caller := &contractDefectCaller{dryRun: true}
|
|
output, err := executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"rename", "--node", "node-1", "--name", "photo.heic", "--dry-run")
|
|
if err != nil {
|
|
t.Fatalf("drive rename dry-run returned error: %v", err)
|
|
}
|
|
if len(caller.calls) != 0 {
|
|
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
|
|
}
|
|
if !strings.Contains(output, `"newName": "photo.heic"`) {
|
|
t.Fatalf("dry-run output = %q, want unmodified input name", output)
|
|
}
|
|
}
|
|
|
|
func TestDriveRenameBaseNameConservativeEdges(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
nodeType string
|
|
extension string
|
|
want string
|
|
}{
|
|
{name: ".txt", nodeType: "file", extension: "txt", want: ".txt"},
|
|
{name: "report.txt", nodeType: "directory", extension: "txt", want: "report.txt"},
|
|
{name: "report.txt", nodeType: "dir", extension: "txt", want: "report.txt"},
|
|
{name: " report.txt ", nodeType: "file", extension: "", want: "report.txt"},
|
|
{name: "report.txt", nodeType: "file", extension: ".txt", want: "report"},
|
|
}
|
|
for _, test := range tests {
|
|
if got := driveRenameBaseName(test.name, test.nodeType, test.extension); got != test.want {
|
|
t.Fatalf("driveRenameBaseName(%q, %q, %q) = %q, want %q",
|
|
test.name, test.nodeType, test.extension, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDriveInfoRestoresFileSizeWhenDocMetadataIsNull(t *testing.T) {
|
|
caller := &contractDefectCaller{responses: map[string]string{
|
|
"drive/get_file_info": `{"result":{"fileId":"node-1","extension":"adoc","fileSize":93682}}`,
|
|
"doc/get_document_info": `{"result":{"title":"Doc","fileSize":null}}`,
|
|
}}
|
|
output, err := executeContractDefectCommand(t, caller, newDriveCommand,
|
|
"info", "--node", "node-1")
|
|
if err != nil {
|
|
t.Fatalf("drive info returned error: %v", err)
|
|
}
|
|
var payload struct {
|
|
Result struct {
|
|
FileSize int64 `json:"fileSize"`
|
|
} `json:"result"`
|
|
}
|
|
if err := json.Unmarshal([]byte(output), &payload); err != nil {
|
|
t.Fatalf("decode drive info output %q: %v", output, err)
|
|
}
|
|
if payload.Result.FileSize != 93682 {
|
|
t.Fatalf("fileSize = %d, want 93682", payload.Result.FileSize)
|
|
}
|
|
}
|