Files

495 lines
18 KiB
Go

// 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/search_contact_by_key_word"},
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/search_contact_by_key_word": `{"result":[]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "没有精确匹配",
},
{
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", "--yes"})
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
}