chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:22:54 +08:00
commit bf9395e022
2349 changed files with 588574 additions and 0 deletions
@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_ChatMessageWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-chat-" + suffix
messageText := "im-chat-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("list chat messages as user", func(t *testing.T) {
startTime := time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339)
endTime := time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339)
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", startTime,
"--end", endTime,
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && strings.Contains(item.Get("content").String(), messageText) {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() != messageID {
continue
}
require.True(t, strings.Contains(item.Get("content").String(), messageText), "stdout:\n%s", result.Stdout)
found = true
break
}
require.True(t, found, "expected message %s in stdout:\n%s", messageID, result.Stdout)
})
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_ChatUpdateWorkflow tests the +chat-update shortcut.
func TestIM_ChatUpdateWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
originalName := "lark-cli-e2e-im-update-" + suffix
updatedName := originalName + "-updated"
updatedDescription := "Updated description for e2e test"
chatID := createChat(t, parentT, ctx, originalName)
t.Run("update chat name as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--name", updatedName,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("update chat description as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-update",
"--chat-id", chatID,
"--description", updatedDescription,
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("get updated chat as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, updatedName, gjson.Get(result.Stdout, "data.name").String())
assert.Equal(t, updatedDescription, gjson.Get(result.Stdout, "data.description").String())
})
}
// TestIM_ChatsGetWorkflow tests the im chats get command.
func TestIM_ChatsGetWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-get-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat info as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "get"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
})
require.NoError(t, err)
t.Logf("chats get result: %s", result.Stdout)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
dataExists := gjson.Get(result.Stdout, "data").Exists()
require.True(t, dataExists, "data object should exist")
chatNameGot := gjson.Get(result.Stdout, "data.name").String()
require.Equal(t, chatName, chatNameGot)
})
}
// TestIM_ChatsLinkWorkflow tests the im chats link command.
func TestIM_ChatsLinkWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-chats-link-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
t.Run("get chat share link as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "chats", "link"},
DefaultAs: "bot",
Params: map[string]any{"chat_id": chatID},
Data: map[string]any{
"validity_period": "week",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
shareLink := gjson.Get(result.Stdout, "data.share_link").String()
require.NotEmpty(t, shareLink, "share_link should not be empty")
t.Logf("Generated share link: %s", shareLink)
})
}
+52
View File
@@ -0,0 +1,52 @@
# IM CLI E2E Coverage
## Metrics
- Denominator: 30 leaf commands
- Covered: 11
- Coverage: 36.7%
## Summary
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
- TestIM_ChatsGetWorkflow: proves `im chats get` on a fresh chat fixture via `get chat info as bot`.
- TestIM_ChatsLinkWorkflow: proves `im chats link` via `get chat share link as bot`.
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessagesSendAudioDryRunRejectsNonOpus: proves the `im +messages-send --audio` dry-run validation rejects non-Opus local audio before upload, with typed validation metadata and recovery guidance.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | im +chat-create | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/create chat as user; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow; im/chat_workflow_test.go::TestIM_ChatsGetWorkflow; im/chat_workflow_test.go::TestIM_ChatsLinkWorkflow; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot | `--name`; `--type private` | covered via workflow setup with created chat IDs asserted |
| ✓ | im +chat-messages-list | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/list chat messages as user; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--chat-id`; `--start`; `--end` | reads back created message and discovers thread ID |
| ✕ | im +chat-search | shortcut | | none | UAT did not reliably return freshly created private chats, so it is left uncovered |
| ✓ | im +chat-update | shortcut | im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat name as bot; im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/update chat description as bot | `--chat-id`; `--name`; `--description` | |
| ✓ | im +messages-mget | shortcut | im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser/batch get message as user | `--message-ids` | verifies sent message content by ID |
| ✓ | im +messages-reply | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/reply to message in thread as bot | `--message-id`; `--text`; `--reply-in-thread` | reply is read back via thread list |
| ✕ | im +messages-resources-download | shortcut | | none | needs a stable image/file message fixture plus file_key proof; left uncovered |
| ✕ | im +messages-search | shortcut | | none | freshly sent messages were not indexed deterministically in UAT time for a stable read-after-write proof |
| ✓ | im +messages-send | shortcut | im/chat_message_workflow_test.go::TestIM_ChatMessageWorkflowAsUser/send message as user; im/message_get_workflow_test.go::TestIM_MessageGetWorkflowAsUser; im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot; im/message_audio_dryrun_test.go::TestIM_MessagesSendAudioDryRunRejectsNonOpus | `--chat-id`; `--text`; `--audio ./voice.mp3 --dry-run` | live text sends feed follow-up reads; dry-run pins non-Opus audio validation before upload |
| ✓ | im +threads-messages-list | shortcut | im/message_reply_workflow_test.go::TestIM_MessageReplyWorkflowAsBot/list thread replies as bot | `--thread` | proves threaded reply is persisted |
| ✕ | im chat.members create | api | | none | no member mutation workflow yet |
| ✕ | im chat.members get | api | | none | no member get workflow yet |
| ✕ | im chats create | api | | none | only covered indirectly through `+chat-create` |
| ✓ | im chats get | api | im/chat_workflow_test.go::TestIM_ChatUpdateWorkflow/get updated chat as bot; im/chat_workflow_test.go::TestIM_ChatsGetWorkflow/get chat info as bot | `chat_id` in `--params` | |
| ✓ | im chats link | api | im/chat_workflow_test.go::TestIM_ChatsLinkWorkflow/get chat share link as bot | `chat_id` in `--params`; `validity_period` in `--data` | |
| ✕ | im chats list | api | | none | no chats list workflow yet |
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
| ✕ | im images create | api | | none | no image upload workflow yet |
| ✕ | im messages delete | api | | none | no recall workflow yet |
| ✓ | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
| ✕ | im pins create | api | | none | pin workflows not covered |
| ✕ | im pins delete | api | | none | pin workflows not covered |
| ✕ | im pins list | api | | none | pin workflows not covered |
| ✕ | im reactions batch_query | api | | none | reaction workflows not covered |
| ✕ | im reactions create | api | | none | reaction workflows not covered |
| ✕ | im reactions delete | api | | none | reaction workflows not covered |
| ✕ | im reactions list | api | | none | reaction workflows not covered |
@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_FeedShortcutWorkflowAsUser walks the full create → list → remove
// loop for a single CHAT-type feed shortcut, mirroring the +flag-* workflow
// test. The feed_shortcuts API is user-identity only and version-locked, so
// the assertion strategy uses RunCmdWithRetry against the list endpoint
// rather than assuming the index is updated synchronously.
func TestIM_FeedShortcutWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-feed-shortcut-" + suffix
var chatID string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatID)
})
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("pin chat to feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// failed_shortcuts may be absent (server returns {} on full success)
// or present-and-empty — either way it must not contain our chat.
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"create should not report our chat as failed")
}
})
t.Run("list feed shortcuts as user with detail enrichment", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() != chatID {
continue
}
found = true
require.Equal(t, int64(1), item.Get("type").Int(), "type should be 1 (CHAT)")
// detail enrichment is on by default — the chat we just created
// must come back with the chat info object attached.
require.True(t, item.Get("detail").Exists(),
"detail field should be attached when enrichment is enabled")
require.Equal(t, chatID, item.Get("detail.chat_id").String(),
"detail.chat_id should echo feed_card_id")
require.Equal(t, chatName, item.Get("detail.name").String(),
"detail.name should carry the chat's group name")
break
}
require.True(t, found, "expected chat %s in feed shortcut list", chatID)
})
t.Run("list feed shortcuts with --no-detail skips lookup", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
var foundEntry gjson.Result
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
foundEntry = item
break
}
}
require.True(t, foundEntry.Exists(), "expected our chat in the bare list")
require.False(t, foundEntry.Get("detail").Exists(),
"detail field should NOT be present with --no-detail")
})
t.Run("unpin chat from feed as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", chatID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("shortcut.feed_card_id").String(),
"remove should not report our chat as failed")
}
})
t.Run("verify chat removed from feed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
if item.Get("feed_card_id").String() == chatID {
return true // still there, retry
}
}
return false
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(result.Stdout, "data.shortcuts").Array() {
require.NotEqual(t, chatID, item.Get("feed_card_id").String(),
"chat should not be in feed list after remove")
}
})
}
// TestIM_FeedShortcutBatchAsUser exercises batch create / remove with two
// chats in a single API call. The per-item failure path (failed_shortcuts)
// is checked by mixing a real chat with an obviously invalid id.
func TestIM_FeedShortcutBatchAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
var chatA, chatB string
t.Cleanup(func() {
cleanupFeedShortcuts(parentT, "user", chatA, chatB)
})
t.Run("create two chats as user", func(t *testing.T) {
chatA = createChatAs(t, parentT, ctx, "im-feed-batch-a-"+suffix, "user")
chatB = createChatAs(t, parentT, ctx, "im-feed-batch-b-"+suffix, "user")
})
t.Run("batch pin both with --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA + "," + chatB,
"--tail",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("batch pin with one invalid id reports per-item failure", func(t *testing.T) {
// Re-pinning chatA (already pinned) should still succeed without
// noting it as failure; the synthetic invalid id should land in
// failed_shortcuts with reason_label=invalid_item.
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", chatA,
"--chat-id", "oc_definitely_not_a_real_chat_id_" + suffix,
},
DefaultAs: "user",
})
require.NoError(t, err)
// Partial failure exits with the generic API failure code while the
// full ledger stays machine-readable on stdout.
result.AssertExitCode(t, 1)
result.AssertStdoutStatus(t, false)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.total").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.success_count").Int())
require.Equal(t, int64(1), gjson.Get(result.Stdout, "data.failure_count").Int())
var sawInvalid bool
for _, item := range gjson.Get(result.Stdout, "data.failed_shortcuts").Array() {
require.NotEqual(t, chatA, item.Get("shortcut.feed_card_id").String(),
"real chat should not appear in failed_shortcuts")
if item.Get("shortcut.feed_card_id").String() == "oc_definitely_not_a_real_chat_id_"+suffix {
sawInvalid = true
require.Equal(t, "invalid_item", item.Get("reason_label").String(),
"invalid id should be labeled invalid_item")
}
}
require.True(t, sawInvalid, "expected the bogus chat id in failed_shortcuts")
var sawSucceeded bool
for _, item := range gjson.Get(result.Stdout, "data.succeeded_shortcuts").Array() {
if item.Get("feed_card_id").String() == chatA {
sawSucceeded = true
require.Equal(t, int64(1), item.Get("type").Int(), "succeeded shortcut type should be CHAT")
}
}
require.True(t, sawSucceeded, "expected the real chat id in succeeded_shortcuts")
})
t.Run("batch remove both", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", chatA,
"--chat-id", chatB,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...string) {
parentT.Helper()
var ids []string
for _, id := range chatIDs {
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return
}
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"im", "+feed-shortcut-list", "--no-detail"},
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts list", listResult, listErr)
if listErr != nil || listResult == nil || listResult.ExitCode != 0 {
return
}
present := make(map[string]bool, len(ids))
for _, id := range ids {
present[id] = false
}
for _, item := range gjson.Get(listResult.Stdout, "data.shortcuts").Array() {
id := item.Get("feed_card_id").String()
if _, ok := present[id]; ok {
present[id] = true
}
}
args := []string{"im", "+feed-shortcut-remove"}
for _, id := range ids {
if present[id] {
args = append(args, "--chat-id", id)
}
}
if len(args) == 2 {
return
}
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: args,
DefaultAs: defaultAs,
})
clie2e.ReportCleanupFailure(parentT, "cleanup feed shortcuts", result, err)
}
// TestIM_FeedShortcutDryRun covers all three shortcuts in --dry-run mode
// using env-only identity hints. strict_mode/default_as lock the command to
// user identity without injecting a fake user token that would trigger
// user_info verification during startup.
func TestIM_FeedShortcutDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
require.Contains(t, result.Stdout, "oc_test_dry_run")
// --head is the default so the body should be is_header=true
require.Contains(t, result.Stdout, `"is_header"`)
})
t.Run("create dry-run with --tail flips is_header to false", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, `"is_header": false`)
})
t.Run("create dry-run rejects --head + --tail", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "oc_test_dry_run",
"--head",
"--tail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
// Validation errors exit 2 with the structured error envelope;
// assert against combined output to stay channel-agnostic.
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "mutually exclusive")
})
t.Run("create dry-run rejects non-oc chat ids", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-create",
"--chat-id", "om_not_a_chat",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
combined := result.Stdout + "\n" + result.Stderr
require.Contains(t, combined, "must be an open_chat_id")
})
t.Run("remove dry-run hits /remove endpoint", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-remove",
"--chat-id", "oc_a,oc_b",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts/remove")
require.Contains(t, result.Stdout, "oc_a")
require.Contains(t, result.Stdout, "oc_b")
require.NotContains(t, result.Stdout, "is_header", "remove must not send is_header")
})
t.Run("list dry-run mentions detail enrichment path", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v2/feed_shortcuts")
// Enrichment is on by default → DryRun adds a desc about the extra
// chats.batch_query call and the conditional scope.
require.Contains(t, result.Stdout, "im:chat:read")
require.Contains(t, result.Stdout, "batch_query")
})
t.Run("list dry-run with --no-detail omits the extra-scope note", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+feed-shortcut-list",
"--no-detail",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.NotContains(t, result.Stdout, "im:chat:read",
"with --no-detail, dry-run must not mention im:chat:read")
})
}
+305
View File
@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_FlagWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-" + suffix
messageText := "flag-test-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our flagged message is in the list
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
// Verify it's a message-type flag (flag_type=2)
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("verify flag removed", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
// Check if our message is still in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return true // Still there, retry
}
}
return false // Not found, success
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify our message is NOT in the list
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
require.NotEqual(t, messageID, item.Get("item_id").String(), "message should not be in flag list after cancel")
}
})
}
func TestIM_FlagCreateWithExplicitTypeAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-flag-explicit-" + suffix
messageText := "flag-explicit-msg-" + suffix
var chatID string
var messageID string
t.Run("create chat as user", func(t *testing.T) {
chatID = createChatAs(t, parentT, ctx, chatName, "user")
})
t.Run("send message as user", func(t *testing.T) {
messageID = sendMessageAs(t, ctx, chatID, messageText, "user")
})
t.Run("create flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", messageID,
"--item-type", "default",
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
t.Run("list flags to verify explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "10",
"--page-all",
},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
return false
}
}
return true
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
// Verify explicit types were applied
var found bool
for _, item := range gjson.Get(result.Stdout, "data.flag_items").Array() {
if item.Get("item_id").String() == messageID {
found = true
require.Equal(t, "0", item.Get("item_type").String(), "expected item_type=0 (default)")
require.Equal(t, "2", item.Get("flag_type").String(), "expected flag_type=2 (message)")
break
}
}
require.True(t, found, "expected message %s in flag list", messageID)
})
t.Run("cancel flag with explicit types as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", messageID,
"--flag-type", "message",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagListPaginationAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
t.Run("list flags with page-all as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--page-size", "5",
"--page-all",
"--page-limit", "3",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
})
}
func TestIM_FlagDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
t.Run("create flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-create",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("cancel flag dry-run with om", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-cancel",
"--message-id", "om_test_dry_run",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "POST")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags/cancel")
require.Contains(t, result.Stdout, "flag_items")
require.Contains(t, result.Stdout, "om_test_dry_run")
})
t.Run("list flag dry-run", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+flag-list",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, result.Stdout, "GET")
require.Contains(t, result.Stdout, "/open-apis/im/v1/flags")
require.Contains(t, result.Stdout, "page_size")
})
}
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"testing"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// createChat creates a private chat with the given name and returns the chatID.
// The chat will be automatically cleaned up via parentT.Cleanup().
// Note: Chat deletion is not available via lark-cli im command.
func createChat(t *testing.T, parentT *testing.T, ctx context.Context, name string) string {
t.Helper()
return createChatAs(t, parentT, ctx, name, "bot")
}
func createChatAs(t *testing.T, parentT *testing.T, ctx context.Context, name string, defaultAs string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+chat-create",
"--name", name,
"--type", "private",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
require.NotEmpty(t, chatID, "chat_id should not be empty")
parentT.Cleanup(func() {
// No IM chat delete command is currently available in lark-cli,
// so created chats are intentionally left in the test account.
})
return chatID
}
// sendMessage sends a text message to the specified chat and returns the messageID.
func sendMessage(t *testing.T, ctx context.Context, chatID string, text string) string {
t.Helper()
return sendMessageAs(t, ctx, chatID, text, "bot")
}
func sendMessageAs(t *testing.T, ctx context.Context, chatID string, text string, defaultAs string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--chat-id", chatID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, messageID, "message_id should not be empty")
return messageID
}
@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestIM_DownloadResourcesDryRun verifies the --download-resources flag is wired
// into +chat-messages-list without breaking the existing request structure: the
// underlying GET /open-apis/im/v1/messages call is identical with or without the
// flag (AC4), and the flag only adds a resource-download declaration to dry-run.
func TestIM_DownloadResourcesDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
run := func(t *testing.T, extraArgs ...string) string {
t.Helper()
args := append([]string{
"im", "+chat-messages-list",
"--chat-id", "oc_dryrun",
"--no-reactions",
}, extraArgs...)
args = append(args, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"})
require.NoError(t, err)
result.AssertExitCode(t, 0)
return result.Stdout
}
t.Run("default off: no resources declaration, request unchanged", func(t *testing.T) {
out := run(t)
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.NotContains(t, strings.ToLower(out), "lark-im-resources", "default must not declare resource download:\n%s", out)
})
t.Run("with --download-resources: request unchanged, declares download", func(t *testing.T) {
out := run(t, "--download-resources")
require.Equal(t, "GET", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/im/v1/messages", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "oc_dryrun", gjson.Get(out, "api.0.params.container_id").String(), "stdout:\n%s", out)
require.Contains(t, strings.ToLower(out), "lark-im-resources", "flag must declare resource download:\n%s", out)
})
}
@@ -0,0 +1,62 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessagesSendAudioDryRunRejectsNonOpus(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "im_audio_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_audio_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
workDir := t.TempDir()
audioPath := filepath.Join(workDir, "voice.mp3")
require.NoError(t, os.WriteFile(audioPath, []byte("not real mp3; validation checks extension before upload"), 0o600))
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"im", "+messages-send",
"--chat-id", "oc_123",
"--audio", "./voice.mp3",
"--dry-run",
},
DefaultAs: "bot",
WorkDir: workDir,
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
if got := gjson.Get(result.Stderr, "error.type").String(); got != "validation" {
t.Fatalf("error.type = %q, want validation\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("error.subtype = %q, want invalid_argument\nstderr:\n%s", got, result.Stderr)
}
if got := gjson.Get(result.Stderr, "error.param").String(); got != "--audio" {
t.Fatalf("error.param = %q, want --audio\nstderr:\n%s", got, result.Stderr)
}
message := gjson.Get(result.Stderr, "error.message").String()
if !strings.Contains(message, "--audio supports only Opus audio files") {
t.Fatalf("error.message = %q, want Opus guidance\nstderr:\n%s", message, result.Stderr)
}
hint := gjson.Get(result.Stderr, "error.hint").String()
if !strings.Contains(hint, "--file") || !strings.Contains(hint, "ffmpeg") {
t.Fatalf("error.hint = %q, want --file and ffmpeg guidance\nstderr:\n%s", hint, result.Stderr)
}
}
@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
messageText := "im-forward-msg-" + suffix
replyText := "im-forward-reply-" + suffix
selfOpenID := getSelfOpenID(t, ctx)
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")
t.Run("forward message with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "messages", "forward"},
DefaultAs: "user",
Params: map[string]any{
"message_id": messageID,
"receive_id_type": "chat_id",
"uuid": "msg-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})
var threadID string
t.Run("create thread fixture as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
})
t.Run("forward thread with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "threads", "forward"},
DefaultAs: "user",
Params: map[string]any{
"thread_id": threadID,
"receive_id_type": "chat_id",
"uuid": "thread-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
})
}
func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
t.Helper()
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID := item.Get("thread_id").String()
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
return threadID
}
}
t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
return ""
}
func getSelfOpenID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}
func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--user-id", userOpenID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
return chatID, messageID
}
func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
stderrLower := strings.ToLower(result.Stderr)
if strings.Contains(stderrLower, "permission denied") ||
strings.Contains(stderrLower, "230027") ||
strings.Contains(stderrLower, "missing_scope") {
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
}
}
@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageGetWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
parentT := t
suffix := clie2e.GenerateSuffix()
chatName := "im-lookup-" + suffix
messageText := "im-msg-" + suffix
chatID := createChatAs(t, parentT, ctx, chatName, "user")
messageID := sendMessageAs(t, ctx, chatID, messageText, "user")
t.Run("batch get message as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-mget", "--message-ids", messageID},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
messages := gjson.Get(result.Stdout, "data.messages").Array()
require.Len(t, messages, 1, "stdout:\n%s", result.Stdout)
require.Equal(t, messageID, messages[0].Get("message_id").String(), "stdout:\n%s", result.Stdout)
require.True(t, strings.Contains(messages[0].Get("content").String(), messageText), "stdout:\n%s", result.Stdout)
})
}
@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageReplyWorkflowAsBot(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
chatName := "lark-cli-e2e-im-reply-" + suffix
originalMessage := "lark-cli-e2e-original-message-" + suffix
replyText := "lark-cli-e2e-reply-text-" + suffix
chatID := createChat(t, parentT, ctx, chatName)
messageID := sendMessage(t, ctx, chatID, originalMessage)
t.Run("reply to message in thread as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.NotEmpty(t, gjson.Get(result.Stdout, "data.message_id").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})
t.Run("list thread replies as bot", func(t *testing.T) {
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
var threadID string
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID = item.Get("thread_id").String()
break
}
}
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
threadResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"im", "+threads-messages-list", "--thread", threadID},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if strings.Contains(item.Get("content").String(), replyText) {
return false
}
}
return true
},
})
require.NoError(t, err)
threadResult.AssertExitCode(t, 0)
threadResult.AssertStdoutStatus(t, true)
var found bool
for _, item := range gjson.Get(threadResult.Stdout, "data.messages").Array() {
if strings.Contains(item.Get("content").String(), replyText) {
found = true
break
}
}
require.True(t, found, "expected reply content in stdout:\n%s", threadResult.Stdout)
})
}