Files
dingtalk-workspace-cli/internal/shortcut/shortcut_test.go

504 lines
15 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.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package shortcut
import (
"bytes"
"os"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestCrossPlatformCoverageProductDefaultsToService(t *testing.T) {
if got := (Shortcut{Service: "contact"}).product(); got != "contact" {
t.Fatalf("product() = %q, want contact", got)
}
if got := (Shortcut{Service: "contact", Product: "org"}).product(); got != "org" {
t.Fatalf("product() = %q, want org", got)
}
}
func TestCrossPlatformCoverageRiskDefaultsToRead(t *testing.T) {
if got := (Shortcut{}).risk(); got != RiskRead {
t.Fatalf("risk() = %q, want read", got)
}
if got := (Shortcut{Risk: RiskHighWrite}).risk(); got != RiskHighWrite {
t.Fatalf("risk() = %q, want high-risk-write", got)
}
}
func TestCrossPlatformCoverageConfirmRiskPromptsForStaticWrite(t *testing.T) {
cmd := &cobra.Command{}
var stderr bytes.Buffer
cmd.SetErr(&stderr)
rt := &RuntimeContext{cmd: cmd}
s := Shortcut{
Service: "chat",
Command: "+messages-send",
Risk: RiskWrite,
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previousStdin := os.Stdin
os.Stdin = reader
t.Cleanup(func() {
os.Stdin = previousStdin
_ = reader.Close()
_ = writer.Close()
})
if _, err := writer.WriteString("yes\n"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if !confirmRisk(rt, s) {
t.Fatal("static write risk was not confirmed")
}
if got := stderr.String(); !strings.Contains(got, "chat +messages-send(write)") {
t.Fatalf("confirmation prompt = %q", got)
}
}
func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
s := Shortcut{
Service: "contact",
Command: "+search-user",
Description: "search",
Flags: []Flag{
{Name: "query", Type: FlagString, Required: true, Enum: []string{"name", "mobile"}},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "verbose", Type: FlagBool},
{Name: "ids", Type: FlagStringSlice, Default: "self,team"},
{Name: "mode", Type: FlagString},
{Name: "start", Type: FlagString},
{Name: "end", Type: FlagString},
},
Constraints: []Constraint{
{
Kind: ConstraintExactlyOne,
Flags: []string{"query", "ids"},
},
{
Kind: ConstraintAtLeastOne,
Flags: []string{"mode", "start"},
},
{
Kind: ConstraintMutuallyExclusive,
Flags: []string{"start", "end"},
},
},
}
cmd := mount(s)
if cmd.Use != "+search-user" {
t.Fatalf("Use = %q, want +search-user", cmd.Use)
}
for _, name := range []string{"query", "limit", "verbose", "ids"} {
if cmd.Flags().Lookup(name) == nil {
t.Errorf("flag --%s not registered", name)
}
}
if v, _ := cmd.Flags().GetInt("limit"); v != 20 {
t.Errorf("limit default = %d, want 20", v)
}
if v, _ := cmd.Flags().GetStringSlice("ids"); strings.Join(v, ",") != "self,team" {
t.Errorf("ids default = %#v, want [self team]", v)
}
query := cmd.Flags().Lookup("query")
if got := query.Annotations["dws.schema.required"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("required annotation = %#v", got)
}
if got := query.Annotations["x-cli-enum"]; strings.Join(got, ",") != "name,mobile" {
t.Fatalf("enum annotation = %#v", got)
}
if raw := cmd.Annotations["dws.schema.constraints"]; raw != `{"mutually_exclusive":[["query","ids"],["start","end"]],"require_one_of":[["query","ids"],["mode","start"]]}` {
t.Fatalf("constraints annotation = %q", raw)
}
}
func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.T) {
cmd := mount(Shortcut{
Service: "chat",
Command: "+search",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString, Hidden: true},
{Name: "id", Type: FlagString},
{Name: "legacy-id", Type: FlagString, Hidden: true},
},
Constraints: []Constraint{
{
Kind: ConstraintAtLeastOne,
Flags: []string{"query", "keyword"},
},
{
Kind: ConstraintExactlyOne,
Flags: []string{"id", "legacy-id"},
},
},
})
query := cmd.Flags().Lookup("query")
if got := query.Annotations["dws.schema.required"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("collapsed public requirement annotation = %#v", got)
}
id := cmd.Flags().Lookup("id")
if got := id.Annotations["dws.schema.required"]; len(got) != 1 || got[0] != "true" {
t.Fatalf("collapsed exact-one requirement annotation = %#v", got)
}
if raw := cmd.Annotations["dws.schema.constraints"]; raw != "" {
t.Fatalf("hidden compatibility alias leaked into public Schema constraints: %q", raw)
}
}
func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
aiTag := AIMessageTagFlag()
if aiTag.Name != "ai-tag" || aiTag.Type != FlagBool || aiTag.Default != "true" {
t.Fatalf("AIMessageTagFlag() = %#v", aiTag)
}
s := Shortcut{
Service: "chat",
Command: "+compat",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "size", Type: FlagInt},
aiTag,
},
}
cmd := mount(s)
rt := &RuntimeContext{cmd: cmd, shortcut: s}
if err := cmd.Flags().Set("keyword", "树莓派"); err != nil {
t.Fatal(err)
}
if got := rt.StrFirst("query", "keyword"); got != "树莓派" {
t.Fatalf("StrFirst() = %q, want 树莓派", got)
}
if got := rt.IntFirst("limit", "size"); got != 20 {
t.Fatalf("IntFirst() default = %d, want 20", got)
}
if err := cmd.Flags().Set("size", "7"); err != nil {
t.Fatal(err)
}
if got := rt.IntFirst("limit", "size"); got != 7 {
t.Fatalf("IntFirst() alias = %d, want 7", got)
}
params := rt.AddAIMessageTag(nil)
if got := params["clawType"]; got != edition.ClawType() {
t.Fatalf("clawType = %#v, want %q", got, edition.ClawType())
}
if err := cmd.Flags().Set("ai-tag", "false"); err != nil {
t.Fatal(err)
}
params = rt.AddAIMessageTag(map[string]any{"content": "hello"})
if _, ok := params["clawType"]; ok {
t.Fatalf("clawType unexpectedly present with --ai-tag=false: %#v", params)
}
}
func TestCrossPlatformCoverageValidateFlagsRequiredAndEnum(t *testing.T) {
s := Shortcut{
Service: "contact",
Command: "+x",
Flags: []Flag{
{Name: "query", Required: true},
{Name: "order", Enum: []string{"asc", "desc"}},
},
}
cmd := mount(s)
// Missing required flag → error.
rt := &RuntimeContext{cmd: cmd, shortcut: s}
if err := validateFlags(rt, s); err == nil {
t.Fatal("expected error for missing required --query")
}
// Set required, leave enum unset → ok.
_ = cmd.Flags().Set("query", "张三")
if err := validateFlags(rt, s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Invalid enum → error.
_ = cmd.Flags().Set("order", "sideways")
if err := validateFlags(rt, s); err == nil {
t.Fatal("expected error for invalid --order enum")
}
// Valid enum → ok.
_ = cmd.Flags().Set("order", "asc")
if err := validateFlags(rt, s); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCrossPlatformCoverageValidateFlagsRejectsEmptyRequiredValuesAndInvalidSliceEnums(t *testing.T) {
s := Shortcut{
Service: "minutes",
Command: "+detail",
Flags: []Flag{
{Name: "id", Type: FlagString, Required: true},
{Name: "artifacts", Type: FlagStringSlice, Enum: []string{"summary", "todos"}},
},
}
cmd := mount(s)
rt := &RuntimeContext{cmd: cmd, shortcut: s}
_ = cmd.Flags().Set("id", " ")
if err := validateFlags(rt, s); err == nil {
t.Fatal("expected empty required string to fail")
}
_ = cmd.Flags().Set("id", "task-1")
_ = cmd.Flags().Set("artifacts", "summary,unknown")
if err := validateFlags(rt, s); err == nil {
t.Fatal("expected invalid string-slice enum value to fail")
}
cmd = mount(s)
rt = &RuntimeContext{cmd: cmd, shortcut: s}
_ = cmd.Flags().Set("id", "task-1")
_ = cmd.Flags().Set("artifacts", "summary,todos")
if err := validateFlags(rt, s); err != nil {
t.Fatalf("valid string-slice enum failed: %v", err)
}
}
func TestCrossPlatformCoverageDeclarativeConstraintsRejectEmptyAndConflictingValues(t *testing.T) {
s := Shortcut{
Service: "chat",
Command: "+messages",
Flags: []Flag{
{Name: "group", Type: FlagString},
{Name: "user", Type: FlagString},
},
Constraints: []Constraint{
{Kind: ConstraintExactlyOne, Flags: []string{"group", "user"}},
},
}
cmd := mount(s)
rt := &RuntimeContext{cmd: cmd, shortcut: s}
if err := validateConstraints(rt, s); err == nil {
t.Fatal("expected missing exactly-one flags to fail")
}
_ = cmd.Flags().Set("group", " ")
if err := validateConstraints(rt, s); err == nil {
t.Fatal("empty flag must not satisfy exactly-one")
}
_ = cmd.Flags().Set("group", "cid-1")
if err := validateConstraints(rt, s); err != nil {
t.Fatalf("one non-empty flag should pass: %v", err)
}
_ = cmd.Flags().Set("user", "user-1")
if err := validateConstraints(rt, s); err == nil {
t.Fatal("two flags must violate exactly-one")
}
}
func TestCrossPlatformCoverageMountHelpPublishesRequiredEnumsAndConstraints(t *testing.T) {
s := Shortcut{
Service: "chat",
Command: "+messages",
Description: "messages",
Intent: "读取群聊或单聊消息。",
Flags: []Flag{
{Name: "group", Type: FlagString, Required: true, Desc: "群 ID"},
{Name: "user", Type: FlagString, Desc: "用户 ID"},
{Name: "direction", Type: FlagString, Enum: []string{"newer", "older"}, Desc: "方向"},
},
Constraints: []Constraint{
{Kind: ConstraintExactlyOne, Flags: []string{"group", "user"}},
},
}
cmd := mount(s)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
if err := cmd.Help(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, want := range []string{
"参数约束", "--group、--user 必须且只能指定一个", "群 ID(必填)", "可选值: newer, older",
} {
if !strings.Contains(help, want) {
t.Errorf("help missing %q:\n%s", want, help)
}
}
}
func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) {
shortcuts := []Shortcut{
{Service: "contact", Command: "+a", Execute: noop},
{Service: "contact", Command: "+b", Execute: noop},
{Service: "calendar", Command: "+c", Execute: noop},
}
cmds := build(shortcuts)
if len(cmds) != 2 {
t.Fatalf("got %d service commands, want 2", len(cmds))
}
byName := map[string]*cobra.Command{}
for _, c := range cmds {
byName[c.Use] = c
}
if got := len(byName["contact"].Commands()); got != 2 {
t.Errorf("contact has %d leaves, want 2", got)
}
if got := len(byName["calendar"].Commands()); got != 1 {
t.Errorf("calendar has %d leaves, want 1", got)
}
}
func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) {
previous := append([]Shortcut(nil), allShortcuts...)
t.Cleanup(func() { allShortcuts = previous })
allShortcuts = nil
Register(
Shortcut{Service: "calendar", Command: "+builtin", Execute: noop},
Shortcut{Service: "calendar", Command: "+user", UserDefined: true, Execute: noop},
)
all := Commands()
if len(all) != 1 || len(all[0].Commands()) != 2 {
t.Fatalf("all shortcut commands = %#v", all)
}
builtins := BuiltInCommands()
if len(builtins) != 1 || len(builtins[0].Commands()) != 1 ||
builtins[0].Commands()[0].Name() != "+builtin" {
t.Fatalf("built-in shortcut commands = %#v", builtins)
}
}
func noop(_ *RuntimeContext) error { return nil }
func TestCrossPlatformCoverageCallMCPWriteDataRejectsDryRun(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
cmd := &cobra.Command{Use: "x"}
root.AddCommand(cmd)
if err := root.PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatalf("set dry-run: %v", err)
}
rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "calendar"}}
_, err := rt.CallMCPWriteData("calendar", "create_calendar_event", map[string]any{"summary": "x"})
if err == nil {
t.Fatal("expected dry-run write guard error")
}
if !strings.Contains(err.Error(), "calendar/create_calendar_event") {
t.Fatalf("error = %q, want tool name", err.Error())
}
}
func TestCrossPlatformCoverageCallMCPDataDryRunReadNameFailsClosed(t *testing.T) {
root := &cobra.Command{Use: "dws"}
root.PersistentFlags().Bool("dry-run", false, "")
cmd := &cobra.Command{Use: "x"}
root.AddCommand(cmd)
if err := root.PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatalf("set dry-run: %v", err)
}
rt := &RuntimeContext{cmd: cmd, shortcut: Shortcut{Service: "chat"}}
for _, tool := range []string{"send_personal_message", "custom_operation"} {
_, err := rt.CallMCPData("chat", tool, map[string]any{"receiverUid": "u"})
if err == nil {
t.Fatalf("%s: expected dry-run read allowlist error", tool)
}
if !strings.Contains(err.Error(), "chat/"+tool) {
t.Fatalf("%s: error = %q, want tool name", tool, err.Error())
}
}
for _, tool := range []string{
"get_conversation_info",
"list_topic_replies",
"query_busy_status",
"search_groups",
"unread_message_conversation_list",
} {
if !looksReadTool(tool) {
t.Errorf("%s: expected read-only classification", tool)
}
}
}
func TestCrossPlatformCoverageValidationHelpers(t *testing.T) {
s := Shortcut{
Service: "x", Command: "+y",
Flags: []Flag{
{Name: "a"}, {Name: "b"}, {Name: "c"},
{Name: "n", Type: FlagInt},
},
}
cmd := mount(s)
rt := &RuntimeContext{cmd: cmd, shortcut: s}
// nothing set
if err := rt.MutuallyExclusive("a", "b"); err != nil {
t.Errorf("MutuallyExclusive with none set should pass: %v", err)
}
if err := rt.AtLeastOne("a", "b"); err == nil {
t.Error("AtLeastOne with none set should fail")
}
if err := rt.ExactlyOne("a", "b"); err == nil {
t.Error("ExactlyOne with none set should fail")
}
_ = cmd.Flags().Set("a", "1")
if err := rt.MutuallyExclusive("a", "b"); err != nil {
t.Errorf("MutuallyExclusive with one set should pass: %v", err)
}
if err := rt.AtLeastOne("a", "b"); err != nil {
t.Errorf("AtLeastOne with one set should pass: %v", err)
}
if err := rt.ExactlyOne("a", "b"); err != nil {
t.Errorf("ExactlyOne with one set should pass: %v", err)
}
_ = cmd.Flags().Set("b", "1")
if err := rt.MutuallyExclusive("a", "b"); err == nil {
t.Error("MutuallyExclusive with two set should fail")
}
if err := rt.ExactlyOne("a", "b"); err == nil {
t.Error("ExactlyOne with two set should fail")
}
// RangeInt
_ = cmd.Flags().Set("n", "50")
if err := rt.RangeInt("n", 1, 30); err == nil {
t.Error("RangeInt 50 not in [1,30] should fail")
}
_ = cmd.Flags().Set("n", "20")
if err := rt.RangeInt("n", 1, 30); err != nil {
t.Errorf("RangeInt 20 in [1,30] should pass: %v", err)
}
// RequireAll
if err := rt.RequireAll("a", "c"); err == nil {
t.Error("RequireAll with c unset should fail")
}
_ = cmd.Flags().Set("c", "1")
if err := rt.RequireAll("a", "c"); err != nil {
t.Errorf("RequireAll with both set should pass: %v", err)
}
}