504 lines
15 KiB
Go
504 lines
15 KiB
Go
// 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)
|
||
}
|
||
}
|