test(chat): close IM shortcut coverage gaps
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 "" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user