Files
larksuite__cli/shortcuts/mail/mail_thread_manage_test.go
yangr-happy 3e8b6c6659 Add mail thread management shortcuts (#2370)
* feat: add mail thread manage shortcuts

* Validate mail thread batch size locally

Reject thread management requests with more than 20 unique thread IDs before making an API call. This mirrors the server-side batch limit and keeps the CLI error in the validation path.

* docs(mail): preserve message shortcut guidance

* feat(mail): expose add-folder thread flag

* docs(mail): tighten thread shortcut guidance

* docs(mail): localize thread shortcut sections

* docs(mail): align thread shortcut headings

* test(mail): cover thread management review gaps

* fix(mail): harden thread manage validation

* fix(mail): skip thread label folder prevalidation

* test(mail): cover thread shortcut registration

* docs(mail): localize thread management skills

* docs(mail): tighten thread skill guidance

* docs(mail): trim thread modify guidance

* docs(mail): remove redundant output note

* fix(mail): align thread batch management output

* docs(mail): simplify thread output wording

* fix(mail): remove unused thread folder scope

* docs(mail): remove thread raw endpoint preface

* fix(mail): support bot thread shortcuts

* docs(mail): keep thread skill updates minimal

* docs(mail): minimize mail template bot note

* docs(mail): align bot identity guidance

* fix(auth): mint bot token from env app secret

* Revert "fix(auth): mint bot token from env app secret"

This reverts commit 648460e00dfce136df1dbe3b7448fa52ad6d31a4.

* fix(mail): tighten thread management guidance

* docs(mail): avoid repeating identity support

* docs(mail): keep read identity guidance focused

* fix(mail): simplify thread receipt label error

* docs(mail): avoid repeating shortcut identity details

* docs(mail): simplify thread shortcut guidance

---------

Co-authored-by: yangr-happy <301323675+yangr-happy@users.noreply.github.com>
2026-09-07 15:18:23 +08:00

471 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func threadManageID(suffix string) string {
return "thread_abcdefghijklmnop_" + suffix
}
func threadManageIDs(count int) []string {
ids := make([]string, 0, count)
for i := 0; i < count; i++ {
ids = append(ids, threadManageID(fmt.Sprintf("%02d", i+1)))
}
return ids
}
func stubThreadManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/threads/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeThreadManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_thread_ids"].([]interface{})
if !ok {
t.Fatalf("success_thread_ids = %#v, want array", data["success_thread_ids"])
}
failed, ok := data["failed_thread_ids"].([]interface{})
if !ok {
t.Fatalf("failed_thread_ids = %#v, want array", data["failed_thread_ids"])
}
return success, failed
}
func TestThreadModify_Metadata(t *testing.T) {
if MailThreadModify.Command != "+thread-modify" {
t.Fatalf("Command = %q", MailThreadModify.Command)
}
if MailThreadModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailThreadModify.Risk)
}
if strings.Join(MailThreadModify.AuthTypes, ",") != "user,bot" {
t.Errorf("AuthTypes = %v, want [user bot]", MailThreadModify.AuthTypes)
}
if len(MailThreadModify.Scopes) != 1 || MailThreadModify.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailThreadModify.Scopes)
}
if len(MailThreadModify.ConditionalScopes) != 0 {
t.Errorf("ConditionalScopes = %v, want none", MailThreadModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailThreadModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "thread-ids", "add-label-ids", "remove-label-ids", "add-folder", "folder-id"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["thread-ids"].Type != "string_array" || !flags["thread-ids"].Required {
t.Errorf("--thread-ids = %#v, want required string_array", flags["thread-ids"])
}
if got := strings.Join(flags["add-folder"].Aliases, ","); got != "" {
t.Errorf("--add-folder aliases = %q, want none", got)
}
if !flags["folder-id"].Hidden {
t.Errorf("--folder-id hidden = false, want true")
}
}
func TestThreadTrash_Metadata(t *testing.T) {
if MailThreadTrash.Command != "+thread-trash" {
t.Fatalf("Command = %q", MailThreadTrash.Command)
}
if MailThreadTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailThreadTrash.Risk)
}
if strings.Join(MailThreadTrash.AuthTypes, ",") != "user,bot" {
t.Errorf("AuthTypes = %v, want [user bot]", MailThreadTrash.AuthTypes)
}
}
func TestThreadManage_ShortcutsRegistration(t *testing.T) {
commands := map[string]bool{}
for _, shortcut := range Shortcuts() {
commands[shortcut.Command] = true
}
for _, want := range []string{"+thread-modify", "+thread-trash"} {
if !commands[want] {
t.Fatalf("Shortcuts() missing %s", want)
}
}
}
func TestThreadManage_NormalizeThreadIDs(t *testing.T) {
id1 := threadManageID("1")
id2 := threadManageID("2")
got, err := normalizeThreadManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeThreadManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
for _, tc := range [][]string{
{},
{""},
{" "},
{id1 + ","},
{" " + id1},
{id1 + "\n" + id2},
{"1234567890123456"},
{"thread_abcdefghijklmnop!"},
} {
_, err := normalizeThreadManageIDs(tc)
requireMessageManageValidationParam(t, err, "--thread-ids")
}
withDuplicates := append(threadManageIDs(mailThreadManageBatchSize), threadManageID("01"))
got, err = normalizeThreadManageIDs(withDuplicates)
if err != nil {
t.Fatalf("normalizeThreadManageIDs with duplicate returned error: %v", err)
}
if len(got) != mailThreadManageBatchSize {
t.Fatalf("ids len = %d, want %d after dedupe", len(got), mailThreadManageBatchSize)
}
}
func TestThreadModify_LabelFolderBodyAndOutputContract(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := threadManageID("1")
id2 := threadManageID("2")
post := stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id1 + "," + id2 + "," + id1,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
threadIDs := body["thread_ids"].([]interface{})
if len(threadIDs) != 2 || threadIDs[0] != id1 || threadIDs[1] != id2 {
t.Fatalf("thread_ids = %#v, want deduped [%s %s]", threadIDs, id1, id2)
}
if got := body["add_folder"]; got != "ARCHIVED" {
t.Fatalf("add_folder = %v, want ARCHIVED", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Fatalf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "FLAGGED" {
t.Fatalf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
data := decodeShortcutEnvelopeData(t, stdout)
success, failed := decodeThreadManageSummary(t, data)
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v, want [%s %s]/[]", success, failed, id1, id2)
}
if _, ok := data["updated_count"]; ok {
t.Fatalf("updated_count must not be present: %#v", data)
}
if _, ok := data["failed_ids"]; ok {
t.Fatalf("failed_ids must not be present: %#v", data)
}
if _, ok := data["submitted_thread_ids"]; ok {
t.Fatalf("submitted_thread_ids must not be present: %#v", data)
}
if _, ok := data["submitted_count"]; ok {
t.Fatalf("submitted_count must not be present: %#v", data)
}
}
func TestThreadModify_FolderIDAliasMapsToAddFolder(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := threadManageID("alias")
post := stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
"--folder-id", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "ARCHIVED" {
t.Fatalf("add_folder = %v, want ARCHIVED", got)
}
}
func TestThreadModify_AddFolderAndFolderIDConflict(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := threadManageID("conflict")
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
"--add-folder", "archive",
"--folder-id", "folderA",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--folder-id")
if !strings.Contains(err.Error(), "pass only one") {
t.Fatalf("error = %v, want pass only one conflict message", err)
}
}
func TestThreadModify_Validation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := threadManageID("1")
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
}, f, stdout)
requireMessageManageValidationParam(t, err, "--thread-modify")
if !strings.Contains(err.Error(), "provide at least one") {
t.Fatalf("error = %v, want missing action validation", err)
}
err = runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
for _, tc := range []struct {
name string
flag string
}{
{name: "add", flag: "--add-label-ids"},
{name: "remove", flag: "--remove-label-ids"},
} {
t.Run("rejects read receipt request label on "+tc.name, func(t *testing.T) {
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
tc.flag, "read_receipt_request",
}, f, stdout)
requireMessageManageValidationParam(t, err, tc.flag)
if !strings.Contains(err.Error(), "thread 级别不能管理 `READ_RECEIPT_REQUEST`") {
t.Fatalf("error = %v, want thread-level receipt label guidance", err)
}
})
}
err = runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
"--folder-id", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--folder-id")
if !strings.Contains(err.Error(), "use +thread-trash") {
t.Fatalf("error = %v, want +thread-trash hint", err)
}
}
func TestThreadModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := threadManageIDs(41)
first := stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
threadIDs := body["thread_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(threadIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(threadIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeThreadManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestThreadModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := threadManageID("1")
stubThreadManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestThreadModify_DryRunShowsPostURLAndBody(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := threadManageID("1")
id2 := threadManageID("2")
err := runMountedMailShortcut(t, MailThreadModify, []string{
"+thread-modify",
"--thread-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/threads/batch_modify`,
`thread_ids`,
`add_label_ids`,
`add_folder`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
if strings.Contains(out, `validation_api_plan`) {
t.Fatalf("dry-run output must not include validation_api_plan; got %s", out)
}
}
func TestThreadTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := threadManageID("1")
id2 := threadManageID("2")
err := runMountedMailShortcut(t, MailThreadTrash, []string{
"+thread-trash",
"--thread-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubThreadManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailThreadTrash, []string{
"+thread-trash",
"--thread-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["thread_ids"].([]interface{})); got != 2 {
t.Fatalf("thread_ids len = %d, want 2", got)
}
success, failed := decodeThreadManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestThreadTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := threadManageID("1")
stubThreadManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailThreadTrash, []string{
"+thread-trash",
"--thread-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestThreadTrash_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := threadManageIDs(41)
first := stubThreadManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubThreadManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubThreadManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailThreadTrash, []string{
"+thread-trash",
"--thread-ids", strings.Join(ids, ","),
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
threadIDs := body["thread_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(threadIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(threadIDs), want)
}
}
success, failed := decodeThreadManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestThreadTrash_DryRunShowsPostURLAndBody(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := threadManageID("1")
err := runMountedMailShortcut(t, MailThreadTrash, []string{
"+thread-trash",
"--thread-ids", id,
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/threads/batch_trash`,
`thread_ids`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}