780 lines
30 KiB
Go
780 lines
30 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package chat
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"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 TestCrossPlatformCoverageConversationListFailureBoundaries(t *testing.T) {
|
|
for _, tail := range [][]string{
|
|
{"--limit", "0"},
|
|
{"--limit", "101"},
|
|
{"--page-limit", "2"},
|
|
{"--page-all", "--page-limit", "0"},
|
|
{"--page-all", "--page-limit", "501"},
|
|
} {
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs(append([]string{"chat", "+conversation-list"}, tail...))
|
|
if err := root.Execute(); err == nil {
|
|
t.Errorf("invalid conversation args succeeded: %v", tail)
|
|
}
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
caller *larkAlignmentCaller
|
|
args []string
|
|
wantError bool
|
|
}{
|
|
{name: "first read failure", caller: &larkAlignmentCaller{failProductTool: "im/list_all_conversations"}, wantError: true},
|
|
{name: "later read failure", caller: &larkAlignmentCaller{
|
|
sequenceResponses: map[string][]string{"im/list_all_conversations": {`{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}},
|
|
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
|
|
}, args: []string{"--page-all"}},
|
|
{name: "missing pagination", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[]}}`}}},
|
|
{name: "invalid cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":"bad"}}`}}},
|
|
{name: "stalled cursor", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--cursor", "2"}},
|
|
{name: "page limit", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":true,"nextCursor":2}}`}}, args: []string{"--page-all", "--page-limit", "1"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
helpers.InitDeps(tc.caller)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs(append([]string{"chat", "+conversation-list"}, tc.args...))
|
|
err := root.Execute()
|
|
if (err != nil) != tc.wantError {
|
|
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
|
|
}
|
|
})
|
|
}
|
|
|
|
for value, want := range map[any]int64{int(1): 1, int64(2): 2, float64(3): 3, "4": 4} {
|
|
got, err := conversationPaginationCursor(value)
|
|
if err != nil || got != want {
|
|
t.Fatalf("cursor %#v = %d, %v; want %d", value, got, err, want)
|
|
}
|
|
}
|
|
if _, err := conversationPaginationCursor(struct{}{}); err == nil {
|
|
t.Fatal("unsupported cursor unexpectedly accepted")
|
|
}
|
|
if got := unwrapConversationTuple(nil); got != nil {
|
|
t.Fatalf("empty tuple = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
caller *larkAlignmentCaller
|
|
args []string
|
|
wantError string
|
|
}{
|
|
{
|
|
name: "owner query resolution",
|
|
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
|
|
args: []string{"chat", "+chat-create", "--name", "群", "--users", "u1", "--owner-query", "群主", "--yes"},
|
|
wantError: "fixture lower call failed",
|
|
},
|
|
{
|
|
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",
|
|
},
|
|
{
|
|
name: "chat update resolution",
|
|
caller: &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}},
|
|
args: []string{"chat", "+chat-update", "--group", "missing", "--name", "新群", "--yes"},
|
|
wantError: "没有找到",
|
|
},
|
|
{
|
|
name: "reply write",
|
|
caller: &larkAlignmentCaller{failProductTool: "chat/send_personal_message"},
|
|
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", fixtureCurrentDOpenID, "--text", "收到", "--yes"},
|
|
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)
|
|
}
|
|
|
|
external := &larkAlignmentCaller{responses: map[string]string{
|
|
"contact/search_contact_by_key_word": `{"result":[{"name":"外部联系人","openDingTalkId":"D-external"}]}`,
|
|
}}
|
|
helpers.InitDeps(external)
|
|
root = newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+chat-create", "--name", "群", "--owner-open-dingtalk-id", fixtureCurrentDOpenID, "--member-query", "外部联系人", "--yes"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageNaturalGroupReadFailures(t *testing.T) {
|
|
for _, command := range []string{"+chat-invite-url", "+chat-bots"} {
|
|
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", command, "--group", "missing"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Errorf("%s missing natural group unexpectedly resolved", command)
|
|
}
|
|
}
|
|
}
|
|
|
|
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", "31"},
|
|
{"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 TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
caller *larkAlignmentCaller
|
|
args []string
|
|
}{
|
|
{name: "multiple recall ids", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-recall", "--message-ids", "m1,m2", "--yes"}},
|
|
{name: "recall lookup failure", caller: &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
|
{name: "recall lookup empty", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
|
{name: "recall lookup missing conversation", caller: &larkAlignmentCaller{responses: map[string]string{"im/list_messages_by_ids": `{"result":[{"openMessageId":"m1"}]}`}}, args: []string{"chat", "+messages-recall", "--msg-id", "m1", "--yes"}},
|
|
{name: "card status low", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "0", "--yes"}},
|
|
{name: "card status high", caller: &larkAlignmentCaller{}, args: []string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "6", "--yes"}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
helpers.InitDeps(tc.caller)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs(tc.args)
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("failure boundary unexpectedly succeeded")
|
|
}
|
|
})
|
|
}
|
|
|
|
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
|
|
"im/update_streaming_card": `{"result":{"updated":true}}`,
|
|
}})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for value, want := range map[any]int{
|
|
int(1): 1, int32(2): 2, int64(3): 3, float32(4): 4, float64(5): 5, "bad": 0,
|
|
} {
|
|
if got := messageLedgerInt(value); got != want {
|
|
t.Fatalf("messageLedgerInt(%#v) = %d, want %d", value, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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", fixtureCurrentDOpenID, "--text", "x"},
|
|
{"--identity", "user", "--group", "cid", "--at-user-ids", "u1", "--text", "x"},
|
|
{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--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", fixtureCurrentDOpenID, "--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", fixtureCurrentDOpenID, "--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", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"},
|
|
want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true},
|
|
},
|
|
{
|
|
name: "user direct",
|
|
args: []string{"--identity", "user", "--open-dingtalk-id", fixtureCurrentDOpenID, "--text", "x"},
|
|
want: map[string]any{"receiverOpenDingTalkId": fixtureCurrentDOpenID},
|
|
},
|
|
{
|
|
name: "bot group mentions",
|
|
args: []string{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--text", "x", "--at-user-ids", "u1", "--at-open-dingtalk-ids", fixtureCurrentDOpenID},
|
|
want: map[string]any{"atUserIds": []string{"u1"}, "atOpendingtalkIds": []string{fixtureCurrentDOpenID}},
|
|
},
|
|
{
|
|
name: "bot direct targets",
|
|
args: []string{"--identity", "bot", "--robot-code", "r", "--users", "u1", "--open-dingtalk-ids", fixtureCurrentDOpenID, "--text", "x", "--at-all"},
|
|
want: map[string]any{"userIds": []string{"u1"}, "openDingtalkIds": []string{fixtureCurrentDOpenID}, "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 TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
atIDs string
|
|
atAll bool
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "declared member missing from body",
|
|
body: "请查收",
|
|
atIDs: fixtureCurrentDOpenID,
|
|
wantErr: "必须在正文中使用对应",
|
|
},
|
|
{
|
|
name: "declared member only appears as a longer token prefix",
|
|
body: "@" + fixtureCurrentDOpenID + "_suffix 请查收",
|
|
atIDs: fixtureCurrentDOpenID,
|
|
wantErr: "必须在正文中使用对应",
|
|
},
|
|
{
|
|
name: "body member missing from declaration",
|
|
body: "<@" + fixtureCurrentDOpenID + "> 请查收",
|
|
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
|
|
},
|
|
{
|
|
name: "body at-all missing from declaration",
|
|
body: "<@all> 请查收",
|
|
wantErr: "必须同时指定 --at-all",
|
|
},
|
|
{
|
|
name: "bare body member missing from declaration",
|
|
body: "@" + fixtureCurrentDOpenID + " 请查收",
|
|
wantErr: "必须同时通过 --at-open-dingtalk-ids 声明",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fake := &larkAlignmentCaller{}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
args := []string{
|
|
"chat", "+messages-send", "--identity", "user",
|
|
"--group", "cid", "--markdown", tt.body, "--yes",
|
|
}
|
|
if tt.atIDs != "" {
|
|
args = append(args, "--at-open-dingtalk-ids", tt.atIDs)
|
|
}
|
|
if tt.atAll {
|
|
args = append(args, "--at-all")
|
|
}
|
|
root.SetArgs(args)
|
|
err := root.Execute()
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
|
}
|
|
if len(fake.calls) != 0 {
|
|
t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestCrossPlatformCoverageUnifiedSendGroupFileAndBatchBoundaries(t *testing.T) {
|
|
readGroupFile := messagesSendReadGroupFile
|
|
t.Cleanup(func() { messagesSendReadGroupFile = readGroupFile })
|
|
|
|
for _, tail := range [][]string{
|
|
{"--identity", "bot", "--robot-code", "r", "--group", "cid", "--groups", "c1", "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups", "c1", "--groups-file", "groups.txt", "--text", "x"},
|
|
{"--identity", "webhook", "--webhook-token", "token", "--chat-query", "群", "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups", "", "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups", strings.Join(makeIDs(101), ","), "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups-file", "/absolute.txt", "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups-file", "../escape.txt", "--text", "x"},
|
|
{"--identity", "bot", "--robot-code", "r", "--groups-file", "missing.txt", "--text", "x"},
|
|
} {
|
|
helpers.InitDeps(&larkAlignmentCaller{})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs(append([]string{"chat", "+messages-send"}, append(tail, "--yes")...))
|
|
if err := root.Execute(); err == nil {
|
|
t.Errorf("invalid unified send args succeeded: %v", tail)
|
|
}
|
|
}
|
|
|
|
temp := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(temp, "groups-dir"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(temp, "large.txt"), make([]byte, messagesSendMaxGroupFileSize+1), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(temp, "unreadable.txt"), []byte("cid"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
messagesSendReadGroupFile = func(path string) ([]byte, error) {
|
|
if filepath.Base(path) == "unreadable.txt" {
|
|
return nil, os.ErrPermission
|
|
}
|
|
return readGroupFile(path)
|
|
}
|
|
t.Chdir(temp)
|
|
for _, path := range []string{"groups-dir", "large.txt", "unreadable.txt"} {
|
|
helpers.InitDeps(&larkAlignmentCaller{})
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "r", "--groups-file", path, "--text", "x", "--yes"})
|
|
if err := root.Execute(); err == nil {
|
|
t.Errorf("invalid group file %q succeeded", path)
|
|
}
|
|
}
|
|
|
|
fake := &larkAlignmentCaller{}
|
|
helpers.InitDeps(fake)
|
|
root := newPlatformCoverageRoot()
|
|
root.SetArgs([]string{
|
|
"chat", "+messages-send", "--identity", "bot", "--robot-code", "r",
|
|
"--groups", "c1,c2", "--text", "x", "--at-user-ids", "u1",
|
|
"--at-open-dingtalk-ids", fixtureCurrentDOpenID, "--at-all", "--yes",
|
|
})
|
|
if err := root.Execute(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(fake.calls) != 2 {
|
|
t.Fatalf("batch calls = %#v", fake.calls)
|
|
}
|
|
for _, call := range fake.calls {
|
|
if !reflect.DeepEqual(call.args["atUserIds"], []string{"u1"}) ||
|
|
!reflect.DeepEqual(call.args["atOpendingtalkIds"], []string{fixtureCurrentDOpenID}) ||
|
|
call.args["isAtAll"] != "true" {
|
|
t.Fatalf("batch mention args = %#v", call.args)
|
|
}
|
|
}
|
|
|
|
shortcut.Register(shortcut.Shortcut{
|
|
Service: "chat", Command: "+coverage-unified-execute-error",
|
|
Flags: MessagesSend.Flags, Execute: executeMessagesSend,
|
|
})
|
|
root = newPlatformCoverageRoot()
|
|
root.SetArgs([]string{
|
|
"chat", "+coverage-unified-execute-error", "--identity", "bot", "--robot-code", "r",
|
|
"--groups-file", "missing-again.txt", "--text", "x",
|
|
})
|
|
if err := root.Execute(); err == nil {
|
|
t.Fatal("execute-time group file error was swallowed")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|