test(chat): close IM shortcut coverage gaps

This commit is contained in:
Dennis
2026-07-29 09:46:00 +08:00
parent f3231ed2a8
commit 33df6ee794
16 changed files with 1977 additions and 34 deletions
+36
View File
@@ -71,6 +71,32 @@ func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
}
}
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
var nilAdapter *toolCallerAdapter
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("nil adapter accepted a read-only call")
}
regularRunner := &capturingSuccessRunner{}
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
t.Fatalf("non-dry read should use the regular runner: %v", err)
}
if got := regularRunner.calls.Load(); got != 1 {
t.Fatalf("regular runner calls = %d, want 1", got)
}
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("read-only runner error was swallowed")
}
var nilRuntime *runtimeRunner
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
t.Fatal("nil runtime runner accepted a read-only call")
}
}
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
fallback := &countingErrorRunner{}
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
@@ -165,3 +191,13 @@ func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invo
Response: map[string]any{"read": true},
}, nil
}
type failingReadOnlyRunner struct{}
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("regular runner must not be called")
}
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
return executor.Result{}, errors.New("read failed")
}
+56
View File
@@ -27,6 +27,7 @@ type crossPlatformCoverageCaller struct {
args map[string]any
token string
dryRun bool
reads int
}
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
@@ -44,6 +45,16 @@ func (c *crossPlatformCoverageCaller) CallToolWithToken(
return &edition.ToolResult{}, nil
}
func (c *crossPlatformCoverageCaller) CallReadTool(
_ context.Context,
_, _ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.reads++
c.args = args
return &edition.ToolResult{}, nil
}
func (*crossPlatformCoverageCaller) Format() string { return "json" }
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
@@ -129,6 +140,51 @@ func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
}
}
func TestCrossPlatformCoverageRecordingReadToolCaller(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv("DWS_USAGE_TRACKING", "1")
inner := &crossPlatformCoverageCaller{dryRun: true}
caller := newRecordingToolCaller(inner)
readCaller, ok := caller.(edition.ReadToolCaller)
if !ok {
t.Fatal("recording caller dropped read-only capability")
}
if _, err := readCaller.CallReadTool(
context.Background(),
"im",
"search_groups",
map[string]any{"open_conversation_id": "cid_x"},
); err != nil {
t.Fatal(err)
}
if inner.reads != 1 || inner.args["open_conversation_id"] != "cid_x" {
t.Fatalf("read forwarding = reads %d args %#v", inner.reads, inner.args)
}
records, err := usage.Read()
if err != nil {
t.Fatal(err)
}
if len(records) != 1 || !records[0].OK {
t.Fatalf("real read must be recorded as successful: %#v", records)
}
withoutRead := recordingToolCaller{inner: nonReadToolCaller{}}
if _, err := withoutRead.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
t.Fatal("recording caller accepted an inner caller without read support")
}
}
type nonReadToolCaller struct{}
func (nonReadToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
return &edition.ToolResult{}, nil
}
func (nonReadToolCaller) Format() string { return "json" }
func (nonReadToolCaller) DryRun() bool { return false }
func (nonReadToolCaller) Fields() string { return "" }
func (nonReadToolCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
@@ -14,8 +14,11 @@
package helpers
import (
"context"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestValidatedConversationCategoryTitle(t *testing.T) {
@@ -48,3 +51,35 @@ func TestValidatedConversationCategoryTitle(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageCategoryCommandsRejectInvalidTitles(t *testing.T) {
caller := &categoryTitleCaller{}
old := deps
t.Cleanup(func() { deps = old })
InitDeps(caller)
for _, argv := range [][]string{
{"category", "create", "--title", "1234567890123456"},
{"category", "rename", "--category-id", "42", "--title", " "},
} {
cmd := newChatCommand()
cmd.SetArgs(argv)
if err := cmd.Execute(); err == nil {
t.Fatalf("newChatCommand(%v) unexpectedly accepted an invalid title", argv)
}
}
if caller.calls != 0 {
t.Fatalf("invalid category titles reached MCP %d times", caller.calls)
}
}
type categoryTitleCaller struct{ calls int }
func (c *categoryTitleCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
c.calls++
return &edition.ToolResult{}, nil
}
func (*categoryTitleCaller) Format() string { return "json" }
func (*categoryTitleCaller) DryRun() bool { return false }
func (*categoryTitleCaller) Fields() string { return "" }
func (*categoryTitleCaller) JQ() string { return "" }
+27
View File
@@ -59,6 +59,11 @@ func TestCrossPlatformCoverageRawToolAuditLineIsSingleLineJSON(t *testing.T) {
}
}
func TestCrossPlatformCoverageRawToolAuditEnabled(t *testing.T) {
t.Setenv("DWS_DUMP_RAW", "1")
dumpRawToolResponse("im", "search_groups", `{"result":[]}`)
}
func installHelpersCoreDeps(t *testing.T, caller edition.ToolCaller) (*bytes.Buffer, *bytes.Buffer) {
t.Helper()
old := deps
@@ -101,6 +106,28 @@ func TestCrossPlatformCoverageDryRunReadLookupUsesExplicitCapability(t *testing.
}
}
func TestCrossPlatformCoverageReadLookupInitializationAndRegularExecution(t *testing.T) {
old := deps
t.Cleanup(func() { deps = old })
deps = nil
if _, err := CallMCPReadToolTextOnServer("im", "search_groups", nil); err == nil {
t.Fatal("uninitialized read lookup unexpectedly succeeded")
}
caller := &helpersCoreCaller{
format: "json",
result: textToolResult(`{"success":true,"result":{"groups":[]}}`),
}
installHelpersCoreDeps(t, caller)
got, err := CallMCPReadToolTextOnServer("im", "search_groups", nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, `"groups"`) || caller.calls != 1 {
t.Fatalf("regular read result/calls = %q, %d", got, caller.calls)
}
}
func TestCrossPlatformCoverageSharedDependenciesRoutingAndWrappers(t *testing.T) {
oldDeps := deps
deps = nil
+2 -2
View File
@@ -610,7 +610,7 @@ func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]an
}, nil
}
cwd, err := os.Getwd()
cwd, err := resourceGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
}
@@ -665,7 +665,7 @@ func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]an
})
continue
}
size, downloadErr := downloadResourceAtomically(
size, downloadErr := resourceDownload(
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if downloadErr != nil {
failures = append(failures, map[string]any{
@@ -0,0 +1,122 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
rows := listMessagesProject(map[string]any{"result": map[string]any{"messages": []any{
map[string]any{
"openMessageId": "msg",
"openConversationId": "cid",
"threadId": "thread",
"msgType": "text",
"createTime": "1",
"updateTime": "2",
"content": `{"mediaId":"@image"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted",
},
},
}}})
if len(rows) != 1 {
t.Fatalf("rows = %#v", rows)
}
for _, key := range []string{"threadId", "updateTime", "quotedMessage", "resourceRefs"} {
if _, ok := rows[0][key]; !ok {
t.Errorf("projection missing %s: %#v", key, rows[0])
}
}
}
func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources"}
readyMget := `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`
missingContextMget := `{"result":[{"content":"{\"mediaId\":\"@file\"}"}]}`
validInfo := `{"result":{"resourceUrl":"https://example.test/resource.bin"}}`
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": readyMget,
}})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--dry-run"))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
t.Run("getwd", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": readyMget,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "工作目录") {
t.Fatalf("getwd error = %v", err)
}
})
cases := []struct {
name string
mget string
info string
failProductTool string
outputDir string
downloadErr error
pathErr bool
}{
{name: "missing context", mget: missingContextMget, info: validInfo},
{name: "resource lookup", mget: readyMget, failProductTool: "im/get_resource_download_url"},
{name: "invalid info", mget: readyMget, info: `{"result":{}}`},
{name: "path", mget: readyMget, info: validInfo, outputDir: "go.mod", pathErr: true},
{name: "download", mget: readyMget, info: validInfo, downloadErr: errors.New("download")},
{name: "success", mget: readyMget, info: validInfo},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resetResourceDownloadHooks(t)
if tc.pathErr {
resourceAbs = func(string) (string, error) { return "", errors.New("path") }
}
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
_ string,
_ bool,
) (int64, error) {
return 4, tc.downloadErr
}
caller := &larkAlignmentCaller{
failProductTool: tc.failProductTool,
responses: map[string]string{
"im/list_messages_by_ids": tc.mget,
"im/get_resource_download_url": tc.info,
},
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
args := append([]string{}, baseArgs...)
if tc.outputDir != "" {
args = append(args, "--output-dir", tc.outputDir)
}
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
}
}
@@ -0,0 +1,494 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageConversationValidationAndTypeVariants(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
tooMany := make([]string, 11)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("cid-%d", i)
}
root.SetArgs([]string{
"chat", "+conversation-set-top",
"--conversation-ids", strings.Join(tooMany, ","),
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("more than ten conversation IDs were accepted")
}
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+category-create", "--title", " ", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("blank category title was accepted")
}
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+coverage-category-title",
Flags: []shortcut.Flag{{Name: "title", Type: shortcut.FlagString}},
Validate: validateConversationCategoryTitle,
Execute: func(*shortcut.RuntimeContext) error { return nil },
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+coverage-category-title", "--title", " "})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "不能为空") {
t.Fatalf("direct blank category validation error = %v", err)
}
tests := []struct {
value map[string]any
want string
}{
{map[string]any{"singleChat": "true"}, "direct"},
{map[string]any{"singleChat": "false"}, "group"},
{map[string]any{"singleChat": float64(1)}, "direct"},
{map[string]any{"singleChat": float64(0)}, "group"},
{map[string]any{"singleChat": 1}, "direct"},
{map[string]any{"singleChat": 0}, "group"},
{map[string]any{"conversationType": "group_chat"}, "group"},
}
for _, tc := range tests {
if got, ok := conversationListTopType(tc.value); !ok || got != tc.want {
t.Errorf("conversationListTopType(%#v) = %q, %v; want %q", tc.value, got, ok, tc.want)
}
}
}
func TestCrossPlatformCoverageConversationAndGroupListExecution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
for _, args := range [][]string{
{"chat", "+conversation-set-top", "--conversation-id", "cid", "--yes"},
{"chat", "+conversation-list", "--limit", "1", "--cursor", "1", "--exclude-muted"},
{"chat", "+conversation-list-top", "--limit", "1", "--cursor", "1", "--exclude-muted", "--type", "group"},
{"chat", "+category-list-conversations", "--category-id", "1", "--exclude-muted"},
{"chat", "+chat-list-mine", "--role", "OWNER", "--limit", "1", "--exclude-muted"},
{"chat", "+chat-list-all", "--limit", "1", "--cursor", "next"},
{"chat", "+messages-list-pin", "--open-conversation-id", "cid", "--cursor", "next", "--size", "1"},
} {
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", args, err)
}
}
}
func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
tests := []struct {
name string
caller *larkAlignmentCaller
args []string
wantError string
}{
{
name: "current profile call",
caller: &larkAlignmentCaller{failProductTool: "contact/get_current_user_profile"},
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"},
wantError: "读取当前用户",
},
{
name: "missing current user",
caller: &larkAlignmentCaller{responses: map[string]string{
"contact/get_current_user_profile": `{"result":[]}`,
}},
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"},
wantError: "缺少 userId",
},
{
name: "create write",
caller: &larkAlignmentCaller{failProductTool: "im/create_group_conversation"},
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--yes"},
wantError: "fixture lower call failed",
},
{
name: "explicit sender lookup",
caller: &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "解析为 openDingTalkId",
},
{
name: "explicit sender unresolved",
caller: &larkAlignmentCaller{responses: map[string]string{
"contact/get_user_info_by_user_ids": `{"result":[]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "无法把 --ref-sender",
},
{
name: "referenced message lookup",
caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--text", "收到", "--yes"},
wantError: "读取被引用消息",
},
{
name: "referenced message missing sender",
caller: &larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"other","senderOpenDingTalkId":"D-other"},{"openMessageId":"msg"}]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--text", "收到", "--yes"},
wantError: "未返回 senderOpenDingTalkId",
},
{
name: "feed source",
caller: &larkAlignmentCaller{failProductTool: "im/list_conversations_by_category"},
args: []string{"chat", "+feed-group-query-item", "--category-id", "1", "--conversation-ids", "cid"},
wantError: "fixture lower call failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs(tc.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want containing %q", err, tc.wantError)
}
})
}
dry := &larkAlignmentCaller{}
helpers.InitDeps(dry)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--dry-run", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(dry.calls) != 2 ||
dry.calls[0].tool != "get_current_user_profile" ||
dry.calls[1].tool != "create_group_conversation" {
t.Fatalf("chat-create dry-run calls = %#v", dry.calls)
}
}
func TestCrossPlatformCoverageReplyShapeHelpers(t *testing.T) {
if got := findOpenDingTalkID([]map[string]any{{"openDingTalkId": "D-map"}}); got != "D-map" {
t.Fatalf("findOpenDingTalkID([]map) = %q", got)
}
if got := shortcutMessageMaps(nil); got != nil {
t.Fatalf("nil message maps = %#v", got)
}
if got := shortcutMessageMaps(map[string]any{}); got != nil {
t.Fatalf("empty message maps = %#v", got)
}
maps := shortcutMessageMaps(map[string]any{
"data": map[string]any{
"items": []map[string]any{{"openMessageId": "msg"}},
},
})
if len(maps) != 1 || maps[0]["openMessageId"] != "msg" {
t.Fatalf("nested []map message maps = %#v", maps)
}
if got := currentProfileUserID(map[string]any{"result": map[string]any{"userId": "nested"}}); got != "nested" {
t.Fatalf("nested current profile = %q", got)
}
if got := currentProfileUserID(map[string]any{"userId": "direct"}); got != "direct" {
t.Fatalf("direct current profile = %q", got)
}
data := map[string]any{"result": "not-a-map"}
normalizeCreatedConversation(data)
if data["result"] != "not-a-map" {
t.Fatalf("normalization changed non-map result: %#v", data)
}
}
func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
tooMany := make([]string, 11)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("msg-%d", i)
}
cases := [][]string{
{"chat", "+flag-create", "--message-ids", strings.Join(tooMany, ","), "--conversation-id", "cid", "--yes"},
{"chat", "+flag-list", "--cursor", "-1"},
{"chat", "+flag-list", "--size", "101"},
{"chat", "+messages-mget", "--msg-ids", strings.Join(makeIDs(51), ",")},
{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--output-dir", "../escape"},
}
for _, args := range cases {
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Errorf("invalid args unexpectedly succeeded: %v", args)
}
}
fake := &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg"})
if err := root.Execute(); err == nil {
t.Fatal("mget lower error was swallowed")
}
}
func TestCrossPlatformCoverageFeedCompleteAndExcludeMuted(t *testing.T) {
fake := &larkAlignmentCaller{category: `{"result":{"hasMore":false,"list":[{"openConversationId":"cid"}]}}`}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+feed-group-query-item",
"--category-id", "1",
"--conversation-ids", "cid",
"--exclude-muted",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].args["excludeMuted"] != true {
t.Fatalf("feed calls = %#v", fake.calls)
}
}
func TestCrossPlatformCoverageUnifiedSendValidationMatrix(t *testing.T) {
cases := [][]string{
{"--identity", "user", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "user", "--text", "x"},
{"--identity", "user", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
{"--identity", "user", "--open-dingtalk-id", "D1", "--at-all", "--text", "x"},
{"--identity", "bot", "--group", "cid", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--users", "u1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--open-dingtalk-id", "D1", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--at-mobiles", "13800000000", "--text", "x"},
{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--at-user-ids", "u2", "--text", "x"},
{"--identity", "webhook", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--group", "cid", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--at-open-dingtalk-ids", "D1", "--text", "x"},
{"--identity", "webhook", "--webhook-token", "token", "--uuid", "key", "--text", "x"},
}
for _, tail := range cases {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := append([]string{"chat", "+messages-send"}, tail...)
args = append(args, "--yes")
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Errorf("invalid unified send unexpectedly succeeded: %v", tail)
}
if len(fake.calls) != 0 {
t.Errorf("invalid unified send reached lower service: %v => %#v", tail, fake.calls)
}
}
}
func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T) {
tests := []struct {
name string
args []string
want map[string]any
}{
{
name: "user group mentions",
args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", "D1,D2", "--at-all"},
want: map[string]any{"atOpenDingTalkIds": []string{"D1", "D2"}, "atAll": true},
},
{
name: "user direct",
args: []string{"--identity", "user", "--open-dingtalk-id", "D1", "--text", "x"},
want: map[string]any{"receiverOpenDingTalkId": "D1"},
},
{
name: "bot group mentions",
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", "D1"},
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{"D1"}},
},
{
name: "bot direct targets",
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", "D1", "--text", "x", "--at-all"},
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{"D1"}, "isAtAll": "true"},
},
{
name: "webhook mentions",
args: []string{"--identity", "webhook", "--webhook-token", "token", "--text", "x", "--at-user-ids", "u1", "--at-mobiles", "13800000000"},
want: map[string]any{"atUserIds": []string{"u1"}, "atMobiles": []string{"13800000000"}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
args := append([]string{"chat", "+messages-send"}, tc.args...)
args = append(args, "--yes")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
call := fake.calls[len(fake.calls)-1]
for key, want := range tc.want {
if !reflect.DeepEqual(call.args[key], want) {
t.Errorf("%s = %#v, want %#v", key, call.args[key], want)
}
}
})
}
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send", "--identity", "user", "--group", "cid", "--text", "x", "--dry-run", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("unified send dry-run reached lower service: %#v", fake.calls)
}
fake = &larkAlignmentCaller{failProductTool: "chat/send_personal_message"}
helpers.InitDeps(fake)
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-send", "--identity", "user", "--group", "cid", "--text", "x", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("unified send write error was swallowed")
}
if got := shortcutMessageTitle(strings.Repeat("界", 45)); len([]rune(got)) != 40 {
t.Fatalf("long generated title has %d runes", len([]rune(got)))
}
}
func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) {
flags := append([]shortcut.Flag(nil), MessagesSend.Flags...)
for i := range flags {
if flags[i].Name == "identity" || flags[i].Name == "as" {
flags[i].Enum = nil
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+coverage-unified-send",
Flags: flags,
Execute: executeMessagesSend,
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+coverage-unified-send", "--identity", "unsupported", "--text", "x"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "unsupported identity") {
t.Fatalf("unsupported identity error = %v", err)
}
}
func TestCrossPlatformCoverageMuteMemberResolutionFailures(t *testing.T) {
tests := []struct {
name string
caller *muteMemberScenarioCaller
}{
{"contact error", &muteMemberScenarioCaller{contactErr: errors.New("contact failed")}},
{"contact missing name", &muteMemberScenarioCaller{contactText: `{"result":[]}`}},
{"member error", &muteMemberScenarioCaller{memberErr: errors.New("members failed")}},
{"member missing fields", &muteMemberScenarioCaller{memberMode: "missing-fields"}},
{"member cursor missing", &muteMemberScenarioCaller{memberMode: "missing-cursor"}},
{"member ambiguous", &muteMemberScenarioCaller{memberMode: "ambiguous"}},
{"member page limit", &muteMemberScenarioCaller{memberMode: "page-limit"}},
{"unrelated directory result", &muteMemberScenarioCaller{
contactText: `{"result":[{"orgEmployeeModel":{"orgUserId":"other","orgUserName":"其他"}},{"orgEmployeeModel":{"orgUserId":"user-1","orgUserName":"测试成员"}}]}`,
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-mute-member", "--group", "cid", "--users", "user-1", "--off", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("resolution failure scenario unexpectedly succeeded")
}
})
}
for _, tc := range []struct {
value any
want string
}{
{float64(1.5), "1.5"},
{int(2), "2"},
{int64(3), "3"},
} {
if got := shortcutString(map[string]any{"value": tc.value}, "value"); got != tc.want {
t.Errorf("shortcutString(%T) = %q, want %q", tc.value, got, tc.want)
}
}
}
type muteMemberScenarioCaller struct {
contactErr error
memberErr error
contactText string
memberMode string
}
func (c *muteMemberScenarioCaller) CallTool(
_ context.Context,
product, tool string,
args map[string]any,
) (*edition.ToolResult, error) {
switch product + "/" + tool {
case "contact/get_user_info_by_user_ids":
if c.contactErr != nil {
return nil, c.contactErr
}
text := c.contactText
if text == "" {
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-1","orgUserName":"测试成员"}}]}`
}
return textResult(text), nil
case "chat/get_group_members":
if c.memberErr != nil {
return nil, c.memberErr
}
switch c.memberMode {
case "missing-fields":
return textResult(`{"result":{"hasMore":false,"list":[{"memberEmpName":"","openDingtalkId":""}]}}`), nil
case "missing-cursor":
return textResult(`{"result":{"hasMore":true,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"}]}}`), nil
case "ambiguous":
return textResult(`{"result":{"hasMore":false,"list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"},{"memberEmpName":"测试成员","openDingtalkId":"D2"}]}}`), nil
case "page-limit":
cursor, _ := strconv.Atoi(fmt.Sprint(args["cursor"]))
return textResult(fmt.Sprintf(
`{"result":{"hasMore":true,"nextCursor":"%d","list":[{"memberEmpName":"测试成员","openDingtalkId":"D1"}]}}`,
cursor+1,
)), nil
default:
return textResult(`{"result":{"hasMore":false,"list":[]}}`), nil
}
default:
return textResult(`{"success":true}`), nil
}
}
func (*muteMemberScenarioCaller) Format() string { return "json" }
func (*muteMemberScenarioCaller) DryRun() bool { return false }
func (*muteMemberScenarioCaller) Fields() string { return "" }
func (*muteMemberScenarioCaller) JQ() string { return "" }
func textResult(text string) *edition.ToolResult {
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
}
func makeIDs(count int) []string {
ids := make([]string, count)
for i := range ids {
ids[i] = fmt.Sprintf("id-%d", i)
}
return ids
}
+1 -4
View File
@@ -161,15 +161,12 @@ var MessagesReply = shortcut.Shortcut{
if err != nil {
return err
}
content, err := json.Marshal(map[string]string{
content, _ := json.Marshal(map[string]string{
"referenceOpenMessageId": replyMessageID(rt),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": rt.Str("text"),
})
if err != nil {
return err
}
params := rt.AddAIMessageTag(map[string]any{
"openConversationId": rt.Str("conversation-id"),
"msgType": "reply",
+13 -4
View File
@@ -23,9 +23,11 @@ type larkAlignmentCall struct {
}
type larkAlignmentCaller struct {
calls []larkAlignmentCall
failTarget string
category string
calls []larkAlignmentCall
failTarget string
failProductTool string
category string
responses map[string]string
}
func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -33,8 +35,12 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
if f.failTarget != "" && args["openMessageId"] == f.failTarget {
return nil, errors.New("fixture write failed")
}
key := product + "/" + tool
if f.failProductTool == key {
return nil, errors.New("fixture lower call failed")
}
text := `{"success":true}`
switch product + "/" + tool {
switch key {
case "contact/get_current_user_profile":
text = `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "contact/get_user_info_by_user_ids":
@@ -49,6 +55,9 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
text = `{"result":{"hasMore":false,"list":[{"openConversationId":"cid-a","conversationName":"A"},{"openConversationId":"cid-b","conversationName":"B"}]}}`
}
}
if response, ok := f.responses[key]; ok {
text = response
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
+37 -20
View File
@@ -31,6 +31,23 @@ import (
const resourceDownloadTimeout = 10 * time.Minute
var (
resourceGetwd = os.Getwd
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceStat = os.Stat
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceMkdir = os.Mkdir
resourceCreateTemp = os.CreateTemp
resourceCopy = io.Copy
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = os.Rename
resourceLink = os.Link
resourceDownload = downloadResourceAtomically
)
// MessagesResourceDownload resolves a temporary IM resource URL and saves the
// bytes through a safe, atomic, no-clobber local-file workflow.
var MessagesResourceDownload = shortcut.Shortcut{
@@ -97,7 +114,7 @@ var MessagesResourceDownload = shortcut.Shortcut{
if err != nil {
return err
}
cwd, err := os.Getwd()
cwd, err := resourceGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
@@ -106,7 +123,7 @@ var MessagesResourceDownload = shortcut.Shortcut{
if err != nil {
return err
}
size, err := downloadResourceAtomically(
size, err := resourceDownload(
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if err != nil {
return err
@@ -206,11 +223,11 @@ func resolveResourceDownloadPath(
if err := validateResourceDownloadOutput(output); err != nil {
return "", "", err
}
baseDir, err = filepath.Abs(baseDir)
baseDir, err = resourceAbs(baseDir)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realBase, err := filepath.EvalSymlinks(baseDir)
realBase, err := resourceEvalSymlinks(baseDir)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
@@ -220,7 +237,7 @@ func resolveResourceDownloadPath(
strings.HasSuffix(rawOutput, "/")
output = filepath.Clean(rawOutput)
candidate := filepath.Join(realBase, output)
info, statErr := os.Stat(candidate)
info, statErr := resourceStat(candidate)
isDirectory := (statErr == nil && info.IsDir()) ||
directoryIntent ||
output == "."
@@ -232,18 +249,18 @@ func resolveResourceDownloadPath(
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
return "", "", err
}
realParent, err := filepath.EvalSymlinks(parent)
realParent, err := resourceEvalSymlinks(parent)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
}
parentRel, err := filepath.Rel(realBase, realParent)
parentRel, err := resourceRel(realBase, realParent)
if err != nil || parentRel == ".." ||
strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
return "", "", apperrors.NewValidation("--output 解析后逃逸工作目录")
}
absolutePath = filepath.Join(realParent, filepath.Base(candidate))
if info, statErr := os.Lstat(absolutePath); statErr == nil {
if info, statErr := resourceLstat(absolutePath); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", "", apperrors.NewValidation("--output 目标不能是符号链接")
}
@@ -257,7 +274,7 @@ func resolveResourceDownloadPath(
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", "", apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
}
relativePath, err = filepath.Rel(realBase, absolutePath)
relativePath, err = resourceRel(realBase, absolutePath)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
}
@@ -265,7 +282,7 @@ func resolveResourceDownloadPath(
}
func ensureResourceDownloadParent(baseDir, parent string) error {
relative, err := filepath.Rel(baseDir, parent)
relative, err := resourceRel(baseDir, parent)
if err != nil || relative == ".." ||
strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
return apperrors.NewValidation("--output 解析后逃逸工作目录")
@@ -277,12 +294,12 @@ func ensureResourceDownloadParent(baseDir, parent string) error {
current := baseDir
for _, part := range strings.Split(relative, string(os.PathSeparator)) {
current = filepath.Join(current, part)
info, statErr := os.Lstat(current)
info, statErr := resourceLstat(current)
if errors.Is(statErr, os.ErrNotExist) {
if mkdirErr := os.Mkdir(current, 0o755); mkdirErr != nil {
if mkdirErr := resourceMkdir(current, 0o755); mkdirErr != nil {
// A concurrent creator may have won the race. Re-check the
// resulting entry instead of following it implicitly.
info, statErr = os.Lstat(current)
info, statErr = resourceLstat(current)
if statErr != nil {
return apperrors.NewInternal(fmt.Sprintf(
"创建输出目录失败: %v", mkdirErr))
@@ -362,17 +379,17 @@ func downloadResourceAtomically(
}
parent := filepath.Dir(destPath)
temp, err := os.CreateTemp(parent, "."+filepath.Base(destPath)+".part-*")
temp, err := resourceCreateTemp(parent, "."+filepath.Base(destPath)+".part-*")
if err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("创建下载临时文件失败: %v", err))
}
tempPath := temp.Name()
defer func() {
_ = temp.Close()
_ = resourceTempClose(temp)
_ = os.Remove(tempPath)
}()
size, err = io.Copy(temp, response.Body)
size, err = resourceCopy(temp, response.Body)
if err != nil {
return 0, apperrors.NewAPI(fmt.Sprintf("写入消息资源失败: %v", err))
}
@@ -381,19 +398,19 @@ func downloadResourceAtomically(
"消息资源大小校验失败: 下载 %d 字节,期望 %d 字节",
size, response.ContentLength))
}
if err := temp.Sync(); err != nil {
if err := resourceTempSync(temp); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("同步消息资源失败: %v", err))
}
if err := temp.Close(); err != nil {
if err := resourceTempClose(temp); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("关闭消息资源失败: %v", err))
}
if overwrite {
if err := os.Rename(tempPath, destPath); err != nil {
if err := resourceRename(tempPath, destPath); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("发布消息资源失败: %v", err))
}
return size, nil
}
if err := os.Link(tempPath, destPath); err != nil {
if err := resourceLink(tempPath, destPath); err != nil {
if errors.Is(err, os.ErrExist) {
return 0, apperrors.NewValidation(
"目标文件已存在;如确认覆盖请显式传 --overwrite")
@@ -0,0 +1,526 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func resetResourceDownloadHooks(t *testing.T) {
t.Helper()
getwd := resourceGetwd
abs := resourceAbs
eval := resourceEvalSymlinks
stat := resourceStat
lstat := resourceLstat
rel := resourceRel
mkdir := resourceMkdir
createTemp := resourceCreateTemp
copyFn := resourceCopy
syncFn := resourceTempSync
closeFn := resourceTempClose
renameFn := resourceRename
linkFn := resourceLink
downloadFn := resourceDownload
t.Cleanup(func() {
resourceGetwd = getwd
resourceAbs = abs
resourceEvalSymlinks = eval
resourceStat = stat
resourceLstat = lstat
resourceRel = rel
resourceMkdir = mkdir
resourceCreateTemp = createTemp
resourceCopy = copyFn
resourceTempSync = syncFn
resourceTempClose = closeFn
resourceRename = renameFn
resourceLink = linkFn
resourceDownload = downloadFn
})
}
func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
baseArgs := []string{
"chat", "+messages-resource-download",
"--resource-id", "@image",
"--message-id", "msg",
"--open-conversation-id", "cid",
}
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--dry-run"))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
})
t.Run("lower error", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{failProductTool: "im/get_resource_download_url"})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil {
t.Fatal("lower error was swallowed")
}
})
t.Run("invalid download info", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil {
t.Fatal("missing URL was accepted")
}
})
t.Run("getwd error", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil {
t.Fatal("getwd error was swallowed")
}
})
t.Run("path error", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceAbs = func(string) (string, error) { return "", errors.New("path") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil {
t.Fatal("path error was swallowed")
}
})
for _, tc := range []struct {
name string
download func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error)
wantError bool
}{
{
name: "download error",
download: func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error) {
return 0, errors.New("download")
},
wantError: true,
},
{
name: "success",
download: func(context.Context, *http.Client, string, map[string]string, string, bool) (int64, error) {
return 7, nil
},
},
} {
t.Run(tc.name, func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceDownload = tc.download
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--output", filepath.Join(t.TempDir(), "file")))
// The absolute path is intentionally rejected by the public command,
// so use a relative path while running from the repository cwd.
root.SetArgs(append(append([]string{}, baseArgs...), "--output", "coverage-resource.bin", "--overwrite"))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
})
}
}
func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
for _, output := range []string{"", "/absolute", "../escape"} {
if err := validateResourceDownloadOutput(output); err == nil {
t.Errorf("unsafe output %q accepted", output)
}
}
for _, data := range []map[string]any{
{},
{"resourceUrl": []any{42, ""}},
{"resourceUrl": "://bad"},
{"resourceUrl": "http://example.test/file"},
} {
if _, _, err := resourceDownloadInfo(data); err == nil {
t.Errorf("invalid resource info accepted: %#v", data)
}
}
resourceURL, headers, err := resourceDownloadInfo(map[string]any{
"resourceUrl": "https://example.test/file",
"headers": map[string]any{
"": "ignored",
"X-Count": 3,
"X-Test": "ok",
},
})
if err != nil || resourceURL == "" || len(headers) != 1 || headers["X-Test"] != "ok" {
t.Fatalf("resource info = %q %#v %v", resourceURL, headers, err)
}
for host, want := range map[string]bool{
"ALIYUNCS.COM.": true,
"bucket.aliyuncs.com": true,
"example.com": false,
} {
if got := isAliyunOSSHost(host); got != want {
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
}
}
}
func TestCrossPlatformCoverageResourceDownloadPathErrors(t *testing.T) {
t.Run("absolute resolution", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceAbs = func(string) (string, error) { return "", errors.New("abs") }
if _, _, err := resolveResourceDownloadPath(".", "file", "https://example.test/file", false); err == nil {
t.Fatal("absolute-path error was swallowed")
}
})
t.Run("base symlink resolution", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceEvalSymlinks = func(string) (string, error) { return "", errors.New("eval") }
if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil {
t.Fatal("base eval error was swallowed")
}
})
t.Run("parent symlink resolution", func(t *testing.T) {
resetResourceDownloadHooks(t)
calls := 0
resourceEvalSymlinks = func(path string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("parent eval")
}
return filepath.EvalSymlinks(path)
}
if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil {
t.Fatal("parent eval error was swallowed")
}
})
t.Run("parent escape", func(t *testing.T) {
resetResourceDownloadHooks(t)
calls := 0
resourceRel = func(base, target string) (string, error) {
calls++
if calls == 2 {
return "../outside", nil
}
return filepath.Rel(base, target)
}
if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil {
t.Fatal("parent escape was accepted")
}
})
t.Run("target inspection", func(t *testing.T) {
base := t.TempDir()
existing := filepath.Join(base, "existing")
if err := os.WriteFile(existing, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := resolveResourceDownloadPath(base, "existing", "https://example.test/file", false); err == nil {
t.Fatal("existing file was accepted without overwrite")
}
if _, _, err := resolveResourceDownloadPath(base, "existing", "https://example.test/file", true); err != nil {
t.Fatal(err)
}
dir := filepath.Join(base, "dir")
if err := os.Mkdir(dir, 0o755); err != nil {
t.Fatal(err)
}
if _, _, err := resolveResourceDownloadPath(base, filepath.Join("dir", "child")+"/", "https://example.test/file", false); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "link")
if err := os.Symlink(existing, link); err == nil {
if _, _, resolveErr := resolveResourceDownloadPath(base, "link", "https://example.test/file", false); resolveErr == nil {
t.Fatal("symlink target was accepted")
}
}
})
t.Run("lstat error", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("lstat") }
if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil {
t.Fatal("lstat error was swallowed")
}
})
t.Run("target becomes directory", func(t *testing.T) {
resetResourceDownloadHooks(t)
base := t.TempDir()
resourceStat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
resourceLstat = func(string) (os.FileInfo, error) {
return os.Stat(base)
}
if _, _, err := resolveResourceDownloadPath(base, "file", "https://example.test/file", false); err == nil {
t.Fatal("directory target was accepted")
}
})
t.Run("final relative error", func(t *testing.T) {
resetResourceDownloadHooks(t)
calls := 0
resourceRel = func(base, target string) (string, error) {
calls++
if calls == 3 {
return "", errors.New("rel")
}
return filepath.Rel(base, target)
}
if _, _, err := resolveResourceDownloadPath(t.TempDir(), "file", "https://example.test/file", false); err == nil {
t.Fatal("final relative error was swallowed")
}
})
}
func TestCrossPlatformCoverageEnsureResourceDownloadParentErrors(t *testing.T) {
base := t.TempDir()
if err := ensureResourceDownloadParent(base, filepath.Join(base, "..", "outside")); err == nil {
t.Fatal("outside parent was accepted")
}
if err := ensureResourceDownloadParent(base, base); err != nil {
t.Fatal(err)
}
t.Run("mkdir and recheck error", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceMkdir = func(string, os.FileMode) error { return errors.New("mkdir") }
resourceLstat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }
if err := ensureResourceDownloadParent(base, filepath.Join(base, "new")); err == nil {
t.Fatal("mkdir error was swallowed")
}
})
t.Run("concurrent creator", func(t *testing.T) {
resetResourceDownloadHooks(t)
target := filepath.Join(base, "concurrent")
resourceMkdir = func(path string, mode os.FileMode) error {
if err := os.Mkdir(path, mode); err != nil {
t.Fatal(err)
}
return errors.New("lost race")
}
if err := ensureResourceDownloadParent(base, target); err != nil {
t.Fatal(err)
}
})
t.Run("inspection error", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceLstat = func(string) (os.FileInfo, error) { return nil, errors.New("inspect") }
if err := ensureResourceDownloadParent(base, filepath.Join(base, "inspect")); err == nil {
t.Fatal("inspection error was swallowed")
}
})
file := filepath.Join(base, "file")
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := ensureResourceDownloadParent(base, file); err == nil {
t.Fatal("file parent was accepted")
}
link := filepath.Join(base, "link-parent")
if err := os.Symlink(base, link); err == nil {
if err := ensureResourceDownloadParent(base, link); err == nil {
t.Fatal("symlink parent was accepted")
}
}
}
func TestCrossPlatformCoverageResourceDownloadFilenameFallbacks(t *testing.T) {
for _, resourceURL := range []string{"://bad", "https://example.test/", "https://example.test/%zz"} {
if got := resourceDownloadFilename(resourceURL); got != "download" {
t.Errorf("resourceDownloadFilename(%q) = %q", resourceURL, got)
}
}
if got := resourceDownloadFilename("https://example.test/a%20b.txt"); got != "a b.txt" {
t.Fatalf("decoded filename = %q", got)
}
}
type resourceRoundTripper func(*http.Request) (*http.Response, error)
func (f resourceRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request)
}
func resourceResponseClient(status int, body string, length int64) *http.Client {
return &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: length,
Header: make(http.Header),
}, nil
})}
}
func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
dest := filepath.Join(t.TempDir(), "resource")
if _, err := downloadResourceAtomically(context.Background(), nil, ":", nil, dest, false); err == nil {
t.Fatal("invalid request URL was accepted")
}
errorClient := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport")
})}
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://example.test/file", nil, dest, false); err == nil {
t.Fatal("transport error was swallowed")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://example.test/file", nil, dest, false); err == nil {
t.Fatal("HTTP 500 was accepted")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://example.test/file", nil, dest, false); err == nil {
t.Fatal("content-length mismatch was accepted")
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Test") != "ok" {
t.Errorf("missing forwarded header")
}
_, _ = w.Write([]byte("body"))
}))
t.Cleanup(plain.Close)
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
if _, err := downloadResourceAtomically(context.Background(), nil, plain.URL, map[string]string{"X-Test": "ok"}, nilClientDest, true); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) {
httpRedirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, "http://example.test/file", http.StatusFound)
}))
t.Cleanup(httpRedirect.Close)
if _, err := downloadResourceAtomically(
context.Background(), httpRedirect.Client(), httpRedirect.URL, nil,
filepath.Join(t.TempDir(), "http-redirect"), false,
); err == nil {
t.Fatal("HTTP redirect was accepted")
}
var loop *httptest.Server
loop = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, loop.URL, http.StatusFound)
}))
t.Cleanup(loop.Close)
if _, err := downloadResourceAtomically(
context.Background(), loop.Client(), loop.URL, nil,
filepath.Join(t.TempDir(), "loop"), false,
); err == nil {
t.Fatal("redirect loop was accepted")
}
original := loop.Client()
original.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("original redirect policy")
}
if _, err := downloadResourceAtomically(
context.Background(), original, loop.URL, nil,
filepath.Join(t.TempDir(), "original"), false,
); err == nil {
t.Fatal("original redirect rejection was swallowed")
}
}
func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
client := resourceResponseClient(http.StatusOK, "body", 4)
run := func(t *testing.T, overwrite bool) error {
t.Helper()
_, err := downloadResourceAtomically(
context.Background(), client, "https://example.test/file", nil,
filepath.Join(t.TempDir(), "resource"), overwrite)
return err
}
t.Run("create temp", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceCreateTemp = func(string, string) (*os.File, error) { return nil, errors.New("create") }
if err := run(t, false); err == nil {
t.Fatal("create-temp error was swallowed")
}
})
t.Run("copy", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceCopy = func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy") }
if err := run(t, false); err == nil {
t.Fatal("copy error was swallowed")
}
})
t.Run("sync", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceTempSync = func(*os.File) error { return errors.New("sync") }
if err := run(t, false); err == nil {
t.Fatal("sync error was swallowed")
}
})
t.Run("close", func(t *testing.T) {
resetResourceDownloadHooks(t)
calls := 0
resourceTempClose = func(file *os.File) error {
calls++
if calls == 1 {
return errors.New("close")
}
return file.Close()
}
if err := run(t, false); err == nil {
t.Fatal("close error was swallowed")
}
})
t.Run("rename", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceRename = func(string, string) error { return errors.New("rename") }
if err := run(t, true); err == nil {
t.Fatal("rename error was swallowed")
}
})
t.Run("link exists", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceLink = func(string, string) error { return os.ErrExist }
if err := run(t, false); err == nil {
t.Fatal("link-exists error was swallowed")
}
})
t.Run("link other", func(t *testing.T) {
resetResourceDownloadHooks(t)
resourceLink = func(string, string) error { return errors.New("link") }
if err := run(t, false); err == nil {
t.Fatal("link error was swallowed")
}
})
}
func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T) {
resetResourceDownloadHooks(t)
var copied bytes.Buffer
resourceCopy = func(writer io.Writer, reader io.Reader) (int64, error) {
body, err := io.ReadAll(reader)
if err != nil {
return 0, err
}
copied.Write(body)
written, writeErr := writer.Write(body)
return int64(written), writeErr
}
dest := filepath.Join(t.TempDir(), "resource")
if _, err := downloadResourceAtomically(
context.Background(), resourceResponseClient(200, "ok", 2),
"https://example.test/file", nil, dest, true,
); err != nil {
t.Fatal(err)
}
if copied.String() != "ok" {
t.Fatalf("copied = %q", copied.String())
}
}
+1 -4
View File
@@ -135,10 +135,7 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
switch identity {
case "user":
content, err := json.Marshal(map[string]string{"title": title, "text": body})
if err != nil {
return err
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
@@ -0,0 +1,119 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chatmsg
import (
"encoding/json"
"reflect"
"testing"
)
func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
quoted := QuotedMessage(map[string]any{
"quotedMessage": map[string]any{
"openMessageId": "msg-quoted",
"msgType": "file",
"content": `{"mediaId":"@quoted-file"}`,
},
})
if quoted["messageType"] != "file" || len(quoted["resourceRefs"].([]map[string]any)) != 1 {
t.Fatalf("quoted projection = %#v", quoted)
}
if firstMessageValue(map[string]any{"a": " ", "b": "value"}, "a", "b") != "value" {
t.Fatal("blank string did not fall through")
}
if Resources(nil) != nil {
t.Fatal("nil message returned resources")
}
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"mediaId": 42},
},
})
if len(resources) != 1 || resources[0]["resourceId"] != "@file-a" {
t.Fatalf("resources = %#v", resources)
}
if got := mediaIDScalar(42); got != "" {
t.Fatalf("non-string media ID = %q", got)
}
}
func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) {
got := Reactions(map[string]any{
"reactions": []map[string]any{
{"emoji": " ", "count": 0},
{"emojiName": "赞", "replyUsers": []string{"u1"}, "replyCount": "1"},
{"reactionType": "笑", "operators": []any{"u2"}, "reactionCount": json.Number("2")},
},
})
counts := got["counts"].([]map[string]any)
details := got["details"].([]map[string]any)
if len(counts) != 2 || len(details) != 2 {
t.Fatalf("reactions = %#v", got)
}
if firstReactionValue(map[string]any{"a": " ", "b": "ok"}, "a", "b") != "ok" {
t.Fatal("blank reaction value did not fall through")
}
if users := reactionUsers(map[string]any{"users": []string{"u1", "u2"}}); !reflect.DeepEqual(users, []any{"u1", "u2"}) {
t.Fatalf("users = %#v", users)
}
for _, tc := range []struct {
value any
want any
}{
{int(1), int(1)},
{int32(2), int32(2)},
{int64(3), int64(3)},
{float32(4), float32(4)},
{float64(5), float64(5)},
{json.Number("6"), json.Number("6")},
{"7", "7"},
} {
if got := reactionCount(map[string]any{"count": tc.value}, 9); !reflect.DeepEqual(got, tc.want) {
t.Errorf("reactionCount(%T) = %#v, want %#v", tc.value, got, tc.want)
}
}
if got := Reactions(map[string]any{"reactions": []any{"invalid", map[string]any{}}}); got != nil {
t.Fatalf("empty reaction rows = %#v", got)
}
}
func TestCrossPlatformCoveragePaginationVariants(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{"hasMore": true}, []map[string]any{{}}, "older")
if _, ok := payload["nextPage"]; ok {
t.Fatalf("missing time produced next page: %#v", payload)
}
if Pagination(nil) != nil {
t.Fatal("nil pagination was non-nil")
}
page := Pagination(map[string]any{"data": map[string]any{
"has_more": true,
"next_token": int64(8),
}})
if page["hasMore"] != true || page["nextCursor"] != int64(8) {
t.Fatalf("page = %#v", page)
}
for _, tc := range []struct {
value any
want bool
}{
{nil, false},
{" ", false},
{"0", false},
{"cursor", true},
{int(0), false},
{int(1), true},
{int64(0), false},
{int64(1), true},
{float64(0), false},
{float64(1), true},
{true, true},
} {
if got := paginationValuePresent(tc.value); got != tc.want {
t.Errorf("paginationValuePresent(%#v) = %v, want %v", tc.value, got, tc.want)
}
}
}
@@ -0,0 +1,209 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package shortcut
import (
"context"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) {
cases := map[string]string{
"json": `{`,
"header": `{
"version": 2,
"service": "chat",
"default_availability": "available",
"shortcuts": {}
}`,
"command": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": true,
"reviewed": true
}
}
}`,
"review": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "",
"risk": "read",
"public": true,
"reviewed": false
}
}
}`,
"disposition": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "unknown",
"semantic_delta": "reviewed",
"risk": "read",
"public": true,
"reviewed": true
}
}
}`,
"risk": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "unknown",
"public": true,
"reviewed": true
}
}
}`,
"availability": `{
"version": 1,
"service": "chat",
"default_availability": "unknown",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": false,
"reviewed": true
}
}
}`,
"alias": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "alias_internal",
"semantic_delta": "reviewed",
"risk": "read",
"public": false,
"reviewed": true
}
}
}`,
"public unavailable": `{
"version": 1,
"service": "chat",
"default_availability": "available",
"shortcuts": {
"+messages": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"availability": "unavailable",
"public": true,
"reviewed": true
}
}
}`,
}
original := semanticCatalogJSON
t.Cleanup(func() { semanticCatalogJSON = original })
for name, payload := range cases {
t.Run(name, func(t *testing.T) {
semanticCatalogJSON = []byte(payload)
defer func() {
if recover() == nil {
t.Fatal("invalid semantic catalog did not panic")
}
}()
_ = mustLoadSemanticCatalog()
})
}
}
func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) {
if !InPublicCatalog("chat", "+messages-send") {
t.Fatal("reviewed public semantic shortcut is missing")
}
if InPublicCatalog("chat", "+conversation-mute-at-all") {
t.Fatal("reviewed unavailable semantic shortcut is public")
}
if InPublicCatalog("unknown", "+missing") {
t.Fatal("unknown shortcut is public")
}
}
func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) {
caller := &runtimeReadCoverageCaller{}
old := helpers.GetCaller()
t.Cleanup(func() { helpers.InitDeps(old) })
helpers.InitDeps(caller)
rt := &RuntimeContext{}
caller.text = ""
if got, err := rt.callMCPReadData("im", "search_groups", nil); err != nil || len(got) != 0 {
t.Fatalf("empty read = %#v, %v", got, err)
}
if caller.args == nil {
t.Fatal("nil read parameters were not normalized")
}
caller.err = errors.New("read failed")
if _, err := rt.callMCPReadData("im", "search_groups", nil); err == nil {
t.Fatal("read error was swallowed")
}
caller.err = nil
caller.text = `not-json`
if _, err := rt.callMCPReadData("im", "search_groups", map[string]any{"keyword": "x"}); err == nil {
t.Fatal("invalid read JSON was accepted")
}
caller.text = `{"result":{"groups":[]}}`
got, err := rt.callMCPReadData("im", "search_groups", map[string]any{"keyword": "x"})
if err != nil || !strings.Contains(caller.text, "groups") || got["result"] == nil {
t.Fatalf("valid read = %#v, %v", got, err)
}
}
type runtimeReadCoverageCaller struct {
args map[string]any
text string
err error
}
func (c *runtimeReadCoverageCaller) CallTool(
_ context.Context,
_ string,
_ string,
args map[string]any,
) (*edition.ToolResult, error) {
c.args = args
if c.err != nil {
return nil, c.err
}
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: c.text}},
}, nil
}
func (c *runtimeReadCoverageCaller) Format() string { return "json" }
func (c *runtimeReadCoverageCaller) DryRun() bool { return false }
func (c *runtimeReadCoverageCaller) Fields() string { return "" }
func (c *runtimeReadCoverageCaller) JQ() string { return "" }
@@ -0,0 +1,266 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type smartCoverageCaller struct {
responses map[string][]string
failAt map[string]int
counts map[string]int
}
func (c *smartCoverageCaller) CallTool(
_ context.Context,
product, tool string,
_ map[string]any,
) (*edition.ToolResult, error) {
if c.counts == nil {
c.counts = map[string]int{}
}
key := product + "/" + tool
c.counts[key]++
if c.failAt[key] == c.counts[key] {
return nil, errors.New("fixture failure")
}
responses := c.responses[key]
text := `{"result":[]}`
if len(responses) > 0 {
index := c.counts[key] - 1
if index >= len(responses) {
index = len(responses) - 1
}
text = responses[index]
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *smartCoverageCaller) CallReadTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, product, tool, args)
}
func (*smartCoverageCaller) Format() string { return "json" }
func (*smartCoverageCaller) DryRun() bool { return false }
func (*smartCoverageCaller) Fields() string { return "" }
func (*smartCoverageCaller) JQ() string { return "" }
func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
message := map[string]any{
"openMessageId": "msg",
"openConversationId": "cid",
"threadId": "thread",
"msgType": "text",
"createTime": "1",
"updateTime": "2",
"content": `{"mediaId":"@image"}`,
"emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted body",
},
}
for name, row := range map[string]map[string]any{
"at-me": atMeProject(message),
"chat": projectChatMessage(message),
"search": searchMsgProject(message),
} {
for _, key := range []string{"threadId", "messageType", "updateTime", "quotedMessage", "resourceRefs"} {
if _, ok := row[key]; !ok {
t.Errorf("%s projection missing %s: %#v", name, key, row)
}
}
}
}
func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
"chat/list_individual_chat_message": {`{"result":{"messages":[]}}`},
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.counts["chat/list_individual_chat_message"] != 1 {
t.Fatalf("calls = %#v", caller.counts)
}
}
func TestCrossPlatformCoverageChatMembersListOutcomes(t *testing.T) {
successResponses := map[string][]string{
"chat/get_group_members": {`{"result":{"hasMore":false,"list":[{"memberEmpName":"成员","openDingtalkId":"D-user"}]}}`},
"bot/list_group_bots": {`{"result":{"bots":[{"robotName":"机器人","robotCode":"robot"}]}}`},
}
cases := []struct {
name string
memberArg string
failAt map[string]int
wantError bool
}{
{name: "both success"},
{name: "user only failure", memberArg: "user", failAt: map[string]int{"chat/get_group_members": 1}, wantError: true},
{name: "bot only failure", memberArg: "bot", failAt: map[string]int{"bot/list_group_bots": 1}, wantError: true},
{name: "both failure", failAt: map[string]int{"chat/get_group_members": 1, "bot/list_group_bots": 1}, wantError: true},
{name: "partial user failure", failAt: map[string]int{"chat/get_group_members": 1}},
{name: "partial bot failure", failAt: map[string]int{"bot/list_group_bots": 1}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: successResponses, failAt: tc.failAt}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
args := []string{"chat", "+chat-members-list", "--conversation-id", "cid"}
if tc.memberArg != "" {
args = append(args, "--member-types", tc.memberArg)
}
root.SetArgs(args)
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
})
}
for _, raw := range [][]string{{""}, {"unknown"}} {
if _, _, err := resolveMemberTypes(raw); err == nil {
t.Fatalf("resolveMemberTypes(%q) succeeded", raw)
}
}
if users, bots, err := resolveMemberTypes(nil); err != nil || !users || !bots {
t.Fatalf("default member types = %v %v %v", users, bots, err)
}
}
func TestCrossPlatformCoverageChatMembersGroupResolutionAndProjection(t *testing.T) {
cases := []struct {
name string
search string
fail bool
wantError bool
}{
{name: "search error", fail: true, wantError: true},
{name: "none", search: `{"result":[]}`, wantError: true},
{name: "ambiguous", search: `{"result":[{"openConversationId":"c1","title":"群"},{"openConversationId":"c2","title":"群"}]}`, wantError: true},
{name: "one", search: `{"result":[{"openConversationId":"c1","title":"群"}]}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
failAt := map[string]int{}
if tc.fail {
failAt["im/search_groups"] = 1
}
caller := &smartCoverageCaller{
responses: map[string][]string{
"im/search_groups": {tc.search},
"chat/get_group_members": {`{"result":{"list":[]}}`},
"bot/list_group_bots": {`{"result":{"bots":[]}}`},
},
failAt: failAt,
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-members-list", "--group", "群"})
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
})
}
bots := groupBotProject(map[string]any{"bots": []any{"invalid", map[string]any{"name": "bot"}}})
if len(bots) != 1 || bots[0]["name"] != "bot" {
t.Fatalf("bots = %#v", bots)
}
if groupBotProject(map[string]any{"result": "invalid"}) == nil {
t.Fatal("empty bot projection should be a non-nil empty slice")
}
}
func TestCrossPlatformCoverageSearchValidationAndTimeErrors(t *testing.T) {
cases := [][]string{
{},
{"--query", "x", "--start", "2026-07-01T00:00:00Z"},
{"--query", "x", "--days", "0"},
{"--query", "x", "--limit", "101"},
{"--query", "x", "--page-limit", "0"},
{"--query", "x", "--start", "bad", "--end", "2026-07-02T00:00:00Z"},
{"--query", "x", "--start", "2026-07-01T00:00:00Z", "--end", "bad"},
{"--query", "x", "--start", "2026-07-02T00:00:00Z", "--end", "2026-07-01T00:00:00Z"},
}
for _, tail := range cases {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+search-msg"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid search args succeeded: %v", tail)
}
}
}
func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) {
cases := []struct {
name string
responses []string
failAt int
args []string
wantError bool
}{
{name: "initial error", failAt: 1, args: []string{"--query", "x"}, wantError: true},
{
name: "duplicate and inferred cursor",
responses: []string{
`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m1"}],"nextCursor":"c2"}}`,
`{"result":{"messages":[],"hasMore":false}}`,
},
args: []string{"--query", "x", "--page-all", "--no-enrich"},
},
{
name: "stalled cursor",
responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":"same"}}`},
args: []string{"--query", "x", "--cursor", "same", "--page-all", "--no-enrich"},
},
{
name: "page limit",
responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":"next"}}`},
args: []string{"--query", "x", "--page-all", "--page-limit", "1", "--no-enrich"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{
responses: map[string][]string{"im/search_messages": tc.responses},
failAt: map[string]int{"im/search_messages": tc.failAt},
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
}
if err == nil {
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatalf("decode %q: %v", strings.TrimSpace(output.String()), decodeErr)
}
if payload["complete"] != false && tc.name != "duplicate and inferred cursor" {
t.Fatalf("payload = %#v", payload)
}
}
})
}
}
+33
View File
@@ -42,6 +42,39 @@ func TestCrossPlatformCoverageShortcutListDeclaresRuntimeSchemaDelivery(t *testi
}
}
func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) {
shortcut.Register(shortcut.Shortcut{
Service: "coverage-usage",
Command: "+hidden",
})
execute := func(args ...string) map[string]any {
t.Helper()
cmd := newListCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
return payload
}
publicRows := execute("--service", "coverage-usage")
allRows := execute("--service", "coverage-usage", "--all")
if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 {
t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"])
}
missing := execute("--service", "__missing__")
if missing["count"].(float64) != 0 {
t.Fatalf("missing service returned shortcuts: %#v", missing)
}
}
func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) {
row := newShortcutListRow(shortcut.Shortcut{
Service: "chat",