diff --git a/internal/app/dry_run_guards_test.go b/internal/app/dry_run_guards_test.go index 1cb57e41..fbe6d6a2 100644 --- a/internal/app/dry_run_guards_test.go +++ b/internal/app/dry_run_guards_test.go @@ -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") +} diff --git a/internal/app/usage_recording_test.go b/internal/app/usage_recording_test.go index 3b9705d4..a8f45268 100644 --- a/internal/app/usage_recording_test.go +++ b/internal/app/usage_recording_test.go @@ -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) diff --git a/internal/helpers/chat_category_title_test.go b/internal/helpers/chat_category_title_test.go index 710e177f..3c85ac66 100644 --- a/internal/helpers/chat_category_title_test.go +++ b/internal/helpers/chat_category_title_test.go @@ -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 "" } diff --git a/internal/helpers/helpers_core_test.go b/internal/helpers/helpers_core_test.go index 669c24c7..6896fea3 100644 --- a/internal/helpers/helpers_core_test.go +++ b/internal/helpers/helpers_core_test.go @@ -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 diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 98151318..12a385b1 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -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{ diff --git a/internal/shortcut/chat/chat_message_coverage_test.go b/internal/shortcut/chat/chat_message_coverage_test.go new file mode 100644 index 00000000..812d7b21 --- /dev/null +++ b/internal/shortcut/chat/chat_message_coverage_test.go @@ -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) + } + }) + } +} diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go new file mode 100644 index 00000000..06708738 --- /dev/null +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -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 +} diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 77f03dc2..9477a3ba 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -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", diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index 8247586c..5aeaf5f3 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -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 } diff --git a/internal/shortcut/chat/resource_download.go b/internal/shortcut/chat/resource_download.go index 768d4e75..65e232cf 100644 --- a/internal/shortcut/chat/resource_download.go +++ b/internal/shortcut/chat/resource_download.go @@ -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") diff --git a/internal/shortcut/chat/resource_download_coverage_test.go b/internal/shortcut/chat/resource_download_coverage_test.go new file mode 100644 index 00000000..d6f7c2fb --- /dev/null +++ b/internal/shortcut/chat/resource_download_coverage_test.go @@ -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()) + } +} diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index deeff7b3..f9c51fc8 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -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), diff --git a/internal/shortcut/chatmsg/coverage_completion_test.go b/internal/shortcut/chatmsg/coverage_completion_test.go new file mode 100644 index 00000000..52eda4de --- /dev/null +++ b/internal/shortcut/chatmsg/coverage_completion_test.go @@ -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) + } + } +} diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go new file mode 100644 index 00000000..8c3e3c4b --- /dev/null +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -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 "" } diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go new file mode 100644 index 00000000..426f474f --- /dev/null +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index da9f56a3..3d1a103c 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -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",