Files
dingtalk-workspace-cli/internal/helpers/contract_defects_test.go
玉澜andCursor 6780177356 fix(test): assert non-dry-run version preflight via CallTool
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>
2026-08-05 01:30:17 +08:00

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)
}
}