Files
larksuite-cli/shortcuts/im/im_flag_test.go
seemslike 16f1a0f320 feat: add flag shortcuts for im (#770)
Add IM flag shortcut commands to lark-cli, enabling users to create, list, and cancel bookmarks on messages and threads via +flag-create, +flag-list, and +flag-cancel.

Change-Id: I8f87f0eadf83fb59b024a3b9fe67b23d363abe0a
2026-05-11 11:32:06 +08:00

1813 lines
53 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestParseItemType(t *testing.T) {
tests := []struct {
name string
input string
want ItemType
wantErr bool
}{
{name: "default", input: "default", want: ItemTypeDefault},
{name: "empty string defaults to default", input: "", want: ItemTypeDefault},
{name: "thread", input: "thread", want: ItemTypeThread},
{name: "msg_thread", input: "msg_thread", want: ItemTypeMsgThread},
{name: "case insensitive", input: "THREAD", want: ItemTypeThread},
{name: "with whitespace", input: " thread ", want: ItemTypeThread},
{name: "invalid", input: "invalid_type", wantErr: true},
{name: "message is not a valid item type", input: "message", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseItemType(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseItemType(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseItemType(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseItemType(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseFlagType(t *testing.T) {
tests := []struct {
name string
input string
want FlagType
wantErr bool
}{
{name: "message", input: "message", want: FlagTypeMessage},
{name: "empty string defaults to message", input: "", want: FlagTypeMessage},
{name: "feed", input: "feed", want: FlagTypeFeed},
{name: "case insensitive", input: "FEED", want: FlagTypeFeed},
{name: "with whitespace", input: " feed ", want: FlagTypeFeed},
{name: "invalid", input: "invalid_type", wantErr: true},
{name: "unknown is not a valid flag type", input: "unknown", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseFlagType(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseFlagType(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseFlagType(%q) unexpected error: %v", tt.input, err)
}
if got != tt.want {
t.Fatalf("parseFlagType(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseItemID(t *testing.T) {
tests := []struct {
name string
input string
wantIT ItemType
wantFT FlagType
wantErr bool
errContain string
}{
{name: "om prefix", input: "om_abc123", wantIT: ItemTypeDefault, wantFT: FlagTypeMessage},
{name: "with whitespace", input: " om_abc123 ", wantIT: ItemTypeDefault, wantFT: FlagTypeMessage},
{name: "empty string", input: "", wantErr: true, errContain: "cannot be empty"},
{name: "unknown prefix", input: "oc_xxx", wantErr: true, errContain: "cannot infer"},
{name: "omt prefix is not special", input: "omt_xyz789", wantErr: true, errContain: "cannot infer"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotIT, gotFT, err := parseItemID(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseItemID(%q) expected error, got nil", tt.input)
}
if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("parseItemID(%q) error = %q, want to contain %q", tt.input, err.Error(), tt.errContain)
}
return
}
if err != nil {
t.Fatalf("parseItemID(%q) unexpected error: %v", tt.input, err)
}
if gotIT != tt.wantIT {
t.Fatalf("parseItemID(%q) itemType = %v, want %v", tt.input, gotIT, tt.wantIT)
}
if gotFT != tt.wantFT {
t.Fatalf("parseItemID(%q) flagType = %v, want %v", tt.input, gotFT, tt.wantFT)
}
})
}
}
func TestIsValidCombo(t *testing.T) {
tests := []struct {
name string
it ItemType
ft FlagType
want bool
}{
{name: "default+message valid", it: ItemTypeDefault, ft: FlagTypeMessage, want: true},
{name: "thread+feed valid", it: ItemTypeThread, ft: FlagTypeFeed, want: true},
{name: "msg_thread+feed valid", it: ItemTypeMsgThread, ft: FlagTypeFeed, want: true},
{name: "default+feed invalid", it: ItemTypeDefault, ft: FlagTypeFeed, want: false},
{name: "thread+message invalid", it: ItemTypeThread, ft: FlagTypeMessage, want: false},
{name: "msg_thread+message invalid", it: ItemTypeMsgThread, ft: FlagTypeMessage, want: false},
{name: "unknown flag type", it: ItemTypeDefault, ft: FlagTypeUnknown, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isValidCombo(tt.it, tt.ft); got != tt.want {
t.Fatalf("isValidCombo(%v, %v) = %v, want %v", tt.it, tt.ft, got, tt.want)
}
})
}
}
func TestNewFlagItem(t *testing.T) {
tests := []struct {
name string
itemID string
it ItemType
ft FlagType
wantJSON string
}{
{
name: "default+message",
itemID: "om_abc123",
it: ItemTypeDefault,
ft: FlagTypeMessage,
wantJSON: `{"item_id":"om_abc123","item_type":"0","flag_type":"2"}`,
},
{
name: "thread+feed",
itemID: "om_xyz789",
it: ItemTypeThread,
ft: FlagTypeFeed,
wantJSON: `{"item_id":"om_xyz789","item_type":"4","flag_type":"1"}`,
},
{
name: "msg_thread+feed",
itemID: "om_123",
it: ItemTypeMsgThread,
ft: FlagTypeFeed,
wantJSON: `{"item_id":"om_123","item_type":"11","flag_type":"1"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := newFlagItem(tt.itemID, tt.it, tt.ft)
if got.ItemID != tt.itemID {
t.Fatalf("newFlagItem().ItemID = %q, want %q", got.ItemID, tt.itemID)
}
if got.ItemType != stringInt(int(tt.it)) {
t.Fatalf("newFlagItem().ItemType = %q, want %q", got.ItemType, stringInt(int(tt.it)))
}
if got.FlagType != stringInt(int(tt.ft)) {
t.Fatalf("newFlagItem().FlagType = %q, want %q", got.FlagType, stringInt(int(tt.ft)))
}
})
}
}
func TestParseItemTypeFromRaw(t *testing.T) {
tests := []struct {
name string
input string
want ItemType
}{
{name: "default", input: "0", want: ItemTypeDefault},
{name: "thread", input: "4", want: ItemTypeThread},
{name: "msg_thread", input: "11", want: ItemTypeMsgThread},
{name: "unknown defaults to default", input: "999", want: ItemTypeDefault},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseItemTypeFromRaw(tt.input); got != tt.want {
t.Fatalf("parseItemTypeFromRaw(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestParseFlagTypeFromRaw(t *testing.T) {
tests := []struct {
input string
want FlagType
}{
{input: "1", want: FlagTypeFeed},
{input: "2", want: FlagTypeMessage},
{input: "999", want: FlagTypeUnknown}, // unknown
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
if got := parseFlagTypeFromRaw(tt.input); got != tt.want {
t.Fatalf("parseFlagTypeFromRaw(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
// helper
func stringInt(v int) string {
return strconv.Itoa(v)
}
func setFlag(t *testing.T, cmd *cobra.Command, name, value string) {
t.Helper()
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Set %s error = %v", name, err)
}
}
func newFlagScopeTestCmd(t *testing.T) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
return cmd
}
type scopedTokenResolver struct {
scopes string
}
func (r scopedTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return &credential.TokenResult{Token: "user-token", Scopes: r.scopes}, nil
}
type errorTokenResolver struct {
err error
}
func (r errorTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
return nil, r.err
}
func setRuntimeScopes(t *testing.T, rt *common.RuntimeContext, scopes string) {
t.Helper()
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, scopedTokenResolver{scopes: scopes}, nil)
}
func setRuntimeTokenError(t *testing.T, rt *common.RuntimeContext, err error) {
t.Helper()
rt.Factory.Credential = credential.NewCredentialProvider(nil, nil, errorTokenResolver{err: err}, nil)
}
func TestFlagMessageID(t *testing.T) {
tests := []struct {
name string
id string
want string
wantErr bool
errContain string
}{
{name: "trims message id", id: " om_abc ", want: "om_abc"},
{name: "missing message id", id: "", wantErr: true, errContain: "--message-id is required"},
{name: "thread id rejected", id: "omt_abc", wantErr: true, errContain: "omt_ prefix is a thread ID"},
{name: "non message id rejected", id: "oc_abc", wantErr: true, errContain: "must start with om_"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", tt.id)
got, err := flagMessageID(&common.RuntimeContext{Cmd: cmd})
if tt.wantErr {
if err == nil {
t.Fatalf("flagMessageID() expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("flagMessageID() error = %q, want %q", err.Error(), tt.errContain)
}
return
}
if err != nil {
t.Fatalf("flagMessageID() error = %v", err)
}
if got != tt.want {
t.Fatalf("flagMessageID() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseExplicitFlagCombo(t *testing.T) {
tests := []struct {
name string
itemType string
flagType string
wantItem ItemType
wantFlag FlagType
wantItemOK bool
wantFlagOK bool
wantErr bool
errContain string
}{
{name: "no overrides"},
{name: "valid feed thread", itemType: "thread", flagType: "feed", wantItem: ItemTypeThread, wantFlag: FlagTypeFeed, wantItemOK: true, wantFlagOK: true},
{name: "valid message default", itemType: "default", flagType: "message", wantItem: ItemTypeDefault, wantFlag: FlagTypeMessage, wantItemOK: true, wantFlagOK: true},
{name: "flag only", flagType: "feed", wantFlag: FlagTypeFeed, wantFlagOK: true},
{name: "item only requires flag", itemType: "thread", wantErr: true, errContain: "requires --flag-type=feed"},
{name: "invalid pair", itemType: "thread", flagType: "message", wantErr: true, errContain: "invalid --item-type=thread --flag-type=message combination"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseExplicitFlagCombo(tt.itemType, tt.flagType)
if tt.wantErr {
if err == nil {
t.Fatalf("parseExplicitFlagCombo() expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("parseExplicitFlagCombo() error = %q, want %q", err.Error(), tt.errContain)
}
return
}
if err != nil {
t.Fatalf("parseExplicitFlagCombo() error = %v", err)
}
if got.ItemTypeSet != tt.wantItemOK {
t.Fatalf("ItemTypeSet = %v, want %v", got.ItemTypeSet, tt.wantItemOK)
}
if got.FlagTypeSet != tt.wantFlagOK {
t.Fatalf("FlagTypeSet = %v, want %v", got.FlagTypeSet, tt.wantFlagOK)
}
if got.ItemTypeSet && got.ItemType != tt.wantItem {
t.Fatalf("ItemType = %v, want %v", got.ItemType, tt.wantItem)
}
if got.FlagTypeSet && got.FlagType != tt.wantFlag {
t.Fatalf("FlagType = %v, want %v", got.FlagType, tt.wantFlag)
}
})
}
}
func TestBuildCreateItem(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantItem flagItem
wantErr bool
errContain string
}{
{
name: "message id defaults to message type",
flags: map[string]string{
"message-id": "om_abc123",
},
wantItem: newFlagItem("om_abc123", ItemTypeDefault, FlagTypeMessage),
},
{
name: "explicit item-type and flag-type",
flags: map[string]string{
"message-id": "om_xyz789",
"item-type": "thread",
"flag-type": "feed",
},
wantItem: newFlagItem("om_xyz789", ItemTypeThread, FlagTypeFeed),
},
{
name: "explicit msg_thread type",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "msg_thread",
"flag-type": "feed",
},
wantItem: newFlagItem("om_abc", ItemTypeMsgThread, FlagTypeFeed),
},
{
name: "explicit flag-type message",
flags: map[string]string{
"message-id": "om_abc",
"flag-type": "message",
},
wantItem: newFlagItem("om_abc", ItemTypeDefault, FlagTypeMessage),
},
{
name: "missing message-id",
flags: map[string]string{
"item-type": "default",
},
wantErr: true,
errContain: "--message-id is required",
},
{
name: "only item-type thread without flag-type should error",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "thread",
},
wantErr: true,
errContain: "--item-type=thread requires --flag-type=feed",
},
{
name: "only item-type msg_thread without flag-type should error",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "msg_thread",
},
wantErr: true,
errContain: "--item-type=msg_thread requires --flag-type=feed",
},
{
name: "only item-type default without flag-type should error",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "default",
},
wantErr: true,
errContain: "--item-type=default requires --flag-type=message",
},
{
name: "invalid item-type",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "invalid",
"flag-type": "feed",
},
wantErr: true,
errContain: "invalid --item-type",
},
{
name: "invalid flag-type",
flags: map[string]string{
"message-id": "om_abc",
"item-type": "thread",
"flag-type": "invalid",
},
wantErr: true,
errContain: "invalid --flag-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for name := range tt.flags {
cmd.Flags().String(name, "", "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range tt.flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime := &common.RuntimeContext{Cmd: cmd}
got, err := buildCreateItem(runtime)
if tt.wantErr {
if err == nil {
t.Fatalf("buildCreateItem() expected error, got nil")
}
if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("buildCreateItem() error = %q, want to contain %q", err.Error(), tt.errContain)
}
return
}
if err != nil {
t.Fatalf("buildCreateItem() unexpected error: %v", err)
}
if got.ItemID != tt.wantItem.ItemID {
t.Fatalf("buildCreateItem().ItemID = %q, want %q", got.ItemID, tt.wantItem.ItemID)
}
if got.ItemType != tt.wantItem.ItemType {
t.Fatalf("buildCreateItem().ItemType = %q, want %q", got.ItemType, tt.wantItem.ItemType)
}
if got.FlagType != tt.wantItem.FlagType {
t.Fatalf("buildCreateItem().FlagType = %q, want %q", got.FlagType, tt.wantItem.FlagType)
}
})
}
}
func TestFlagShortcutStaticScopesIncludeLookupRequirements(t *testing.T) {
wantWriteLookup := append([]string{flagWriteScope}, flagLookupScopes...)
if got := ImFlagCreate.ScopesForIdentity("user"); strings.Join(got, ",") != strings.Join(wantWriteLookup, ",") {
t.Fatalf("ImFlagCreate scopes = %#v, want %#v", got, wantWriteLookup)
}
if got := ImFlagCancel.ScopesForIdentity("user"); strings.Join(got, ",") != strings.Join(wantWriteLookup, ",") {
t.Fatalf("ImFlagCancel scopes = %#v, want %#v", got, wantWriteLookup)
}
if got := ImFlagList.ScopesForIdentity("user"); len(got) != 1 || got[0] != flagReadScope {
t.Fatalf("ImFlagList scopes = %#v, want only %s", got, flagReadScope)
}
}
func TestFlagCreateExplicitFeedTypeDoesNotRequireLookupScopes(t *testing.T) {
var calls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("explicit type should not call lookup API: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, flagWriteScope)
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setFlag(t, cmd, "flag-type", "feed")
setFlag(t, cmd, "item-type", "msg_thread")
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCreateItem(rt)
if err != nil {
t.Fatalf("buildCreateItem() error = %v", err)
}
if got.ItemType != "11" || got.FlagType != "1" {
t.Fatalf("buildCreateItem() = %+v, want msg_thread/feed", got)
}
if calls != 0 {
t.Fatalf("buildCreateItem() made %d lookup call(s), want 0", calls)
}
}
func TestFlagCreateAutoDetectReliesOnDeclaredLookupScopes(t *testing.T) {
var calls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("should fail scope check before lookup API: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, flagWriteScope)
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setFlag(t, cmd, "flag-type", "feed")
setRuntimeField(t, rt, "Cmd", cmd)
_, err := buildCreateItem(rt)
if err == nil || !strings.Contains(err.Error(), "should fail scope check before lookup API") {
t.Fatalf("buildCreateItem() error = %v, want lookup API attempt", err)
}
if calls != 1 {
t.Fatalf("buildCreateItem() made %d lookup call(s), want 1", calls)
}
}
func TestCheckFlagRequiredScopesReportsTokenResolutionError(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("checkFlagRequiredScopes should not call API")
return nil, nil
}))
setRuntimeTokenError(t, rt, errors.New("token cache unavailable"))
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("checkFlagRequiredScopes() error = %T %v, want ExitError", err, err)
}
if exitErr.Code != output.ExitAuth || exitErr.Detail.Type != "auth" {
t.Fatalf("checkFlagRequiredScopes() detail = %+v code=%d, want auth exit", exitErr.Detail, exitErr.Code)
}
if !strings.Contains(exitErr.Detail.Message, "cannot verify required scope") {
t.Fatalf("message = %q, want scope verification context", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, strings.Join(flagMessageReadScopes, " ")) {
t.Fatalf("hint = %q, want required scopes", exitErr.Detail.Hint)
}
}
func TestCheckFlagRequiredScopesAllowsMissingScopeMetadata(t *testing.T) {
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatalf("checkFlagRequiredScopes should not call API")
return nil, nil
}))
setRuntimeScopes(t, rt, "")
err := checkFlagRequiredScopes(context.Background(), rt, flagMessageReadScopes)
if err != nil {
t.Fatalf("checkFlagRequiredScopes() error = %v, want nil for missing scope metadata", err)
}
errOut := rt.Factory.IOStreams.ErrOut.(*bytes.Buffer).String()
if !strings.Contains(errOut, "warning: cannot verify required scope(s)") {
t.Fatalf("stderr = %q, want scope metadata warning", errOut)
}
if !strings.Contains(errOut, strings.Join(flagMessageReadScopes, " ")) {
t.Fatalf("stderr = %q, want required scopes", errOut)
}
}
func TestFlagCancelExplicitTypeDoesNotRequireLookupScopes(t *testing.T) {
var calls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("explicit type should not call lookup API: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, flagWriteScope)
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setFlag(t, cmd, "flag-type", "feed")
setFlag(t, cmd, "item-type", "msg_thread")
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCancelItems(rt)
if err != nil {
t.Fatalf("buildCancelItems() error = %v", err)
}
if len(got) != 1 || got[0].ItemType != "11" || got[0].FlagType != "1" {
t.Fatalf("buildCancelItems() = %+v, want single msg_thread/feed item", got)
}
if calls != 0 {
t.Fatalf("buildCancelItems() made %d lookup call(s), want 0", calls)
}
}
func TestFlagCancelDefaultReliesOnDeclaredLookupScopes(t *testing.T) {
var calls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("should fail scope check before lookup API: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, flagWriteScope)
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCancelItems(rt)
if err != nil {
t.Fatalf("buildCancelItems() error = %v", err)
}
if len(got) != 1 || got[0].ItemType != "0" || got[0].FlagType != "2" {
t.Fatalf("buildCancelItems() = %+v, want default/message fallback", got)
}
if calls != 1 {
t.Fatalf("buildCancelItems() made %d lookup call(s), want 1", calls)
}
}
func TestFlagCreateDryRunFeedDoesNotCallAPI(t *testing.T) {
var calls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return nil, fmt.Errorf("dry-run must not call API: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("message-id", "om_123"); err != nil {
t.Fatalf("Set message-id error = %v", err)
}
if err := cmd.Flags().Set("flag-type", "feed"); err != nil {
t.Fatalf("Set flag-type error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
got := ImFlagCreate.DryRun(context.Background(), rt).Format()
if calls != 0 {
t.Fatalf("DryRun made %d API call(s), want 0", calls)
}
if !strings.Contains(got, "auto:thread|msg_thread") {
t.Fatalf("DryRun output = %s, want auto-detect placeholder", got)
}
}
func TestFlagListSkillDocUsesExistingMessageFetchShortcut(t *testing.T) {
b, err := os.ReadFile("../../skills/lark-im/references/lark-im-flag-list.md")
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
doc := string(b)
if strings.Contains(doc, "+message-get") {
t.Fatalf("flag-list skill doc references nonexistent +message-get shortcut")
}
if !strings.Contains(doc, "+messages-mget") {
t.Fatalf("flag-list skill doc should mention +messages-mget")
}
}
func TestBuildCancelItemsForPreview(t *testing.T) {
tests := []struct {
name string
flags map[string]string
wantLen int
wantDouble bool
wantErr bool
}{
{
name: "om prefix dry-run assumes double-cancel",
flags: map[string]string{"message-id": "om_abc"},
wantLen: 2,
wantDouble: true,
},
{
name: "explicit flag-type single cancel",
flags: map[string]string{"message-id": "om_abc", "flag-type": "message"},
wantLen: 1,
wantDouble: false,
},
{
name: "explicit item-type single cancel",
flags: map[string]string{"message-id": "om_abc", "item-type": "default"},
wantLen: 1,
wantDouble: false,
},
{
name: "missing id",
flags: map[string]string{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
for name := range tt.flags {
cmd.Flags().String(name, "", "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, val := range tt.flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
runtime := &common.RuntimeContext{Cmd: cmd}
got, isDouble, err := buildCancelItemsForPreview(runtime)
if tt.wantErr {
if err == nil {
t.Fatalf("buildCancelItemsForPreview() expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildCancelItemsForPreview() unexpected error: %v", err)
}
if len(got) != tt.wantLen {
t.Fatalf("buildCancelItemsForPreview() returned %d items, want %d", len(got), tt.wantLen)
}
if isDouble != tt.wantDouble {
t.Fatalf("buildCancelItemsForPreview() isDouble = %v, want %v", isDouble, tt.wantDouble)
}
})
}
}
func TestFlagCancelDryRunReportsValidationError(t *testing.T) {
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setFlag(t, cmd, "flag-type", "invalid")
runtime := &common.RuntimeContext{Cmd: cmd}
got := ImFlagCancel.DryRun(context.Background(), runtime).Format()
if !strings.Contains(got, "invalid --flag-type") {
t.Fatalf("DryRun output = %q, want validation error", got)
}
if strings.Contains(got, "flag_items") {
t.Fatalf("DryRun output = %q, should not include request body for invalid input", got)
}
}
func TestFlagCancelDryRunUsesAutoDetectPlaceholderForFeedLayer(t *testing.T) {
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
runtime := &common.RuntimeContext{Cmd: cmd}
got := ImFlagCancel.DryRun(context.Background(), runtime).Format()
if !strings.Contains(got, "auto:thread|msg_thread") {
t.Fatalf("DryRun output = %q, want auto-detect placeholder", got)
}
if strings.Contains(got, `"item_type":"11"`) {
t.Fatalf("DryRun output = %q, should not hard-code msg_thread item_type", got)
}
}
func TestBuildSingleCancelItem(t *testing.T) {
tests := []struct {
name string
id string
itOverride string
ftOverride string
wantIT ItemType
wantFT FlagType
wantErr bool
}{
{
name: "om id infers default+message",
id: "om_abc",
wantIT: ItemTypeDefault,
wantFT: FlagTypeMessage,
},
{
name: "explicit override",
id: "om_xyz",
itOverride: "msg_thread",
ftOverride: "feed",
wantIT: ItemTypeMsgThread,
wantFT: FlagTypeFeed,
},
{
name: "only item-type override",
id: "om_xyz",
itOverride: "msg_thread",
wantErr: true, // msg_thread + message (inferred from om_) is invalid
},
{
name: "only flag-type override",
id: "om_xyz",
ftOverride: "feed",
wantErr: true, // default (inferred from om_) + feed is invalid
},
{
name: "invalid combo: thread+message",
id: "om_abc",
itOverride: "thread",
wantErr: true, // thread + message (inferred from om_) is invalid
},
{
name: "invalid combo: default+feed",
id: "om_xyz",
ftOverride: "feed",
wantErr: true, // default (inferred) + feed is invalid
},
{
name: "empty id",
id: "",
wantErr: true,
},
{
name: "invalid item-type override",
id: "om_abc",
itOverride: "invalid",
wantErr: true,
},
{
name: "invalid flag-type override",
id: "om_abc",
ftOverride: "invalid",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := buildSingleCancelItem(tt.id, tt.itOverride, tt.ftOverride)
if tt.wantErr {
if err == nil {
t.Fatalf("buildSingleCancelItem() expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildSingleCancelItem() unexpected error: %v", err)
}
if got.ItemID != tt.id {
t.Fatalf("buildSingleCancelItem().ItemID = %q, want %q", got.ItemID, tt.id)
}
if got.ItemType != stringInt(int(tt.wantIT)) {
t.Fatalf("buildSingleCancelItem().ItemType = %q, want %q", got.ItemType, stringInt(int(tt.wantIT)))
}
if got.FlagType != stringInt(int(tt.wantFT)) {
t.Fatalf("buildSingleCancelItem().FlagType = %q, want %q", got.FlagType, stringInt(int(tt.wantFT)))
}
})
}
}
func TestListQuery(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().String("page-token", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("page-size", "20"); err != nil {
t.Fatalf("Set page-size error = %v", err)
}
if err := cmd.Flags().Set("page-token", "next_token"); err != nil {
t.Fatalf("Set page-token error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
got := listQuery(runtime)
if got["page_size"][0] != "20" {
t.Fatalf("listQuery() page_size = %q, want %q", got["page_size"][0], "20")
}
if got["page_token"][0] != "next_token" {
t.Fatalf("listQuery() page_token = %q, want %q", got["page_token"][0], "next_token")
}
}
func TestFlagListRejectsInvalidPageLimit(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-limit", 20, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
t.Fatalf("Set page-limit error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
if err := ImFlagList.Validate(context.Background(), runtime); err == nil {
t.Fatalf("Validate() expected page-limit error, got nil")
}
got := ImFlagList.DryRun(context.Background(), runtime).Format()
if !strings.Contains(got, "--page-limit") {
t.Fatalf("DryRun output = %q, want page-limit validation error", got)
}
if strings.Contains(got, "/open-apis/im/v1/flags") {
t.Fatalf("DryRun output = %q, should not include request for invalid input", got)
}
}
func TestFlagListDryRunMentionsConditionalEnrichmentScopes(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-limit", 20, "")
cmd.Flags().Bool("enrich-feed-thread", true, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
got := ImFlagList.DryRun(context.Background(), runtime).Format()
for _, want := range []string{
"im:message.group_msg:get_as_user",
"im:message.p2p_msg:get_as_user",
"--enrich-feed-thread=false",
} {
if !strings.Contains(got, want) {
t.Fatalf("DryRun output = %q, want %q", got, want)
}
}
}
func TestAsString(t *testing.T) {
tests := []struct {
input any
want string
}{
{input: "hello", want: "hello"},
{input: "", want: ""},
{input: 123, want: "123"},
{input: int(456), want: "456"},
{input: float64(78.9), want: "78.9"},
{input: nil, want: ""},
{input: []string{"a"}, want: ""},
}
for _, tt := range tests {
t.Run("", func(t *testing.T) {
if got := asString(tt.input); got != tt.want {
t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestEnrichFeedThreadItems(t *testing.T) {
tests := []struct {
name string
data map[string]any
wantMsg bool // whether message should be attached
wantErr bool
mockMGet map[string]any // mock mget response
mockMGetOK bool
}{
{
name: "empty flag_items",
data: map[string]any{
"flag_items": []any{},
},
wantErr: false,
},
{
name: "non-feed item skipped",
data: map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "om_123",
"item_type": "0",
"flag_type": "2", // message type
},
},
},
wantErr: false,
},
{
name: "feed-thread item with inlined message",
data: map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "omt_123",
"item_type": "4",
"flag_type": "1", // feed type
},
},
"messages": []any{
map[string]any{
"message_id": "omt_123",
"content": "hello",
},
},
},
wantErr: false,
wantMsg: true,
},
{
name: "feed-thread item needs mget",
data: map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "omt_456",
"item_type": "4",
"flag_type": "1",
},
},
},
mockMGetOK: true,
mockMGet: map[string]any{
"items": []any{
map[string]any{
"message_id": "omt_456",
"content": "fetched content",
},
},
},
wantErr: false,
wantMsg: true,
},
{
name: "msg_thread item needs mget",
data: map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "om_789",
"item_type": "11", // msg_thread
"flag_type": "1",
},
},
},
mockMGetOK: true,
mockMGet: map[string]any{
"items": []any{
map[string]any{
"message_id": "om_789",
"content": "msg_thread content",
},
},
},
wantErr: false,
wantMsg: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") {
if !tt.mockMGetOK {
return nil, fmt.Errorf("unexpected mget call")
}
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": tt.mockMGet,
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " "))
err := enrichFeedThreadItems(rt, tt.data)
if tt.wantErr {
if err == nil {
t.Fatalf("enrichFeedThreadItems() expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("enrichFeedThreadItems() unexpected error: %v", err)
}
if tt.wantMsg {
items := tt.data["flag_items"].([]any)
if len(items) == 0 {
t.Fatalf("expected flag_items")
}
item := items[0].(map[string]any)
if _, ok := item["message"]; !ok {
t.Fatalf("expected message to be attached to item")
}
}
})
}
}
func TestEnrichFeedThreadItems_BatchMGet(t *testing.T) {
// Test that batched mget works when > 50 items
var feedItems []any
for i := 0; i < 60; i++ {
feedItems = append(feedItems, map[string]any{
"item_id": fmt.Sprintf("omt_%d", i),
"item_type": "4",
"flag_type": "1",
})
}
callCount := 0
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") {
callCount++
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{},
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " "))
data := map[string]any{"flag_items": feedItems}
err := enrichFeedThreadItems(rt, data)
if err != nil {
t.Fatalf("enrichFeedThreadItems() error = %v", err)
}
// Should make 2 calls: 50 + 10 items
if callCount != 2 {
t.Fatalf("expected 2 mget calls, got %d", callCount)
}
}
func TestEnrichFeedThreadItems_MGetError(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") {
return nil, fmt.Errorf("mget failed")
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
setRuntimeScopes(t, rt, strings.Join(flagMessageReadScopes, " "))
data := map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "omt_123",
"item_type": "4",
"flag_type": "1",
},
},
}
err := enrichFeedThreadItems(rt, data)
if err == nil {
t.Fatalf("enrichFeedThreadItems() expected error, got nil")
}
}
func TestAsStringFloat(t *testing.T) {
// Test float64 conversion specifically (JSON numbers come as float64)
tests := []struct {
input float64
want string
}{
{input: 1.0, want: "1"},
{input: 123.456, want: "123.456"},
{input: 0.0, want: "0"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := asString(tt.input); got != tt.want {
t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAsStringInt(t *testing.T) {
// Test int conversion
tests := []struct {
input int
want string
}{
{input: 1, want: "1"},
{input: 123, want: "123"},
{input: 0, want: "0"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := asString(tt.input); got != tt.want {
t.Fatalf("asString(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestBuildCancelItems_ExplicitOverride(t *testing.T) {
// Test buildCancelItems with explicit item-type and flag-type override
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("message-id", "om_xyz"); err != nil {
t.Fatalf("Set message-id error = %v", err)
}
if err := cmd.Flags().Set("item-type", "msg_thread"); err != nil {
t.Fatalf("Set item-type error = %v", err)
}
if err := cmd.Flags().Set("flag-type", "feed"); err != nil {
t.Fatalf("Set flag-type error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
got, err := buildCancelItems(runtime)
if err != nil {
t.Fatalf("buildCancelItems() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("buildCancelItems() returned %d items, want 1", len(got))
}
if got[0].ItemID != "om_xyz" {
t.Fatalf("buildCancelItems().ItemID = %q, want %q", got[0].ItemID, "om_xyz")
}
if got[0].ItemType != "11" {
t.Fatalf("buildCancelItems().ItemType = %q, want 11", got[0].ItemType)
}
if got[0].FlagType != "1" {
t.Fatalf("buildCancelItems().FlagType = %q, want 1", got[0].FlagType)
}
}
func TestFlagCancelExecuteSummarizesPartialFailure(t *testing.T) {
var cancelCalls int
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_123",
"chat_id": "oc_chat",
},
},
},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_chat"):
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"chat_mode": "group"},
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/flags/cancel"):
cancelCalls++
if cancelCalls == 1 {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{"request_id": "message-ok"},
}), nil
}
return shortcutJSONResponse(200, map[string]any{
"code": 999,
"msg": "feed failed",
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}
}))
cmd := newFlagScopeTestCmd(t)
setFlag(t, cmd, "message-id", "om_123")
setRuntimeField(t, rt, "Cmd", cmd)
err := ImFlagCancel.Execute(context.Background(), rt)
if err == nil {
t.Fatalf("Execute() expected partial failure error, got nil")
}
out := rt.Factory.IOStreams.Out.(*bytes.Buffer).String()
for _, want := range []string{`"results"`, `"item_type": "default"`, `"flag_type": "message"`, `"status": "ok"`, `"item_type": "msg_thread"`, `"flag_type": "feed"`, `"status": "failed"`, "feed failed"} {
if !strings.Contains(out, want) {
t.Fatalf("stdout = %s, want %q", out, want)
}
}
var envelope struct {
Data struct {
Results []map[string]any `json:"results"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &envelope); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, out)
}
if len(envelope.Data.Results) != 2 {
t.Fatalf("results len = %d, want 2", len(envelope.Data.Results))
}
}
func TestBuildCancelItems_OnlyItemTypeOverride(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("message-id", "om_xyz"); err != nil {
t.Fatalf("Set message-id error = %v", err)
}
if err := cmd.Flags().Set("item-type", "thread"); err != nil {
t.Fatalf("Set item-type error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
_, err := buildCancelItems(runtime)
// om_xyz + thread -> inferred flag-type=message from om_ prefix
// thread + message is invalid combo, should error
if err == nil {
t.Fatalf("buildCancelItems() expected error for invalid combo, got nil")
}
}
func TestBuildCancelItems_OmPrefixThreadRoot(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_123",
"chat_id": "oc_chat",
},
},
},
}), nil
}
if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"chat_mode": "group",
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("message-id", "om_123"); err != nil {
t.Fatalf("Set message-id error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCancelItems(rt)
if err != nil {
t.Fatalf("buildCancelItems() error = %v", err)
}
// Thread root message should produce double-cancel
if len(got) != 2 {
t.Fatalf("buildCancelItems() returned %d items, want 2", len(got))
}
// First item should be default+message
if got[0].ItemType != "0" || got[0].FlagType != "2" {
t.Fatalf("first item = %+v, want default+message", got[0])
}
// Second item should be msg_thread+feed (group chat)
if got[1].ItemType != "11" || got[1].FlagType != "1" {
t.Fatalf("second item = %+v, want msg_thread+feed", got[1])
}
}
func TestBuildCancelItems_MessageQueryFails(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("API error")
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
if err := cmd.Flags().Set("message-id", "om_789"); err != nil {
t.Fatalf("Set message-id error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCancelItems(rt)
if err != nil {
t.Fatalf("buildCancelItems() error = %v", err)
}
// When message query fails, should still cancel message layer (best effort)
// Feed layer is skipped since we can't determine chat_type
if len(got) != 1 {
t.Fatalf("buildCancelItems() returned %d items, want 1", len(got))
}
if got[0].ItemType != "0" || got[0].FlagType != "2" {
t.Fatalf("item = %+v, want default+message", got[0])
}
}
func TestBuildCancelItems_MissingID(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
runtime := &common.RuntimeContext{Cmd: cmd}
_, err := buildCancelItems(runtime)
if err == nil {
t.Fatalf("buildCancelItems() expected error, got nil")
}
}
func TestExecuteListAllPages(t *testing.T) {
callCount := 0
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") {
callCount++
hasMore := callCount < 2
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"flag_items": []any{
map[string]any{"item_id": fmt.Sprintf("om_%d", callCount)},
},
"delete_flag_items": []any{},
"messages": []any{},
"has_more": hasMore,
"page_token": fmt.Sprintf("token_%d", callCount),
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Bool("enrich-feed-thread", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
err := executeListAllPages(rt)
if err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
}
func TestExecuteListAllPages_EnrichFeedThread(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"flag_items": []any{
map[string]any{
"item_id": "omt_123",
"item_type": "4",
"flag_type": "1",
},
},
"delete_flag_items": []any{},
"messages": []any{},
"has_more": false,
"page_token": "",
},
}), nil
}
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/mget") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "omt_123",
"content": "test content",
},
},
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Bool("enrich-feed-thread", true, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
err := executeListAllPages(rt)
if err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
}
func TestExecuteListAllPages_PageLimit(t *testing.T) {
callCount := 0
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") {
callCount++
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"flag_items": []any{},
"delete_flag_items": []any{},
"messages": []any{},
"has_more": true, // always has more
"page_token": fmt.Sprintf("token_%d", callCount),
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().Int("page-limit", 3, "") // limit to 3 pages
cmd.Flags().Bool("enrich-feed-thread", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
err := executeListAllPages(rt)
if err != nil {
t.Fatalf("executeListAllPages() error = %v", err)
}
// Should stop at page-limit
if callCount != 3 {
t.Fatalf("expected 3 API calls (page limit), got %d", callCount)
}
}
func TestExecuteListAllPages_APIError(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("API error")
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Int("page-size", 50, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Bool("enrich-feed-thread", false, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
setRuntimeField(t, rt, "Cmd", cmd)
err := executeListAllPages(rt)
if err == nil {
t.Fatalf("executeListAllPages() expected error, got nil")
}
}
func TestBuildCreateItem_FeedAutoDetect(t *testing.T) {
// Test --flag-type feed auto-detects item_type from chat_mode
t.Run("topic-style chat", func(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_123") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_123",
"chat_id": "oc_chat",
},
},
},
}), nil
}
if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"chat_mode": "topic",
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
cmd.Flags().Set("message-id", "om_123")
cmd.Flags().Set("flag-type", "feed")
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCreateItem(rt)
if err != nil {
t.Fatalf("buildCreateItem() error = %v", err)
}
if got.ItemType != "4" {
t.Fatalf("ItemType = %q, want 4 (thread)", got.ItemType)
}
if got.FlagType != "1" {
t.Fatalf("FlagType = %q, want 1 (feed)", got.FlagType)
}
})
t.Run("regular chat", func(t *testing.T) {
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_456") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"items": []any{
map[string]any{
"message_id": "om_456",
"chat_id": "oc_chat",
},
},
},
}), nil
}
if strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/") {
return shortcutJSONResponse(200, map[string]any{
"code": 0,
"data": map[string]any{
"chat_mode": "group",
},
}), nil
}
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("message-id", "", "")
cmd.Flags().String("item-type", "", "")
cmd.Flags().String("flag-type", "", "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
cmd.Flags().Set("message-id", "om_456")
cmd.Flags().Set("flag-type", "feed")
setRuntimeField(t, rt, "Cmd", cmd)
got, err := buildCreateItem(rt)
if err != nil {
t.Fatalf("buildCreateItem() error = %v", err)
}
if got.ItemType != "11" {
t.Fatalf("ItemType = %q, want 11 (msg_thread)", got.ItemType)
}
if got.FlagType != "1" {
t.Fatalf("FlagType = %q, want 1 (feed)", got.FlagType)
}
})
}
func TestValidateExplicitCombo(t *testing.T) {
tests := []struct {
name string
itOverride string
ftOverride string
wantErr bool
errContain string
}{
{name: "no overrides", itOverride: "", ftOverride: "", wantErr: false},
{name: "both valid", itOverride: "thread", ftOverride: "feed", wantErr: false},
{name: "default+message", itOverride: "default", ftOverride: "message", wantErr: false},
{name: "invalid combo", itOverride: "thread", ftOverride: "message", wantErr: true, errContain: "invalid"},
{name: "item-type thread without flag-type", itOverride: "thread", ftOverride: "", wantErr: true, errContain: "requires --flag-type=feed"},
{name: "item-type default without flag-type", itOverride: "default", ftOverride: "", wantErr: true, errContain: "requires --flag-type=message"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateExplicitCombo(tt.itOverride, tt.ftOverride)
if tt.wantErr {
if err == nil {
t.Fatalf("validateExplicitCombo() expected error, got nil")
}
if tt.errContain != "" && !strings.Contains(err.Error(), tt.errContain) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.errContain)
}
return
}
if err != nil {
t.Fatalf("validateExplicitCombo() unexpected error: %v", err)
}
})
}
}
func TestItemTypeString(t *testing.T) {
tests := []struct {
input ItemType
want string
}{
{input: ItemTypeDefault, want: "default"},
{input: ItemTypeThread, want: "thread"},
{input: ItemTypeMsgThread, want: "msg_thread"},
{input: ItemType(999), want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := itemTypeString(tt.input); got != tt.want {
t.Fatalf("itemTypeString(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestFlagTypeString(t *testing.T) {
tests := []struct {
input FlagType
want string
}{
{input: FlagTypeMessage, want: "message"},
{input: FlagTypeFeed, want: "feed"},
{input: FlagTypeUnknown, want: "unknown"},
{input: FlagType(999), want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
if got := flagTypeString(tt.input); got != tt.want {
t.Fatalf("flagTypeString(%v) = %q, want %q", tt.input, got, tt.want)
}
})
}
}