631 lines
19 KiB
Go
631 lines
19 KiB
Go
package helpers
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||
"github.com/spf13/cobra"
|
||
)
|
||
|
||
type captureRunner struct {
|
||
last executor.Invocation
|
||
}
|
||
|
||
func (r *captureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||
r.last = invocation
|
||
return executor.Result{Invocation: invocation}, nil
|
||
}
|
||
|
||
func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) {
|
||
t.Setenv("DWS_"+"BUILD_MODE", "real")
|
||
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendByBotCommand(runner)
|
||
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{
|
||
"--users", "user-001",
|
||
"--robot-code", "robot-001",
|
||
"--title", "Greeting",
|
||
"--text", "hello",
|
||
})
|
||
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
|
||
if got := runner.last.Tool; got != "batch_send_robot_msg_to_users" {
|
||
t.Fatalf("tool = %q, want batch_send_robot_msg_to_users", got)
|
||
}
|
||
if got := runner.last.Params["robotCode"]; got != "robot-001" {
|
||
t.Fatalf("robotCode = %#v, want robot-001", got)
|
||
}
|
||
if got := runner.last.CanonicalProduct; got != "bot" {
|
||
t.Fatalf("CanonicalProduct = %q, want bot", got)
|
||
}
|
||
}
|
||
|
||
func TestChatMessageSendRoutesByDestination(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
wantTool string
|
||
wantKey string
|
||
wantValue string
|
||
}{
|
||
{
|
||
// 群聊对齐 wukong:tool=send_personal_message,会话键=openConversationId
|
||
name: "group",
|
||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||
wantTool: "send_personal_message",
|
||
wantKey: "openConversationId",
|
||
wantValue: "cid-xyz",
|
||
},
|
||
{
|
||
name: "user-direct",
|
||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||
wantTool: "send_direct_message_as_user",
|
||
wantKey: "receiverUserId",
|
||
wantValue: "034766",
|
||
},
|
||
{
|
||
// openDingTalkId 单聊也走 send_personal_message(content 携带正文)
|
||
name: "open-dingtalk-id-direct",
|
||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||
wantTool: "send_personal_message",
|
||
wantKey: "receiverOpenDingTalkId",
|
||
wantValue: "OP123",
|
||
},
|
||
{
|
||
// 群聊正文打包进 content JSON(键序按 encoding/json 字典序:text 在 title 前)
|
||
name: "positional-text",
|
||
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
|
||
wantTool: "send_personal_message",
|
||
wantKey: "content",
|
||
wantValue: `{"text":"hello from positional","title":"t"}`,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if got := runner.last.Tool; got != tc.wantTool {
|
||
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
|
||
}
|
||
if got := runner.last.CanonicalProduct; got != "chat" {
|
||
t.Fatalf("CanonicalProduct = %q, want chat", got)
|
||
}
|
||
if got, ok := runner.last.Params[tc.wantKey]; !ok || got != tc.wantValue {
|
||
t.Fatalf("Params[%q] = %#v, want %q", tc.wantKey, got, tc.wantValue)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
wantErr string
|
||
}{
|
||
{
|
||
name: "no-destination",
|
||
args: []string{"--text", "hi"},
|
||
wantErr: "one of --group, --user, or --open-dingtalk-id is required",
|
||
},
|
||
{
|
||
name: "group-and-user",
|
||
args: []string{"--group", "cid-x", "--user", "034766", "--text", "hi"},
|
||
wantErr: "--group, --user, and --open-dingtalk-id are mutually exclusive",
|
||
},
|
||
{
|
||
name: "empty-text",
|
||
args: []string{"--group", "cid-x"},
|
||
wantErr: "--text (or positional argument) is required",
|
||
},
|
||
// 注:--title 不再强制必填——缺省时由 deriveTitleFromText 从正文自动派生
|
||
// (对齐 wukong),故原 *-without-title 的"必须报错"用例已随实现移除。
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
err := cmd.Execute()
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil; output: %s", out.String())
|
||
}
|
||
if got := err.Error(); !strings.Contains(got, tc.wantErr) {
|
||
t.Fatalf("error = %q, want to contain %q", got, tc.wantErr)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatMessageSendForwardsAtMentions guards that group @-mentions survive the
|
||
// destination-based routing. After aligning `send` with wukong, group messages go
|
||
// through the send_personal_message tool and the @ surface is --at-all (→ atAll)
|
||
// and --at-open-dingtalk-ids (→ atOpenDingTalkIds, openDingTalkId-based). The
|
||
// pre-wukong envelope flags (--at-users / --at-mobiles) no longer exist on `send`;
|
||
// regressing them would resurface `unknown flag: --at-...` (issue #177).
|
||
func TestChatMessageSendForwardsAtMentions(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
wantParams map[string]any
|
||
}{
|
||
{
|
||
name: "group-with-at-open-dingtalk-ids",
|
||
args: []string{
|
||
"--group", "cid-xyz",
|
||
"--title", "拉群通知",
|
||
"--text", "<@op-1> <@op-2> 请关注",
|
||
"--at-open-dingtalk-ids", "op-1,op-2",
|
||
},
|
||
wantParams: map[string]any{
|
||
"openConversationId": "cid-xyz",
|
||
"atOpenDingTalkIds": []string{"op-1", "op-2"},
|
||
},
|
||
},
|
||
{
|
||
name: "group-with-at-all",
|
||
args: []string{
|
||
"--group", "cid-xyz",
|
||
"--title", "全员通知",
|
||
"--text", "<@all> 请关注",
|
||
"--at-all",
|
||
},
|
||
wantParams: map[string]any{
|
||
"openConversationId": "cid-xyz",
|
||
"atAll": true,
|
||
},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if got := runner.last.Tool; got != "send_personal_message" {
|
||
t.Fatalf("Tool = %q, want send_personal_message", got)
|
||
}
|
||
for key, want := range tc.wantParams {
|
||
got, ok := runner.last.Params[key]
|
||
if !ok {
|
||
t.Fatalf("Params missing %q; got %#v", key, runner.last.Params)
|
||
}
|
||
if !equalAny(got, want) {
|
||
t.Fatalf("Params[%q] = %#v, want %#v", key, got, want)
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatMessageSendContentNotHTMLEscaped guards the @-mention rendering fix:
|
||
// the send_personal_message content must keep literal <@openDingTalkId> / <@all>
|
||
// tokens. If json.Marshal's default HTML escaping is reintroduced, the tokens
|
||
// become <@...> and the DingTalk client renders them as plain text
|
||
// instead of a real @-mention.
|
||
func TestChatMessageSendContentNotHTMLEscaped(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
want string // literal token that must survive in content
|
||
}{
|
||
{
|
||
name: "group-at-all",
|
||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@all> hi", "--at-all"},
|
||
want: "<@all>",
|
||
},
|
||
{
|
||
name: "group-at-open-dingtalk-id",
|
||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "<@op-1> hi", "--at-open-dingtalk-ids", "op-1"},
|
||
want: "<@op-1>",
|
||
},
|
||
{
|
||
name: "direct-open-dingtalk-id",
|
||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "<@OP123> hi"},
|
||
want: "<@OP123>",
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
content, _ := runner.last.Params["content"].(string)
|
||
if !strings.Contains(content, tc.want) {
|
||
t.Fatalf("content %q missing literal %q (HTML-escaped?)", content, tc.want)
|
||
}
|
||
if strings.Contains(content, "\\u003c") || strings.Contains(content, "\\u003e") {
|
||
t.Fatalf("content %q is HTML-escaped; @-mention will not render", content)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatMessageSendRejectsAtMentionsOutsideGroup ensures we do not silently
|
||
// drop user intent when --at-* is combined with --user / --open-dingtalk-id
|
||
// (single-chat tools have no @-mention semantics, so the flag would never
|
||
// take effect — fail loudly instead of swallowing).
|
||
func TestChatMessageSendRejectsAtMentionsOutsideGroup(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
}{
|
||
{
|
||
name: "user-with-at-open-dingtalk-ids",
|
||
args: []string{"--user", "034766", "--text", "hi", "--at-open-dingtalk-ids", "op-1"},
|
||
},
|
||
{
|
||
name: "open-dingtalk-id-with-at-all",
|
||
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi", "--at-all"},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
err := cmd.Execute()
|
||
if err == nil {
|
||
t.Fatalf("expected error, got nil; output: %s", out.String())
|
||
}
|
||
if !strings.Contains(err.Error(), "only apply when --group is set") {
|
||
t.Fatalf("error = %q, want '...only apply when --group is set'", err.Error())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatMessageAITagControlsClawType guards the opt-in "Send from AI" indicator:
|
||
// by default NO user-identity send carries the clawType tool argument (so the IM
|
||
// server renders no AI badge). Only when --ai-tag is passed does each path attach
|
||
// the edition claw identity (open-source build pins it to edition.DefaultOSSClawType,
|
||
// "openClaw"); the wukong overlay would attach its own value. The label is opt-in so
|
||
// dws does not surprise users by branding every message they send.
|
||
func TestChatMessageAITagControlsClawType(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
make func(runner executor.Runner) *cobra.Command
|
||
args []string
|
||
}{
|
||
{
|
||
name: "group-markdown",
|
||
make: newChatMessageSendCommand,
|
||
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
|
||
},
|
||
{
|
||
name: "user-direct",
|
||
make: newChatMessageSendCommand,
|
||
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
|
||
},
|
||
{
|
||
name: "open-dingtalk-id-direct",
|
||
make: newChatMessageSendCommand,
|
||
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
|
||
},
|
||
{
|
||
name: "group-rich-media-image",
|
||
make: newChatMessageSendCommand,
|
||
args: []string{"--group", "cid-xyz", "--msg-type", "image", "--media-id", "media-1"},
|
||
},
|
||
{
|
||
name: "reply",
|
||
make: newChatMessageReplyCommand,
|
||
args: []string{
|
||
"--conversation-id", "cid-xyz",
|
||
"--ref-msg-id", "msg-1",
|
||
"--ref-sender", "op-1",
|
||
"--text", "got it",
|
||
},
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
// Default: no --ai-tag → ai-tag defaults to true → must attach clawType.
|
||
t.Run(tc.name+"/default-has-tag", func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := tc.make(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
got, ok := runner.last.Params["clawType"]
|
||
if !ok {
|
||
t.Fatalf("default send must attach clawType (ai-tag defaults true); got %#v", runner.last.Params)
|
||
}
|
||
if got != edition.DefaultOSSClawType {
|
||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||
}
|
||
})
|
||
// Opt-out: --ai-tag=false → omit clawType entirely (no badge).
|
||
t.Run(tc.name+"/ai-tag-false", func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := tc.make(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag=false"))
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if v, ok := runner.last.Params["clawType"]; ok {
|
||
t.Fatalf("--ai-tag=false must omit clawType, got %#v", v)
|
||
}
|
||
})
|
||
// Opt-in (explicit): --ai-tag → attach the edition claw identity.
|
||
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := tc.make(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag"))
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
got, ok := runner.last.Params["clawType"]
|
||
if !ok {
|
||
t.Fatalf("--ai-tag send missing clawType; got %#v", runner.last.Params)
|
||
}
|
||
if got != edition.DefaultOSSClawType {
|
||
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// Robot sends are rendered as bot messages already; they must NOT carry the
|
||
// user-identity clawType argument.
|
||
func TestChatMessageSendByBotOmitsClawType(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendByBotCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{"--group", "cid-xyz", "--robot-code", "robot-001", "--title", "t", "--text", "x"})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if _, ok := runner.last.Params["clawType"]; ok {
|
||
t.Fatalf("bot send must not carry clawType; got %#v", runner.last.Params)
|
||
}
|
||
}
|
||
|
||
// send-by-bot --at-user-ids: userIds go into atUserIds AND the body's
|
||
// `<@userId>` placeholder is rewritten to the `@userId ` chip form. Regression
|
||
// guard for 勤泽 2026-07-04 report where the body still shipped raw `<@id>`.
|
||
func TestChatMessageSendByBotAtUserIDsRendersChip(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendByBotCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{
|
||
"--group", "cid-xyz",
|
||
"--robot-code", "robot-001",
|
||
"--title", "t",
|
||
"--text", "<@u1> ping",
|
||
"--at-user-ids", "u1",
|
||
})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
got, ok := runner.last.Params["markdown"].(string)
|
||
if !ok {
|
||
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
|
||
}
|
||
if !strings.Contains(got, "@u1 ") {
|
||
t.Fatalf("markdown should contain rewritten @u1 chip, got %q", got)
|
||
}
|
||
if strings.Contains(got, "<@u1>") {
|
||
t.Fatalf("markdown should NOT contain raw placeholder <@u1>, got %q", got)
|
||
}
|
||
users, ok := runner.last.Params["atUserIds"].([]any)
|
||
if !ok || len(users) != 1 || users[0] != "u1" {
|
||
t.Fatalf("atUserIds = %#v, want [u1]", runner.last.Params["atUserIds"])
|
||
}
|
||
}
|
||
|
||
// send-by-bot --at-open-dingtalk-ids: the openDingTalkId is forwarded verbatim
|
||
// as atOpendingtalkIds (the server's lowercase spelling — the camelCase
|
||
// atOpenDingTalkIds is silently ignored), and the `<@openId>` placeholder is
|
||
// rewritten to `@openId` in the body so the chip renders. This is what makes
|
||
// @-ing a bot (whose only id is an openDingTalkId) actually deliver.
|
||
func TestChatMessageSendByBotAtOpenIDsRendersChip(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendByBotCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs([]string{
|
||
"--group", "cid-xyz",
|
||
"--robot-code", "robot-001",
|
||
"--title", "t",
|
||
"--text", "<@op1> ping",
|
||
"--at-open-dingtalk-ids", "op1",
|
||
})
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
got, ok := runner.last.Params["markdown"].(string)
|
||
if !ok {
|
||
t.Fatalf("markdown missing or not string; got %#v", runner.last.Params["markdown"])
|
||
}
|
||
if !strings.Contains(got, "@op1 ") {
|
||
t.Fatalf("markdown should contain rewritten @op1 chip, got %q", got)
|
||
}
|
||
if strings.Contains(got, "<@op1>") {
|
||
t.Fatalf("markdown should NOT contain raw placeholder <@op1>, got %q", got)
|
||
}
|
||
opens, ok := runner.last.Params["atOpendingtalkIds"].([]any)
|
||
if !ok || len(opens) != 1 || opens[0] != "op1" {
|
||
t.Fatalf("atOpendingtalkIds = %#v, want [op1]", runner.last.Params["atOpendingtalkIds"])
|
||
}
|
||
// The camelCase field must NOT be set (the server ignores it).
|
||
if _, exists := runner.last.Params["atOpenDingTalkIds"]; exists {
|
||
t.Fatalf("must not send camelCase atOpenDingTalkIds; params=%#v", runner.last.Params)
|
||
}
|
||
}
|
||
|
||
func equalAny(a, b any) bool {
|
||
switch av := a.(type) {
|
||
case []any:
|
||
bv, ok := b.([]any)
|
||
if !ok || len(av) != len(bv) {
|
||
return false
|
||
}
|
||
for i := range av {
|
||
if av[i] != bv[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
case []string:
|
||
// splitCSVStrings 产出 []string(如 atOpenDingTalkIds),用例期望值也写成 []string
|
||
bv, ok := b.([]string)
|
||
if !ok || len(av) != len(bv) {
|
||
return false
|
||
}
|
||
for i := range av {
|
||
if av[i] != bv[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
default:
|
||
return a == b
|
||
}
|
||
}
|
||
|
||
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
args []string
|
||
wantTool string
|
||
}{
|
||
{
|
||
name: "single-chat",
|
||
args: []string{
|
||
"--users", "user-001",
|
||
"--robot-code", "robot-001",
|
||
"--title", "t",
|
||
"--text", "x",
|
||
},
|
||
wantTool: "batch_send_robot_msg_to_users",
|
||
},
|
||
{
|
||
name: "group-chat",
|
||
args: []string{
|
||
"--group", "cid-xyz",
|
||
"--robot-code", "robot-001",
|
||
"--title", "t",
|
||
"--text", "x",
|
||
},
|
||
wantTool: "send_robot_group_message",
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
runner := &captureRunner{}
|
||
cmd := newChatMessageSendByBotCommand(runner)
|
||
var out bytes.Buffer
|
||
cmd.SetOut(&out)
|
||
cmd.SetErr(&out)
|
||
cmd.SetArgs(tc.args)
|
||
if err := cmd.Execute(); err != nil {
|
||
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
|
||
}
|
||
if got := runner.last.CanonicalProduct; got != "bot" {
|
||
t.Fatalf("CanonicalProduct = %q, want bot", got)
|
||
}
|
||
if got := runner.last.Tool; got != tc.wantTool {
|
||
t.Fatalf("Tool = %q, want %q", got, tc.wantTool)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRenderAtMentions(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
body string
|
||
userIDs []string
|
||
want string
|
||
}{
|
||
{
|
||
name: "empty userIDs is no-op",
|
||
body: "hello",
|
||
userIDs: nil,
|
||
want: "hello",
|
||
},
|
||
{
|
||
name: "angle placeholder rewritten",
|
||
body: "<@u001> 请查收",
|
||
userIDs: []string{"u001"},
|
||
want: "@u001 请查收",
|
||
},
|
||
{
|
||
name: "missing mention auto-prepended",
|
||
body: "请查收",
|
||
userIDs: []string{"u001", "u002"},
|
||
want: "@u001 @u002 请查收",
|
||
},
|
||
{
|
||
name: "bare @userId already present, no prefix",
|
||
body: "hi @u001 please",
|
||
userIDs: []string{"u001"},
|
||
want: "hi @u001 please",
|
||
},
|
||
{
|
||
name: "mixed: one placeholder + one missing",
|
||
body: "<@u001> ping",
|
||
userIDs: []string{"u001", "u002"},
|
||
want: "@u002 @u001 ping",
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := renderAtMentions(tc.body, tc.userIDs)
|
||
if got != tc.want {
|
||
t.Fatalf("renderAtMentions(%q, %v) = %q, want %q", tc.body, tc.userIDs, got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|