bf9395e022
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
696 lines
25 KiB
Go
696 lines
25 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package im
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// newChatListTestRuntimeContext registers flags and returns a user-identity runtime context.
|
|
func newChatListTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
|
return newChatListTestRuntimeContextWithIdentity(t, stringFlags, boolFlags, core.AsUser)
|
|
}
|
|
|
|
// newChatListTestRuntimeContextWithIdentity is the identity-aware variant.
|
|
func newChatListTestRuntimeContextWithIdentity(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool, as core.Identity) *common.RuntimeContext {
|
|
t.Helper()
|
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().Int("page-size", 20, "")
|
|
for name := range stringFlags {
|
|
if name == "page-size" {
|
|
continue
|
|
}
|
|
if name == "types" {
|
|
cmd.Flags().StringSlice(name, nil, "")
|
|
} else {
|
|
cmd.Flags().String(name, "", "")
|
|
}
|
|
}
|
|
for name := range boolFlags {
|
|
cmd.Flags().Bool(name, false, "")
|
|
}
|
|
if err := cmd.ParseFlags(nil); err != nil {
|
|
t.Fatalf("ParseFlags() error = %v", err)
|
|
}
|
|
for name, val := range stringFlags {
|
|
if err := cmd.Flags().Set(name, val); err != nil {
|
|
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
|
}
|
|
}
|
|
for name, val := range boolFlags {
|
|
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
|
|
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
|
}
|
|
}
|
|
rt := common.TestNewRuntimeContextWithIdentity(cmd, nil, as)
|
|
// Attach a minimal Factory with IOStreams so DryRun / Execute paths that
|
|
// emit stderr warnings (e.g. bot_strip_p2p) don't panic on runtime.IO().
|
|
// Stays pure-logic — no HTTP client, no httpmock; integration tests use
|
|
// newBotShortcutRuntime / newUserShortcutRuntime for that.
|
|
rt.Factory = &cmdutil.Factory{
|
|
IOStreams: &cmdutil.IOStreams{
|
|
Out: &bytes.Buffer{},
|
|
ErrOut: &bytes.Buffer{},
|
|
},
|
|
}
|
|
return rt
|
|
}
|
|
|
|
func TestBuildChatListParams_Defaults(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{
|
|
"user-id-type": "open_id",
|
|
"sort-type": "ByCreateTimeAsc",
|
|
}, nil)
|
|
got := buildChatListParams(rt, "")
|
|
if got["user_id_type"] != "open_id" {
|
|
t.Fatalf("user_id_type = %v", got["user_id_type"])
|
|
}
|
|
if got["sort_type"] != "ByCreateTimeAsc" {
|
|
t.Fatalf("sort_type = %v", got["sort_type"])
|
|
}
|
|
if got["page_size"] != 20 {
|
|
t.Fatalf("page_size = %v, want 20", got["page_size"])
|
|
}
|
|
if _, present := got["page_token"]; present {
|
|
t.Fatalf("page_token should be omitted when empty")
|
|
}
|
|
if _, present := got["types"]; present {
|
|
t.Fatalf("types should be omitted when --types is empty")
|
|
}
|
|
}
|
|
|
|
func TestBuildChatListParams_Overrides(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{
|
|
"user-id-type": "user_id",
|
|
"sort-type": "ByActiveTimeDesc",
|
|
"page-size": "50",
|
|
"page-token": "tok_xyz",
|
|
}, nil)
|
|
got := buildChatListParams(rt, "")
|
|
if got["user_id_type"] != "user_id" {
|
|
t.Fatalf("user_id_type = %v", got["user_id_type"])
|
|
}
|
|
if got["sort_type"] != "ByActiveTimeDesc" {
|
|
t.Fatalf("sort_type = %v", got["sort_type"])
|
|
}
|
|
if got["page_size"] != 50 {
|
|
t.Fatalf("page_size = %v, want 50", got["page_size"])
|
|
}
|
|
if got["page_token"] != "tok_xyz" {
|
|
t.Fatalf("page_token = %v", got["page_token"])
|
|
}
|
|
}
|
|
|
|
func TestImChatList_Validate_PageSizeBounds(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
pageSize string
|
|
wantErr bool
|
|
}{
|
|
{"zero rejected", "0", true},
|
|
{"negative rejected", "-1", true},
|
|
{"one ok", "1", false},
|
|
{"hundred ok", "100", false},
|
|
{"oneoone rejected", "101", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{"page-size": c.pageSize}, nil)
|
|
err := ImChatList.Validate(context.Background(), rt)
|
|
if (err != nil) != c.wantErr {
|
|
t.Fatalf("Validate() err = %v, wantErr=%v", err, c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImChatList_DryRun_IncludesEndpoint(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{
|
|
"user-id-type": "open_id",
|
|
"sort-type": "ByActiveTimeDesc",
|
|
"page-size": "30",
|
|
}, nil)
|
|
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
|
|
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
|
t.Fatalf("DryRun missing endpoint: %s", got)
|
|
}
|
|
if !strings.Contains(got, `"sort_type":"ByActiveTimeDesc"`) {
|
|
t.Fatalf("DryRun missing sort_type: %s", got)
|
|
}
|
|
if !strings.Contains(got, `"page_size":30`) {
|
|
t.Fatalf("DryRun missing page_size: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw []string
|
|
want []string
|
|
wantErr string // substring match
|
|
}{
|
|
{"empty returns nil no error", nil, nil, ""},
|
|
{"single p2p", []string{"p2p"}, []string{"p2p"}, ""},
|
|
{"single group", []string{"group"}, []string{"group"}, ""},
|
|
{"p2p,group preserves order", []string{"p2p", "group"}, []string{"p2p", "group"}, ""},
|
|
{"group,p2p preserves order", []string{"group", "p2p"}, []string{"group", "p2p"}, ""},
|
|
{"trim whitespace", []string{" p2p ", " group "}, []string{"p2p", "group"}, ""},
|
|
{"lowercase", []string{"P2P", "GROUP"}, []string{"p2p", "group"}, ""},
|
|
{"dedupe", []string{"p2p", "p2p"}, []string{"p2p"}, ""},
|
|
{"empty element rejected", []string{""}, nil, "must contain at least one of p2p, group"},
|
|
{"invalid element rejected", []string{"private"}, nil, `expected one of p2p, group`},
|
|
{"mixed invalid rejected", []string{"p2p", "private"}, nil, `expected one of p2p, group`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got, err := normalizeTypes(c.raw)
|
|
if c.wantErr != "" {
|
|
if err == nil {
|
|
t.Fatalf("normalizeTypes(%v) err = nil; want substring %q", c.raw, c.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("normalizeTypes(%v) err = %v; want substring %q", c.raw, err, c.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("normalizeTypes(%v) unexpected err = %v", c.raw, err)
|
|
}
|
|
if len(got) != len(c.want) {
|
|
t.Fatalf("normalizeTypes(%v) = %v; want %v", c.raw, got, c.want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Fatalf("normalizeTypes(%v)[%d] = %q; want %q", c.raw, i, got[i], c.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveTypes(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
as core.Identity
|
|
wantEffective string
|
|
wantStripped bool
|
|
}{
|
|
{"user empty", "", core.AsUser, "", false},
|
|
{"user p2p", "p2p", core.AsUser, "p2p", false},
|
|
{"user p2p,group", "p2p,group", core.AsUser, "p2p,group", false},
|
|
{"user group,p2p preserves order", "group,p2p", core.AsUser, "group,p2p", false},
|
|
{"user normalized casing", "P2P,GROUP", core.AsUser, "p2p,group", false},
|
|
{"bot empty", "", core.AsBot, "", false},
|
|
{"bot group only", "group", core.AsBot, "group", false},
|
|
{"bot p2p,group strips p2p", "p2p,group", core.AsBot, "group", true},
|
|
{"bot group,p2p strips p2p", "group,p2p", core.AsBot, "group", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{"types": c.raw}, nil, c.as)
|
|
effective, stripped, err := resolveTypes(rt)
|
|
if err != nil {
|
|
t.Fatalf("resolveTypes() unexpected err = %v", err)
|
|
}
|
|
if effective != c.wantEffective {
|
|
t.Fatalf("effective = %q; want %q", effective, c.wantEffective)
|
|
}
|
|
if stripped != c.wantStripped {
|
|
t.Fatalf("stripped = %v; want %v", stripped, c.wantStripped)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildChatListParams_TypesPassthrough(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{
|
|
"user-id-type": "open_id",
|
|
"sort-type": "ByCreateTimeAsc",
|
|
}, nil)
|
|
got := buildChatListParams(rt, "p2p,group")
|
|
if got["types"] != "p2p,group" {
|
|
t.Fatalf("types = %v; want \"p2p,group\"", got["types"])
|
|
}
|
|
}
|
|
|
|
func TestImChatList_Validate_Types(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
typesRaw string
|
|
as core.Identity
|
|
wantErr string // substring; "" means no error
|
|
}{
|
|
{"user empty ok", "", core.AsUser, ""},
|
|
{"user p2p ok", "p2p", core.AsUser, ""},
|
|
{"user group ok", "group", core.AsUser, ""},
|
|
{"user p2p,group ok", "p2p,group", core.AsUser, ""},
|
|
{"user invalid element rejected", "private", core.AsUser, "expected one of p2p, group"},
|
|
{"user comma-only rejected", ",", core.AsUser, "must contain at least one of p2p, group"},
|
|
{"bot empty ok", "", core.AsBot, ""},
|
|
{"bot group ok", "group", core.AsBot, ""},
|
|
{"bot p2p,group ok (degraded at Execute)", "p2p,group", core.AsBot, ""},
|
|
{"bot single p2p rejected", "p2p", core.AsBot, "only supported with user identity"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rt := newChatListTestRuntimeContextWithIdentity(t,
|
|
map[string]string{"types": c.typesRaw, "page-size": "20"},
|
|
nil, c.as)
|
|
err := ImChatList.Validate(context.Background(), rt)
|
|
if c.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected err = %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() err = nil; want substring %q", c.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), c.wantErr) {
|
|
t.Fatalf("Validate() err = %v; want substring %q", err, c.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// attachChatListCmd builds a cobra.Command pre-loaded with all flags ImChatList
|
|
// reads, applies stringFlags / boolFlags, and assigns it to runtime.Cmd. Format
|
|
// is forced to "json" so Execute output lands in a parseable form on
|
|
// runtime.Factory.IOStreams.Out.
|
|
func attachChatListCmd(t *testing.T, runtime *common.RuntimeContext, stringFlags map[string]string, boolFlags map[string]bool) {
|
|
t.Helper()
|
|
cmd := &cobra.Command{Use: "test"}
|
|
cmd.Flags().Int("page-size", 20, "")
|
|
cmd.Flags().String("user-id-type", "open_id", "")
|
|
cmd.Flags().String("sort-type", "ByCreateTimeAsc", "")
|
|
cmd.Flags().StringSlice("types", nil, "")
|
|
cmd.Flags().String("page-token", "", "")
|
|
cmd.Flags().Bool("exclude-muted", false, "")
|
|
cmd.Flags().Bool("dry-run", false, "")
|
|
if err := cmd.ParseFlags(nil); err != nil {
|
|
t.Fatalf("ParseFlags() error = %v", err)
|
|
}
|
|
for name, val := range stringFlags {
|
|
if err := cmd.Flags().Set(name, val); err != nil {
|
|
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
|
}
|
|
}
|
|
for name, val := range boolFlags {
|
|
if err := cmd.Flags().Set(name, strconv.FormatBool(val)); err != nil {
|
|
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
|
}
|
|
}
|
|
runtime.Cmd = cmd
|
|
runtime.Format = "json"
|
|
}
|
|
|
|
// chatListOutBuf retrieves the captured stdout buffer for assertions.
|
|
func chatListOutBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
|
|
t.Helper()
|
|
buf, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
|
if !ok {
|
|
t.Fatalf("expected IOStreams.Out to be *bytes.Buffer")
|
|
}
|
|
return buf
|
|
}
|
|
|
|
// chatListErrBuf retrieves the captured stderr buffer for assertions
|
|
// (used to verify request-level warnings like `bot_strip_p2p`).
|
|
func chatListErrBuf(t *testing.T, runtime *common.RuntimeContext) *bytes.Buffer {
|
|
t.Helper()
|
|
buf, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
|
if !ok {
|
|
t.Fatalf("expected IOStreams.ErrOut to be *bytes.Buffer")
|
|
}
|
|
return buf
|
|
}
|
|
|
|
func TestImChatList_Execute_BotStripsP2p(t *testing.T) {
|
|
var capturedURL string
|
|
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
capturedURL = req.URL.String()
|
|
body := `{"code":0,"msg":"ok","data":{"items":[{"chat_id":"oc_g","name":"G","chat_mode":"group"}],"has_more":false,"page_token":""}}`
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}))
|
|
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
|
|
|
|
if err := ImChatList.Execute(context.Background(), rt); err != nil {
|
|
t.Fatalf("Execute() err = %v", err)
|
|
}
|
|
|
|
if !strings.Contains(capturedURL, "types=group") {
|
|
t.Fatalf("request URL = %s; want types=group (bot strips p2p)", capturedURL)
|
|
}
|
|
if strings.Contains(capturedURL, "p2p") {
|
|
t.Fatalf("request URL = %s; must NOT contain p2p (bot stripped it)", capturedURL)
|
|
}
|
|
|
|
// Structured notice: outData["notices"] contains a {code, message} entry
|
|
// for bot_strip_p2p (request-level adjustment, not a row-level filter).
|
|
out := chatListOutBuf(t, rt).String()
|
|
for _, want := range []string{`"notices"`, `"code": "bot_strip_p2p"`, `"message"`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("stdout JSON missing notice field %q:\n%s", want, out)
|
|
}
|
|
}
|
|
// filter slot must remain mute-scoped: bot_strip_p2p must not leak into
|
|
// outData["filter"].applied (no priority conflict by design).
|
|
if strings.Contains(out, `"applied": "bot_strip_p2p"`) {
|
|
t.Fatalf("bot_strip_p2p should not appear in filter.applied (separate slot):\n%s", out)
|
|
}
|
|
|
|
// Stderr: matches repo `warning: <code>: <message>` convention (cf.
|
|
// shortcuts/common/runner.go unknown-format fallback).
|
|
errOut := chatListErrBuf(t, rt).String()
|
|
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
|
|
t.Fatalf("stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
|
|
}
|
|
}
|
|
|
|
// TestImChatList_DryRun_BotStripsP2pStderrNotice verifies the DryRun branch
|
|
// also emits the bot_strip_p2p warning to stderr so a previewed request
|
|
// truthfully reflects what Execute would send (drive_search.go DryRun parity).
|
|
func TestImChatList_DryRun_BotStripsP2pStderrNotice(t *testing.T) {
|
|
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
t.Fatalf("DryRun should not make HTTP calls; got: %s", req.URL.String())
|
|
return nil, nil
|
|
}))
|
|
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
|
|
|
|
dr := ImChatList.DryRun(context.Background(), rt)
|
|
if dr == nil {
|
|
t.Fatalf("DryRun returned nil")
|
|
}
|
|
|
|
errOut := chatListErrBuf(t, rt).String()
|
|
if !strings.Contains(errOut, "warning: bot_strip_p2p:") {
|
|
t.Fatalf("DryRun stderr missing `warning: bot_strip_p2p:` prefix:\n%s", errOut)
|
|
}
|
|
}
|
|
|
|
func TestImChatList_RowRendering_P2pFields(t *testing.T) {
|
|
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
body := `{"code":0,"msg":"ok","data":{"items":[
|
|
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
|
|
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
|
|
],"has_more":false,"page_token":""}}`
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}))
|
|
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
|
|
|
|
if err := ImChatList.Execute(context.Background(), rt); err != nil {
|
|
t.Fatalf("Execute() err = %v", err)
|
|
}
|
|
|
|
out := chatListOutBuf(t, rt).String()
|
|
for _, want := range []string{"oc_g", "oc_p", "Group", "Peer", `"chat_mode": "p2p"`, `"p2p_target_id": "ou_peer"`} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("output missing %q; got: %s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestImChatList_Execute_PrettyOutputRendersP2pRow exercises the pretty-format
|
|
// rendering closure in Execute, including the new chat_mode=="p2p" branch that
|
|
// surfaces p2p_target_type / p2p_target_id, and the has_more footer that
|
|
// echoes back the page_token.
|
|
func TestImChatList_Execute_PrettyOutputRendersP2pRow(t *testing.T) {
|
|
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
body := `{"code":0,"msg":"ok","data":{"items":[
|
|
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner","description":"a group","external":false,"chat_status":"normal"},
|
|
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
|
|
],"has_more":true,"page_token":"next_tok"}}`
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}))
|
|
attachChatListCmd(t, rt, map[string]string{"types": "p2p,group"}, nil)
|
|
rt.Format = "pretty"
|
|
|
|
if err := ImChatList.Execute(context.Background(), rt); err != nil {
|
|
t.Fatalf("Execute() err = %v", err)
|
|
}
|
|
|
|
out := chatListOutBuf(t, rt).String()
|
|
for _, want := range []string{"oc_g", "Group", "a group", "ou_owner", "normal"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("pretty output missing group-row field %q:\n%s", want, out)
|
|
}
|
|
}
|
|
for _, want := range []string{"oc_p", "Peer", "p2p", "ou_peer"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("pretty output missing p2p-row field %q:\n%s", want, out)
|
|
}
|
|
}
|
|
if !strings.Contains(out, "2 chat(s) listed") {
|
|
t.Fatalf("pretty output missing footer count:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "next_tok") {
|
|
t.Fatalf("pretty output missing page_token in has_more footer:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestImChatList_DryRun_TypesPassthrough(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
as core.Identity
|
|
typesRaw string
|
|
wantSub string // substring expected in dry-run JSON
|
|
wantErr bool // whether Validate should reject before DryRun runs
|
|
}{
|
|
{"user p2p", core.AsUser, "p2p", `"types":"p2p"`, false},
|
|
{"user p2p,group", core.AsUser, "p2p,group", `"types":"p2p,group"`, false},
|
|
{"bot p2p,group strips to group", core.AsBot, "p2p,group", `"types":"group"`, false},
|
|
{"bot group passes", core.AsBot, "group", `"types":"group"`, false},
|
|
{"bot single p2p rejected at Validate", core.AsBot, "p2p", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
rt := newChatListTestRuntimeContextWithIdentity(t, map[string]string{
|
|
"user-id-type": "open_id",
|
|
"sort-type": "ByCreateTimeAsc",
|
|
"page-size": "20",
|
|
"types": c.typesRaw,
|
|
}, nil, c.as)
|
|
if err := ImChatList.Validate(context.Background(), rt); err != nil {
|
|
if !c.wantErr {
|
|
t.Fatalf("Validate() unexpected err = %v", err)
|
|
}
|
|
return
|
|
}
|
|
if c.wantErr {
|
|
t.Fatalf("Validate() err = nil; want rejection")
|
|
}
|
|
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), rt))
|
|
if !strings.Contains(got, c.wantSub) {
|
|
t.Fatalf("DryRun = %s; want substring %q", got, c.wantSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImChatList_RowRendering_ChatModeAbsent(t *testing.T) {
|
|
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
// Response items deliberately omit chat_mode / p2p_target_* (legacy/defensive case).
|
|
body := `{"code":0,"msg":"ok","data":{"items":[
|
|
{"chat_id":"oc_g1","name":"Group1","owner_id":"ou_owner"},
|
|
{"chat_id":"oc_g2","name":"Group2","external":true}
|
|
],"has_more":false,"page_token":""}}`
|
|
return &http.Response{
|
|
StatusCode: 200,
|
|
Body: io.NopCloser(strings.NewReader(body)),
|
|
Header: make(http.Header),
|
|
}, nil
|
|
}))
|
|
attachChatListCmd(t, rt, nil, nil) // no --types; default behavior
|
|
|
|
if err := ImChatList.Execute(context.Background(), rt); err != nil {
|
|
t.Fatalf("Execute() err = %v", err)
|
|
}
|
|
|
|
out := chatListOutBuf(t, rt).String()
|
|
// chat_mode / p2p_target_* must NOT appear since the API didn't return them.
|
|
for _, forbidden := range []string{`"chat_mode"`, `"p2p_target_type"`, `"p2p_target_id"`} {
|
|
// "chats[].chat_mode" is the row-level field — JSON envelope might include it as null or omit it;
|
|
// asserting the rendered table fields are missing is the goal.
|
|
// The JSON pass-through preserves whatever API returned (omitted here),
|
|
// so neither path should produce these strings.
|
|
if strings.Contains(out, forbidden) {
|
|
t.Fatalf("output unexpectedly contains %q (should not appear when API omitted these fields); got: %s", forbidden, out)
|
|
}
|
|
}
|
|
// Sanity: the two chat IDs must still be present (renderer didn't crash).
|
|
for _, want := range []string{"oc_g1", "oc_g2", "Group1", "Group2"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Fatalf("output missing %q; got: %s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
|
rt := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
path := req.URL.Path
|
|
switch {
|
|
case strings.HasSuffix(path, "/im/v1/chats"):
|
|
body := `{"code":0,"msg":"ok","data":{"items":[
|
|
{"chat_id":"oc_g","name":"Group","chat_mode":"group","owner_id":"ou_owner"},
|
|
{"chat_id":"oc_p","name":"Peer","chat_mode":"p2p","p2p_target_type":"user","p2p_target_id":"ou_peer"}
|
|
],"has_more":false,"page_token":""}}`
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
|
case strings.HasSuffix(path, "/chat_user_setting/batch_get_mute_status"):
|
|
// Mark oc_p (the p2p) as muted; oc_g not muted.
|
|
body := `{"code":0,"msg":"ok","data":{"items":[
|
|
{"chat_id":"oc_g","is_muted":false},
|
|
{"chat_id":"oc_p","is_muted":true}
|
|
]}}`
|
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
|
}
|
|
t.Fatalf("unexpected request path: %s", path)
|
|
return nil, nil
|
|
}))
|
|
attachChatListCmd(t, rt,
|
|
map[string]string{"types": "p2p,group"},
|
|
map[string]bool{"exclude-muted": true})
|
|
|
|
if err := ImChatList.Execute(context.Background(), rt); err != nil {
|
|
t.Fatalf("Execute() err = %v", err)
|
|
}
|
|
|
|
out := chatListOutBuf(t, rt).String()
|
|
|
|
var parsed struct {
|
|
Data struct {
|
|
Chats []map[string]interface{} `json:"chats"`
|
|
Filter struct {
|
|
Applied string `json:"applied"`
|
|
FilteredCount int `json:"filtered_count"`
|
|
} `json:"filter"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
|
t.Fatalf("Unmarshal output failed: %v; raw: %s", err, out)
|
|
}
|
|
if parsed.Data.Filter.Applied != "exclude_muted" {
|
|
t.Fatalf("filter.applied = %q; want exclude_muted (no bot_strip_p2p under user). Raw: %s",
|
|
parsed.Data.Filter.Applied, out)
|
|
}
|
|
if parsed.Data.Filter.FilteredCount != 1 {
|
|
t.Fatalf("filter.filtered_count = %d; want 1 (the muted p2p row). Raw: %s",
|
|
parsed.Data.Filter.FilteredCount, out)
|
|
}
|
|
// The muted p2p row should be gone from chats; only oc_g remains.
|
|
if len(parsed.Data.Chats) != 1 {
|
|
t.Fatalf("expected 1 chat after muting; got %d. Raw: %s", len(parsed.Data.Chats), out)
|
|
}
|
|
if parsed.Data.Chats[0]["chat_id"] != "oc_g" {
|
|
t.Fatalf("remaining chat = %v; want oc_g", parsed.Data.Chats[0]["chat_id"])
|
|
}
|
|
}
|
|
|
|
func TestChatList_SortMapping(t *testing.T) {
|
|
cases := []struct{ sort, want string }{
|
|
{"create_time", "ByCreateTimeAsc"},
|
|
{"active_time", "ByActiveTimeDesc"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.sort, func(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{"sort": c.sort}, nil)
|
|
got := buildChatListParams(rt, "")
|
|
if got["sort_type"] != c.want {
|
|
t.Fatalf("sort=%s -> sort_type=%v, want %s", c.sort, got["sort_type"], c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChatList_SortAliasParity proves the hidden --sort-type alias maps to the
|
|
// exact same upstream request as the equivalent new --sort value (byte-equal).
|
|
func TestChatList_SortAliasParity(t *testing.T) {
|
|
pairs := []struct{ newVal, oldVal string }{
|
|
{"create_time", "ByCreateTimeAsc"},
|
|
{"active_time", "ByActiveTimeDesc"},
|
|
}
|
|
for _, p := range pairs {
|
|
t.Run(p.newVal, func(t *testing.T) {
|
|
newRT := newChatListTestRuntimeContext(t, map[string]string{"sort": p.newVal}, nil)
|
|
oldRT := newChatListTestRuntimeContext(t, map[string]string{"sort-type": p.oldVal}, nil)
|
|
a := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), newRT))
|
|
b := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), oldRT))
|
|
if a != b {
|
|
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestChatList_SortNewWins: both flags set -> new wins, old ignored, no error.
|
|
func TestChatList_SortNewWins(t *testing.T) {
|
|
rt := newChatListTestRuntimeContext(t, map[string]string{
|
|
"sort": "active_time",
|
|
"sort-type": "ByCreateTimeAsc",
|
|
}, nil)
|
|
got := buildChatListParams(rt, "")
|
|
if got["sort_type"] != "ByActiveTimeDesc" {
|
|
t.Fatalf("new should win: sort_type=%v, want ByActiveTimeDesc", got["sort_type"])
|
|
}
|
|
}
|
|
|
|
// TestChatList_SortFlagSurface asserts the declared flag structure.
|
|
func TestChatList_SortFlagSurface(t *testing.T) {
|
|
var sortFlag, aliasFlag *common.Flag
|
|
for i := range ImChatList.Flags {
|
|
switch ImChatList.Flags[i].Name {
|
|
case "sort":
|
|
sortFlag = &ImChatList.Flags[i]
|
|
case "sort-type":
|
|
aliasFlag = &ImChatList.Flags[i]
|
|
}
|
|
}
|
|
if sortFlag == nil || aliasFlag == nil {
|
|
t.Fatalf("expected both --sort and --sort-type flags declared")
|
|
}
|
|
if sortFlag.Default != "create_time" {
|
|
t.Errorf("--sort Default = %q, want create_time", sortFlag.Default)
|
|
}
|
|
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,active_time" {
|
|
t.Errorf("--sort Enum = %q, want create_time,active_time", got)
|
|
}
|
|
if !strings.Contains(sortFlag.Desc, "create_time") || !strings.Contains(sortFlag.Desc, "active_time") {
|
|
t.Errorf("--sort Desc must document both fields/directions: %q", sortFlag.Desc)
|
|
}
|
|
if !aliasFlag.Hidden {
|
|
t.Errorf("--sort-type must be Hidden")
|
|
}
|
|
if got := strings.Join(aliasFlag.Enum, ","); got != "ByCreateTimeAsc,ByActiveTimeDesc" {
|
|
t.Errorf("--sort-type Enum = %q, want ByCreateTimeAsc,ByActiveTimeDesc", got)
|
|
}
|
|
if aliasFlag.Default != "" {
|
|
t.Errorf("--sort-type (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
|
}
|
|
}
|