Files
larksuite-cli/shortcuts/im/builders_test.go
YangJunzhou-01 c2e737434c fix(im): clarify messages-send dry-run chat membership (#1150)
clarify messages-send dry-run chat membership
2026-05-28 16:39:00 +08:00

907 lines
33 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
func mustMarshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return string(b)
}
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
// command whose flags are populated from the provided string and bool maps,
// for unit-testing shortcut bodies, validators, and dry-run shapes.
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-limit", 20, "")
for name := range stringFlags {
if name == "page-limit" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// newMessagesSearchTestRuntimeContext is the messages-search variant of
// newTestRuntimeContext: registers the search-specific --page-size flag
// before applying caller-provided values.
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 20, "")
for name := range stringFlags {
if name == "page-size" || name == "page-limit" {
continue
}
cmd.Flags().String(name, "", "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range stringFlags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, val := range boolFlags {
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
// TestBuildCreateChatBody verifies the request body assembled when every
// flag is populated, including the default chat_mode="group".
func TestBuildCreateChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Team Chat",
"description": "daily sync",
"users": "ou_1, ou_2",
"bots": "cli_1, cli_2",
"owner": "ou_owner",
"chat-mode": "group",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "group",
"name": "Team Chat",
"description": "daily sync",
"user_id_list": []string{
"ou_1",
"ou_2",
},
"bot_id_list": []string{
"cli_1",
"cli_2",
},
"owner_id": "ou_owner",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
}
}
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
// chat_mode="topic" in the request body, the topic-chat creation path.
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Topic Group",
"chat-mode": "topic",
}, nil)
got := buildCreateChatBody(runtime)
want := map[string]interface{}{
"chat_type": "public",
"chat_mode": "topic",
"name": "Topic Group",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
}
}
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
// values), but buildCreateChatBody must still emit chat_mode="group" rather
// than an empty string with unspecified server semantics.
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Fallback Test",
"chat-mode": "",
}, nil)
got := buildCreateChatBody(runtime)
if got["chat_mode"] != "group" {
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
}
}
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
func TestSplitMembers(t *testing.T) {
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
want := []string{"ou_1", "ou_2", "ou_3"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("splitMembers() = %#v, want %#v", got, want)
}
}
func TestBuildSearchChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := buildSearchChatBody(runtime)
want := map[string]interface{}{
"query": `"team-alpha"`,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildSearchChatBody() = %#v, want %#v", got, want)
}
}
func TestSplitAndTrimChat(t *testing.T) {
got := common.SplitCSV(" private, , public_joined ,, external ")
want := []string{"private", "public_joined", "external"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("common.SplitCSV() = %#v, want %#v", got, want)
}
}
func TestBuildUpdateChatBody(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"name": "New Name",
"description": "New Description",
}, nil)
got := buildUpdateChatBody(runtime)
want := map[string]interface{}{
"name": "New Name",
"description": "New Description",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("buildUpdateChatBody() = %#v, want %#v", got, want)
}
}
func TestIsMediaKey(t *testing.T) {
tests := []struct {
value string
want bool
}{
{value: "img_123", want: true},
{value: "file_123", want: true},
{value: "/tmp/image.png", want: false},
{value: "video.mp4", want: false},
}
for _, tt := range tests {
if got := isMediaKey(tt.value); got != tt.want {
t.Fatalf("isMediaKey(%q) = %v, want %v", tt.value, got, tt.want)
}
}
}
func TestShortcutValidateBranches(t *testing.T) {
t.Run("ImChatCreate valid", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"type": "public",
"name": "Team Room",
"users": "ou_1,ou_2",
"bots": "cli_1",
"owner": "ou_owner",
}, nil)
if err := ImChatCreate.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImChatCreate.Validate() unexpected error = %v", err)
}
})
t.Run("ImChatCreate name too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"name": strings.Repeat("长", 61),
}, nil)
err := ImChatCreate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--name exceeds the maximum of 60 characters") {
t.Fatalf("ImChatCreate.Validate() error = %v", err)
}
})
t.Run("ImChatCreate description too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"description": strings.Repeat("d", 101),
}, nil)
err := ImChatCreate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") {
t.Fatalf("ImChatCreate.Validate() error = %v", err)
}
})
t.Run("ImChatCreate invalid user id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"users": "ou_1,user_2",
}, nil)
err := ImChatCreate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid user ID format") {
t.Fatalf("ImChatCreate.Validate() error = %v", err)
}
})
t.Run("ImChatCreate too many bots", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"bots": "cli_1,cli_2,cli_3,cli_4,cli_5,cli_6",
}, nil)
err := ImChatCreate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--bots exceeds the maximum of 5") {
t.Fatalf("ImChatCreate.Validate() error = %v", err)
}
})
t.Run("ImChatCreate invalid owner id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"owner": "user_1",
}, nil)
err := ImChatCreate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid user ID format") {
t.Fatalf("ImChatCreate.Validate() error = %v", err)
}
})
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "ok",
"page-size": "0",
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 100") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
t.Run("ImChatSearch query too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": strings.Repeat("q", 65),
}, nil)
err := ImChatSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
t.Fatalf("ImChatSearch.Validate() error = %v", err)
}
})
t.Run("ImChatUpdate requires fields", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
}, nil)
err := ImChatUpdate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "at least one field must be specified") {
t.Fatalf("ImChatUpdate.Validate() error = %v", err)
}
})
t.Run("ImChatUpdate invalid chat id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "bad_chat",
"name": "new",
}, nil)
err := ImChatUpdate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid chat ID format") {
t.Fatalf("ImChatUpdate.Validate() error = %v", err)
}
})
t.Run("ImChatUpdate description too long", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"description": strings.Repeat("x", 101),
}, nil)
err := ImChatUpdate.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--description exceeds the maximum of 100 characters") {
t.Fatalf("ImChatUpdate.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend conflicting target", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"user-id": "ou_123",
"text": "hello",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--chat-id and --user-id are mutually exclusive") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend invalid content json", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"content": "{invalid",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--content is not valid JSON") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend media with text", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
"image": "img_123",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--image/--file/--video/--audio cannot be used with --text, --markdown, or --content") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend valid text", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesSend video with video-cover passes validate", func(t *testing.T) {
// Previously broken: the deleted check used imageKey instead of videoCoverKey,
// so --video + --video-cover would incorrectly fail at Validate.
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"video": "file_456",
"video-cover": "img_789",
}, nil)
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSend.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesSend video without video-cover fails validate", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"video": "file_456",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--video-cover is required when using --video") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend video-cover without video fails validate", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"video-cover": "img_789",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--video-cover can only be used with --video") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"msg-type": "file",
"image": "img_123",
}, nil)
err := ImMessagesSend.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "conflicts with the inferred message type") {
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
}
})
t.Run("ImMessagesReply invalid message id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "bad_id",
"text": "hello",
}, nil)
err := ImMessagesReply.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "must start with om_") {
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
}
})
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "bad_thread",
}, nil)
err := ImThreadsMessagesList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "must start with om_ or omt_") {
t.Fatalf("ImThreadsMessagesList.Validate() error = %v", err)
}
})
t.Run("ImChatMessageList requires one target", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{}, nil)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "specify at least one of --chat-id or --user-id") {
t.Fatalf("ImChatMessageList.Validate() error = %v", err)
}
})
t.Run("ImChatMessageList valid user target", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
}, nil)
if err := ImChatMessageList.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImChatMessageList.Validate() unexpected error = %v", err)
}
})
t.Run("ImChatMessageList rejects both targets", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_abc",
"user-id": "ou_123",
}, nil)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want mutually exclusive", err)
}
})
t.Run("ImChatMessageList rejects user target for bot identity", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
}, nil)
setRuntimeField(t, runtime, "resolvedAs", core.AsBot)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err)
}
})
t.Run("ImMessagesMGet empty ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": " , ",
}, nil)
err := ImMessagesMGet.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--message-ids is required") {
t.Fatalf("ImMessagesMGet.Validate() error = %v", err)
}
})
t.Run("ImMessagesMGet invalid id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,bad_2",
}, nil)
err := ImMessagesMGet.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid message ID") {
t.Fatalf("ImMessagesMGet.Validate() error = %v", err)
}
})
t.Run("ImMessagesResourcesDownload invalid message id", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "bad_id",
"file-key": "img_123",
"type": "image",
}, nil)
err := ImMessagesResourcesDownload.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "must start with om_") {
t.Fatalf("ImMessagesResourcesDownload.Validate() error = %v", err)
}
})
t.Run("ImThreadsMessagesList valid omt thread", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "omt_123",
}, nil)
if err := ImThreadsMessagesList.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImThreadsMessagesList.Validate() unexpected error = %v", err)
}
})
t.Run("ImMessagesSearch invalid page size", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-size": "0",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-size must be an integer between 1 and 50") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid page limit", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-limit": "41",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--page-limit must be an integer between 1 and 40") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid sender id", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"sender": "user_1",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid user ID") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid chat id", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"chat-id": "bad_chat",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "invalid chat ID") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
t.Run("ImMessagesSearch invalid time range", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"start": "2025-01-02T00:00:00Z",
"end": "2025-01-01T00:00:00Z",
}, nil)
err := ImMessagesSearch.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "--start cannot be later than --end") {
t.Fatalf("ImMessagesSearch.Validate() error = %v", err)
}
})
}
func TestMessagesSearchPaginationConfig(t *testing.T) {
t.Run("default single page", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = true, want false")
}
if pageLimit != messagesSearchDefaultPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchDefaultPageLimit)
}
})
t.Run("page all uses max limit", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, nil, map[string]bool{
"page-all": true,
})
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != messagesSearchMaxPageLimit {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want %d", pageLimit, messagesSearchMaxPageLimit)
}
})
t.Run("explicit page limit enables auto pagination", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-limit": "3",
}, nil)
if err := ImMessagesSearch.Validate(context.Background(), runtime); err != nil {
t.Fatalf("ImMessagesSearch.Validate() error = %v, want valid explicit --page-limit", err)
}
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
if !autoPaginate {
t.Fatal("messagesSearchPaginationConfig() autoPaginate = false, want true")
}
if pageLimit != 3 {
t.Fatalf("messagesSearchPaginationConfig() pageLimit = %d, want 3", pageLimit)
}
})
}
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
// produces the expected API path, query parameters, and request body.
func TestShortcutDryRunShapes(t *testing.T) {
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
cmd.Flags().String(name, "", "")
}
cmd.Flags().Bool("set-bot-manager", false, "")
_ = cmd.ParseFlags(nil)
_ = cmd.Flags().Set("type", "public")
_ = cmd.Flags().Set("name", "Team Room")
_ = cmd.Flags().Set("users", "ou_1,ou_2")
_ = cmd.Flags().Set("owner", "ou_owner")
_ = cmd.Flags().Set("set-bot-manager", "true")
_ = cmd.Flags().Set("chat-mode", "group")
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
t.Fatalf("ImChatCreate.DryRun() = %s", got)
}
})
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
"page-size": "50",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
t.Fatalf("ImChatSearch.DryRun() = %s", got)
}
})
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"query": "team-alpha",
}, map[string]bool{
"exclude-muted": true,
})
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
// Filter is client-side; --exclude-muted must NOT mutate request body or auto-inject search_types.
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) {
t.Fatalf("ImChatSearch.DryRun() missing endpoint: %s", got)
}
if strings.Contains(got, `"exclude_muted"`) || strings.Contains(got, `"exclude-muted"`) {
t.Fatalf("--exclude-muted leaked into request: %s", got)
}
if strings.Contains(got, `"search_types"`) {
t.Fatalf("search_types must not be auto-injected by --exclude-muted: %s", got)
}
})
t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) {
runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{
"query": "incident",
"page-size": "51",
"page-token": "next_page",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSearch.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/messages/search"`) || !strings.Contains(got, `"page_size":"50"`) || !strings.Contains(got, `"query":"incident"`) {
t.Fatalf("ImMessagesSearch.DryRun() = %s", got)
}
})
t.Run("ImChatUpdate dry run resolves path", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"name": "New Name",
"description": "New Description",
}, nil)
got := mustMarshalDryRun(t, ImChatUpdate.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats/oc_123"`) || !strings.Contains(got, `"user_id_type":"open_id"`) || !strings.Contains(got, `"name":"New Name"`) {
t.Fatalf("ImChatUpdate.DryRun() = %s", got)
}
})
t.Run("ImMessagesSend dry run resolves open_id target", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
"image": "img_123",
"idempotency-key": "uuid-2",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"receive_id_type":"open_id"`) || !strings.Contains(got, `"msg_type":"image"`) || !strings.Contains(got, `"uuid":"uuid-2"`) || !strings.Contains(got, `\"image_key\":\"img_123\"`) {
t.Fatalf("ImMessagesSend.DryRun() = %s", got)
}
})
t.Run("ImMessagesSend dry run warns chat membership is not verified", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"text": "hello",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, "Bot/user membership in the target chat is not verified") ||
!strings.Contains(got, "Bot/User can NOT be out of the chat") {
t.Fatalf("ImMessagesSend.DryRun() missing membership warning: %s", got)
}
})
t.Run("ImMessagesSend dry run uses placeholder media key for url input", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`) ||
!strings.Contains(got, `"msg_type":"image"`) ||
!strings.Contains(got, `\"image_key\":\"img_dryrun_upload\"`) {
t.Fatalf("ImMessagesSend.DryRun() = %s", got)
}
})
t.Run("ImMessagesSend dry run preserves media and membership descriptions", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"image": "https://example.com/a.png",
}, nil)
mediaDesc := `"description":"dry-run uses placeholder media keys for --image URL input; execution uploads it before sending"`
membershipDesc := `"desc":"NOTE: dry-run validates request shape only. Bot/user membership in the target chat is not verified; the real send may fail with ` + "`Bot/User can NOT be out of the chat`" + `."`
got := mustMarshalDryRun(t, ImMessagesSend.DryRun(context.Background(), runtime))
if !strings.Contains(got, mediaDesc) || !strings.Contains(got, membershipDesc) {
t.Fatalf("ImMessagesSend.DryRun() should preserve both descriptions: %s", got)
}
})
t.Run("ImMessagesMGet dry run expands message ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": "om_1,om_2",
}, nil)
got := mustMarshalDryRun(t, ImMessagesMGet.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/messages/mget?card_msg_content_type=raw_card_content\u0026message_ids=om_1\u0026message_ids=om_2"`) {
t.Fatalf("ImMessagesMGet.DryRun() = %s", got)
}
})
t.Run("ImMessagesResourcesDownload dry run resolves path", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"file-key": "img_123",
"type": "image",
"output": "downloads/out.png",
}, nil)
got := mustMarshalDryRun(t, ImMessagesResourcesDownload.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/messages/om_123/resources/img_123"`) || !strings.Contains(got, `"type":"image"`) || !strings.Contains(got, `"output":"downloads/out.png"`) {
t.Fatalf("ImMessagesResourcesDownload.DryRun() = %s", got)
}
})
t.Run("ImThreadsMessagesList dry run keeps requested thread params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"thread": "omt_123",
"sort": "desc",
"page-size": "10",
}, nil)
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
}
})
t.Run("ImMessagesReply dry run resolves message path and body", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"text": "hi <at id=ou_1/>",
"idempotency-key": "uuid-1",
}, map[string]bool{
"reply-in-thread": true,
})
got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime))
if !strings.Contains(got, "/open-apis/im/v1/messages/om_123/reply") || !strings.Contains(got, `"reply_in_thread":true`) || !strings.Contains(got, `"uuid":"uuid-1"`) {
t.Fatalf("ImMessagesReply.DryRun() = %s", got)
}
})
t.Run("ImMessagesReply dry run uses markdown image placeholders", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-id": "om_123",
"markdown": "![alt](https://example.com/a.png)",
}, nil)
got := mustMarshalDryRun(t, ImMessagesReply.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"description":"dry-run uses placeholder image keys for markdown image URLs; execution downloads and uploads them before sending"`) ||
!strings.Contains(got, `"msg_type":"post"`) ||
!strings.Contains(got, `img_dryrun_1`) {
t.Fatalf("ImMessagesReply.DryRun() = %s", got)
}
})
t.Run("ImChatMessageList dry run notes p2p resolution", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
"page-size": "10",
"sort": "asc",
}, nil)
d := ImChatMessageList.DryRun(context.Background(), runtime)
formatted := d.Format()
if !strings.Contains(formatted, "resolve P2P chat_id") || !strings.Contains(formatted, "container_id=%3Cresolved_chat_id%3E") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s", formatted)
}
})
t.Run("ImChatMessageList dry run includes root-only query", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
})
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id-type": "open_id",
"sort-type": "ByCreateTimeAsc",
}, nil)
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
t.Fatalf("ImChatList.DryRun() = %s", got)
}
if !strings.Contains(got, `"sort_type":"ByCreateTimeAsc"`) {
t.Fatalf("ImChatList.DryRun() missing sort_type: %s", got)
}
})
}
func TestChatMessageListOnlyThreadRootMessagesDryRun(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_123",
"page-size": "20",
"sort": "desc",
}, nil)
formatted := ImChatMessageList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(formatted, "only_thread_root_messages=true") {
t.Fatalf("ImChatMessageList.DryRun().Format() = %s, want only_thread_root_messages=true", formatted)
}
}
func TestDetectAllNonMemberPreSkip(t *testing.T) {
cases := []struct {
name string
searchTypes string
want string
}{
{"empty", "", ""},
{"only public_not_joined", "public_not_joined", SkipReasonAllNonMember},
{"public_not_joined with whitespace", " public_not_joined ", SkipReasonAllNonMember},
{"private only", "private", ""},
{"mixed includes public_not_joined", "public_not_joined,private", ""},
{"all four types", "private,public_joined,external,public_not_joined", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := detectAllNonMemberPreSkip(c.searchTypes)
if got != c.want {
t.Fatalf("detectAllNonMemberPreSkip(%q) = %q, want %q", c.searchTypes, got, c.want)
}
})
}
}