mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 14:38:53 +08:00
Compare commits
5 Commits
docs/lark-
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4c313c8f1 | ||
|
|
a2c820643d | ||
|
|
8e60f01474 | ||
|
|
465c789f7c | ||
|
|
2a7e9c7d0d |
@@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *CheckOptions
|
||||
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *LogoutOptions
|
||||
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
@@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *StatusOptions
|
||||
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Error("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
@@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
var gotOpts *ScopesOptions
|
||||
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "pretty", "--json"})
|
||||
err := cmd.Execute()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if gotOpts == nil {
|
||||
t.Fatal("expected opts to be set")
|
||||
}
|
||||
if !gotOpts.JSON {
|
||||
t.Error("expected JSON=true")
|
||||
}
|
||||
if gotOpts.Format != "json" {
|
||||
t.Errorf("expected format json, got %s", gotOpts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
type CheckOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Scope string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthCheck creates the auth check subcommand.
|
||||
@@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.MarkFlagRequired("scope")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// ListOptions holds all inputs for auth list.
|
||||
type ListOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthList creates the auth list subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
return authListRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
@@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// auth list is a read-only probe; the "configured but no users"
|
||||
// branch below already returns exit 0 with a stderr hint, so we
|
||||
// keep the same contract here. We still want the hint to be
|
||||
@@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error {
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"users": []map[string]interface{}{},
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
|
||||
// reason this hint exists workspace-aware in the first place: an AI agent
|
||||
// in OpenClaw / Hermes that probes auth list before binding gets routed to
|
||||
@@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T)
|
||||
t.Errorf("agent hint must not mention config init: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
users, ok := payload["users"].([]any)
|
||||
if !ok || len(users) != 0 {
|
||||
t.Errorf("stdout.users = %v, want empty array", payload["users"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authListRun(&ListOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
// LogoutOptions holds all inputs for auth logout.
|
||||
type LogoutOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthLogout creates the auth logout subcommand.
|
||||
@@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
|
||||
return authLogoutRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "write")
|
||||
|
||||
return cmd
|
||||
@@ -44,12 +46,28 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
if multi == nil || len(multi.Apps) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_configured",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
app := multi.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil || len(app.Users) == 0 {
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": false,
|
||||
"reason": "not_logged_in",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
|
||||
return nil
|
||||
}
|
||||
@@ -63,6 +81,13 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.JSON {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"ok": true,
|
||||
"loggedOut": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
147
cmd/auth/logout_test.go
Normal file
147
cmd/auth/logout_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func writeLogoutConfig(t *testing.T, users []core.AppUser) {
|
||||
t.Helper()
|
||||
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
|
||||
CurrentApp: "test-app",
|
||||
Apps: []core.AppConfig{
|
||||
{
|
||||
AppId: "test-app",
|
||||
AppSecret: core.PlainSecret("test-secret"),
|
||||
Brand: core.BrandFeishu,
|
||||
Users: users,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveMultiAppConfig() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_configured" {
|
||||
t.Errorf("stdout.reason = %v, want not_configured", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, nil)
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != false {
|
||||
t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"])
|
||||
}
|
||||
if payload["reason"] != "not_logged_in" {
|
||||
t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
if payload["loggedOut"] != true {
|
||||
t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"])
|
||||
}
|
||||
if _, hasReason := payload["reason"]; hasReason {
|
||||
t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"])
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}})
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: "test-app",
|
||||
UserOpenId: "ou_user",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
|
||||
if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil {
|
||||
t.Fatalf("authLogoutRun() error = %v", err)
|
||||
}
|
||||
|
||||
if stdout.Len() != 0 {
|
||||
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Logged out") {
|
||||
t.Errorf("stderr = %q, want success text", stderr.String())
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type ScopesOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Ctx context.Context
|
||||
Format string
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthScopes creates the auth scopes subcommand.
|
||||
@@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
Short: "Query scopes enabled for the app",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
@@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type StatusOptions struct {
|
||||
Factory *cmdutil.Factory
|
||||
Verify bool
|
||||
JSON bool
|
||||
}
|
||||
|
||||
// NewCmdAuthStatus creates the auth status subcommand.
|
||||
@@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmdutil.SetRisk(cmd, "read")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -841,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
"page-size": "10",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) {
|
||||
if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) {
|
||||
t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
@@ -901,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"user-id-type": "open_id",
|
||||
"sort-type": "ByCreateTimeAsc",
|
||||
"sort": "create_time",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) {
|
||||
|
||||
@@ -48,7 +48,8 @@ var ImChatList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "ID type for owner_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
|
||||
{Name: "sort-type", Default: "ByCreateTimeAsc", Desc: "sort order", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "sort", Default: "create_time", Desc: "sort field: create_time (ascending) | active_time (descending)", Enum: []string{"create_time", "active_time"}},
|
||||
{Name: "sort-type", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"ByCreateTimeAsc", "ByActiveTimeDesc"}},
|
||||
{Name: "types", Type: "string_slice", Desc: "chat types to include (group, p2p); omit = groups only (backward compatible); p2p requires user identity"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
@@ -266,9 +267,16 @@ func resolveTypes(runtime *common.RuntimeContext) (string, bool, error) {
|
||||
// CSV string already normalized + bot-stripped by resolveTypes; pass "" to
|
||||
// omit the types query param entirely (backward compatible default).
|
||||
func buildChatListParams(runtime *common.RuntimeContext, effectiveTypes string) map[string]interface{} {
|
||||
sortType := map[string]string{
|
||||
"create_time": "ByCreateTimeAsc",
|
||||
"active_time": "ByActiveTimeDesc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-type", "sort"); ok {
|
||||
sortType = old // old value is already the upstream enum -> pass through
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"sort_type": runtime.Str("sort-type"),
|
||||
"sort_type": sortType,
|
||||
}
|
||||
if n := runtime.Int("page-size"); n > 0 {
|
||||
params["page_size"] = n
|
||||
|
||||
@@ -611,3 +611,85 @@ func TestImChatList_Execute_UserMuteFiltersP2p(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ var ImChatMessageList = common.Shortcut{
|
||||
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
|
||||
{Name: "start", Desc: "start time (ISO 8601)"},
|
||||
{Name: "end", Desc: "end time (ISO 8601)"},
|
||||
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "order", Default: "desc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -209,7 +210,11 @@ func buildChatMessageListParams(sortFlag, pageSizeStr, chatId string) larkcore.Q
|
||||
}
|
||||
|
||||
func buildChatMessageListRequest(runtime *common.RuntimeContext, chatId string) (larkcore.QueryParams, error) {
|
||||
params := buildChatMessageListParams(runtime.Str("sort"), runtime.Str("page-size"), chatId)
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old // old value is asc/desc -> must go through the same map, never pass through
|
||||
}
|
||||
params := buildChatMessageListParams(dir, runtime.Str("page-size"), chatId)
|
||||
|
||||
if startFlag := runtime.Str("start"); startFlag != "" {
|
||||
startTime, err := common.ParseTime(startFlag)
|
||||
|
||||
98
shortcuts/im/im_chat_messages_list_test.go
Normal file
98
shortcuts/im/im_chat_messages_list_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// newMsgListTestRT registers chat-id (so the builder has a container) plus the
|
||||
// sort flags under test; only flags present in stringFlags are "set" (Changed).
|
||||
func newMsgListTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["chat-id"]; !ok {
|
||||
stringFlags["chat-id"] = "oc_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": c.order})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("buildChatMessageListRequest() error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessagesList_OrderAliasParity: hidden --sort alias (asc/desc) must map
|
||||
// through the SAME table as --order (NOT pass through), producing identical upstream.
|
||||
func TestChatMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newMsgListTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newMsgListTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImChatMessageList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderNewWins(t *testing.T) {
|
||||
rt := newMsgListTestRT(t, map[string]string{"order": "asc", "sort": "desc"})
|
||||
params, err := buildChatMessageListRequest(rt, "oc_test")
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if got := params["sort_type"][0]; got != "ByCreateTimeAsc" {
|
||||
t.Fatalf("new should win: sort_type=%s, want ByCreateTimeAsc", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatMessageList.Flags {
|
||||
switch ImChatMessageList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImChatMessageList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImChatMessageList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "desc" {
|
||||
t.Errorf("--order Default = %q, want desc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--sort (alias) Enum = %q, want asc,desc", got)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,8 @@ var ImChatSearch = common.Shortcut{
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
{Name: "is-manager", Type: "bool", Desc: "only show chats you created or manage"},
|
||||
{Name: "disable-search-by-user", Type: "bool", Desc: "disable search-by-member-name (default: search by member name first, then group name)"},
|
||||
{Name: "sort-by", Desc: "sort field (descending)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "sort", Desc: "sort field (always descending): create_time | update_time | member_count", Enum: []string{"create_time", "update_time", "member_count"}},
|
||||
{Name: "sort-by", Hidden: true, Desc: "alias of --sort (hidden)", Enum: []string{"create_time_desc", "update_time_desc", "member_count_desc"}},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"},
|
||||
@@ -209,8 +210,8 @@ var ImChatSearch = common.Shortcut{
|
||||
// buildSearchChatBody builds the JSON request body for POST /im/v2/chats/search
|
||||
// from the runtime flag values. The query string is normalized via
|
||||
// normalizeChatSearchQuery (hyphenated terms get quoted). The "filter" object
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort-by
|
||||
// is empty.
|
||||
// is omitted when no filter flags are set; "sorter" is omitted when --sort
|
||||
// (and its hidden alias --sort-by) is unset.
|
||||
func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
body := map[string]interface{}{}
|
||||
|
||||
@@ -256,9 +257,18 @@ func buildSearchChatBody(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["filter"] = filter
|
||||
}
|
||||
|
||||
// Build sorters (always descending)
|
||||
if sortBy := runtime.Str("sort-by"); sortBy != "" {
|
||||
body["sorter"] = sortBy
|
||||
// Build sorter (always descending). --sort maps field -> field_desc; the hidden
|
||||
// --sort-by alias is already the upstream value (pass-through). Omitted when unset.
|
||||
sorter := map[string]string{
|
||||
"create_time": "create_time_desc",
|
||||
"update_time": "update_time_desc",
|
||||
"member_count": "member_count_desc",
|
||||
}[runtime.Str("sort")]
|
||||
if old, ok := aliasFlagValue(runtime, "sort-by", "sort"); ok {
|
||||
sorter = old
|
||||
}
|
||||
if sorter != "" {
|
||||
body["sorter"] = sorter
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
102
shortcuts/im/im_chat_search_test.go
Normal file
102
shortcuts/im/im_chat_search_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newSearchTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["query"]; !ok {
|
||||
stringFlags["query"] = "team"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestChatSearch_SortMapping(t *testing.T) {
|
||||
cases := []struct{ sort, want string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.sort, func(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": c.sort})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != c.want {
|
||||
t.Fatalf("sort=%s -> sorter=%v, want %s", c.sort, body["sorter"], c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortOmittedWhenUnset: no --sort and no --sort-by -> sorter omitted.
|
||||
func TestChatSearch_SortOmittedWhenUnset(t *testing.T) {
|
||||
rt := newSearchTestRT(t, nil)
|
||||
body := buildSearchChatBody(rt)
|
||||
if _, present := body["sorter"]; present {
|
||||
t.Fatalf("sorter should be omitted when neither --sort nor --sort-by set")
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatSearch_SortAliasParity: hidden --sort-by value is already the upstream
|
||||
// sorter (pass-through), so it must equal the mapped new --sort body.
|
||||
func TestChatSearch_SortAliasParity(t *testing.T) {
|
||||
pairs := []struct{ newVal, oldVal string }{
|
||||
{"create_time", "create_time_desc"},
|
||||
{"update_time", "update_time_desc"},
|
||||
{"member_count", "member_count_desc"},
|
||||
}
|
||||
for _, p := range pairs {
|
||||
t.Run(p.newVal, func(t *testing.T) {
|
||||
newBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort": p.newVal}))
|
||||
oldBody := buildSearchChatBody(newSearchTestRT(t, map[string]string{"sort-by": p.oldVal}))
|
||||
if newBody["sorter"] != oldBody["sorter"] {
|
||||
t.Fatalf("alias parity: new sorter=%v, old sorter=%v", newBody["sorter"], oldBody["sorter"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortNewWins(t *testing.T) {
|
||||
rt := newSearchTestRT(t, map[string]string{"sort": "member_count", "sort-by": "create_time_desc"})
|
||||
body := buildSearchChatBody(rt)
|
||||
if body["sorter"] != "member_count_desc" {
|
||||
t.Fatalf("new should win: sorter=%v, want member_count_desc", body["sorter"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSearch_SortFlagSurface(t *testing.T) {
|
||||
var sortFlag, aliasFlag *common.Flag
|
||||
for i := range ImChatSearch.Flags {
|
||||
switch ImChatSearch.Flags[i].Name {
|
||||
case "sort":
|
||||
sortFlag = &ImChatSearch.Flags[i]
|
||||
case "sort-by":
|
||||
aliasFlag = &ImChatSearch.Flags[i]
|
||||
}
|
||||
}
|
||||
if sortFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --sort and --sort-by flags declared")
|
||||
}
|
||||
if sortFlag.Default != "" {
|
||||
t.Errorf("--sort must have no default (sorter omitted when unset), got %q", sortFlag.Default)
|
||||
}
|
||||
if got := strings.Join(sortFlag.Enum, ","); got != "create_time,update_time,member_count" {
|
||||
t.Errorf("--sort Enum = %q", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort-by must be Hidden")
|
||||
}
|
||||
if got := strings.Join(aliasFlag.Enum, ","); got != "create_time_desc,update_time_desc,member_count_desc" {
|
||||
t.Errorf("--sort-by Enum = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,8 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "thread", Desc: "thread ID (om_xxx or omt_xxx)", Required: true},
|
||||
{Name: "sort", Default: "asc", Desc: "sort order", Enum: []string{"asc", "desc"}},
|
||||
{Name: "order", Default: "asc", Desc: "sort order: asc | desc", Enum: []string{"asc", "desc"}},
|
||||
{Name: "sort", Hidden: true, Desc: "alias of --order (hidden)", Enum: []string{"asc", "desc"}},
|
||||
{Name: "page-size", Default: "50", Desc: "page size (1-500)"},
|
||||
{Name: "page-token", Desc: "page token"},
|
||||
{Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"},
|
||||
@@ -39,15 +40,10 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
threadFlag := runtime.Str("thread")
|
||||
sortFlag := runtime.Str("sort")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageSizeStr := runtime.Str("page-size")
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
d := common.NewDryRunAPI()
|
||||
@@ -57,21 +53,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
containerID = "<resolved_thread_id>"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"container_id_type": "thread",
|
||||
"container_id": containerID,
|
||||
"sort_type": sortType,
|
||||
"page_size": pageSize,
|
||||
"card_msg_content_type": "raw_card_content",
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
params := buildThreadsMessagesListParams(dir, containerID, pageSize, pageToken)
|
||||
|
||||
d = d.
|
||||
GET("/open-apis/im/v1/messages").
|
||||
Params(params).
|
||||
Set("thread", threadFlag).Set("sort", sortFlag).Set("page_size", pageSizeStr)
|
||||
Params(toDryParams(params)).
|
||||
Set("thread", threadFlag).Set("order", dir).Set("page_size", pageSizeStr)
|
||||
if !runtime.Bool("no-reactions") {
|
||||
d = d.POST("/open-apis/im/v1/messages/reactions/batch_query").
|
||||
Desc("Reaction enrichment: queries returned thread messages in batches of up to 20. Pass --no-reactions to skip.")
|
||||
@@ -97,26 +84,12 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sortFlag := runtime.Str("sort")
|
||||
dir := resolveThreadsOrder(runtime)
|
||||
pageToken := runtime.Str("page-token")
|
||||
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if sortFlag == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
|
||||
pageSize, _ := common.ValidatePageSizeTyped(runtime, "page-size", threadsMessagesMaxPageSize, 1, threadsMessagesMaxPageSize)
|
||||
|
||||
params := map[string][]string{
|
||||
"container_id_type": []string{"thread"},
|
||||
"container_id": []string{threadId},
|
||||
"sort_type": []string{sortType},
|
||||
"page_size": []string{strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": []string{"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
params := buildThreadsMessagesListParams(dir, threadId, pageSize, pageToken)
|
||||
|
||||
data, err := runtime.DoAPIJSONTyped(http.MethodGet, "/open-apis/im/v1/messages", params, nil)
|
||||
if err != nil {
|
||||
@@ -188,3 +161,45 @@ var ImThreadsMessagesList = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildThreadsMessagesListParams builds the upstream query params shared by
|
||||
// DryRun and Execute, so the asc/desc -> sort_type mapping lives in exactly one
|
||||
// place (precondition for the dry-run == real alias-parity test).
|
||||
func buildThreadsMessagesListParams(dir, containerID string, pageSize int, pageToken string) map[string][]string {
|
||||
sortType := "ByCreateTimeAsc"
|
||||
if dir == "desc" {
|
||||
sortType = "ByCreateTimeDesc"
|
||||
}
|
||||
params := map[string][]string{
|
||||
"container_id_type": {"thread"},
|
||||
"container_id": {containerID},
|
||||
"sort_type": {sortType},
|
||||
"page_size": {strconv.Itoa(pageSize)},
|
||||
"card_msg_content_type": {"raw_card_content"},
|
||||
}
|
||||
if pageToken != "" {
|
||||
params["page_token"] = []string{pageToken}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// resolveThreadsOrder picks --order, falling back to the hidden --sort alias.
|
||||
func resolveThreadsOrder(runtime *common.RuntimeContext) string {
|
||||
dir := runtime.Str("order")
|
||||
if old, ok := aliasFlagValue(runtime, "sort", "order"); ok {
|
||||
dir = old
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// toDryParams flattens single-valued query params to scalars for dry-run preview,
|
||||
// matching the historical dry-run JSON shape.
|
||||
func toDryParams(p map[string][]string) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(p))
|
||||
for k, v := range p {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
81
shortcuts/im/im_threads_messages_list_test.go
Normal file
81
shortcuts/im/im_threads_messages_list_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newThreadsTestRT(t *testing.T, stringFlags map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
if stringFlags == nil {
|
||||
stringFlags = map[string]string{}
|
||||
}
|
||||
if _, ok := stringFlags["thread"]; !ok {
|
||||
stringFlags["thread"] = "omt_test"
|
||||
}
|
||||
return newChatListTestRuntimeContext(t, stringFlags, nil)
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderMapping(t *testing.T) {
|
||||
cases := []struct{ order, want string }{
|
||||
{"asc", "ByCreateTimeAsc"},
|
||||
{"desc", "ByCreateTimeDesc"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.order, func(t *testing.T) {
|
||||
got := buildThreadsMessagesListParams(c.order, "omt_test", 50, "")
|
||||
if v := got["sort_type"][0]; v != c.want {
|
||||
t.Fatalf("order=%s -> sort_type=%s, want %s", c.order, v, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestThreadsMessagesList_OrderAliasParity proves DryRun(--sort dir) == DryRun(--order dir).
|
||||
// This is the test the refactor exists to make meaningful (single shared mapping).
|
||||
func TestThreadsMessagesList_OrderAliasParity(t *testing.T) {
|
||||
for _, dir := range []string{"asc", "desc"} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
newRT := newThreadsTestRT(t, map[string]string{"order": dir})
|
||||
oldRT := newThreadsTestRT(t, map[string]string{"sort": dir})
|
||||
a := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), newRT))
|
||||
b := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), oldRT))
|
||||
if a != b {
|
||||
t.Fatalf("alias parity broken:\n new=%s\n old=%s", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadsMessagesList_OrderFlagSurface(t *testing.T) {
|
||||
var orderFlag, aliasFlag *common.Flag
|
||||
for i := range ImThreadsMessagesList.Flags {
|
||||
switch ImThreadsMessagesList.Flags[i].Name {
|
||||
case "order":
|
||||
orderFlag = &ImThreadsMessagesList.Flags[i]
|
||||
case "sort":
|
||||
aliasFlag = &ImThreadsMessagesList.Flags[i]
|
||||
}
|
||||
}
|
||||
if orderFlag == nil || aliasFlag == nil {
|
||||
t.Fatalf("expected both --order and --sort flags declared")
|
||||
}
|
||||
if orderFlag.Default != "asc" {
|
||||
t.Errorf("--order Default = %q, want asc", orderFlag.Default)
|
||||
}
|
||||
if got := strings.Join(orderFlag.Enum, ","); got != "asc,desc" {
|
||||
t.Errorf("--order Enum = %q, want asc,desc", got)
|
||||
}
|
||||
if !aliasFlag.Hidden {
|
||||
t.Errorf("--sort must be Hidden")
|
||||
}
|
||||
if aliasFlag.Default != "" {
|
||||
t.Errorf("--sort (hidden alias) must not carry a Default, got %q", aliasFlag.Default)
|
||||
}
|
||||
}
|
||||
18
shortcuts/im/sort_flags.go
Normal file
18
shortcuts/im/sort_flags.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// aliasFlagValue handles a renamed sort flag whose old name is kept as a silent
|
||||
// alias. It returns (oldValue, true) only when the old flag was explicitly used
|
||||
// and the new one was not; otherwise ("", false) — meaning "no old flag, or both
|
||||
// given (new wins), so use the new-flag logic". Pure function, no IO: callable
|
||||
// from DryRun, Execute, and minimal test fixtures alike. Never prints anything.
|
||||
func aliasFlagValue(rt *common.RuntimeContext, oldName, newName string) (string, bool) {
|
||||
if rt.Changed(oldName) && !rt.Changed(newName) {
|
||||
return rt.Str(oldName), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
53
shortcuts/im/sort_flags_test.go
Normal file
53
shortcuts/im/sort_flags_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newAliasTestRT registers a new flag (with a default) and an old flag, then
|
||||
// sets only the flags present in `set` — so Changed() reflects exactly which
|
||||
// flags were "passed on the command line".
|
||||
func newAliasTestRT(t *testing.T, newName, newDefault, oldName string, set map[string]string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String(newName, newDefault, "")
|
||||
cmd.Flags().String(oldName, "", "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for k, v := range set {
|
||||
if err := cmd.Flags().Set(k, v); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", k, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
func TestAliasFlagValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
set map[string]string
|
||||
wantVal string
|
||||
wantOK bool
|
||||
}{
|
||||
{"only old set", map[string]string{"sort-type": "ByActiveTimeDesc"}, "ByActiveTimeDesc", true},
|
||||
{"neither set", nil, "", false},
|
||||
{"only new set", map[string]string{"sort": "active_time"}, "", false},
|
||||
{"both set new wins", map[string]string{"sort": "active_time", "sort-type": "ByCreateTimeAsc"}, "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
rt := newAliasTestRT(t, "sort", "create_time", "sort-type", c.set)
|
||||
gotVal, gotOK := aliasFlagValue(rt, "sort-type", "sort")
|
||||
if gotVal != c.wantVal || gotOK != c.wantOK {
|
||||
t.Fatalf("aliasFlagValue() = (%q, %v), want (%q, %v)", gotVal, gotOK, c.wantVal, c.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ This skill maps to the shortcut: `lark-cli im +chat-list` (internally calls `GET
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# List the user's chats (default sort: ByCreateTimeAsc)
|
||||
# List the user's chats (default sort: create_time, ascending)
|
||||
lark-cli im +chat-list
|
||||
|
||||
# Sort by recent activity (most recently active first)
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc
|
||||
lark-cli im +chat-list --sort active_time
|
||||
|
||||
# Limit page size
|
||||
lark-cli im +chat-list --page-size 50
|
||||
@@ -48,7 +48,7 @@ lark-cli im +chat-list --as user --types p2p
|
||||
|------|------|------|------|
|
||||
| `--user-id-type <type>` | No | `open_id` (default), `union_id`, `user_id` | ID type used for `owner_id` in the response |
|
||||
| `--types <strings>` | No | `group`, `p2p` (comma-separated or repeated) | Chat types to include. Omitted = groups only (backward compatible). `p2p` requires user identity (`--as user`); under `--as bot`, `--types=p2p` alone is rejected and `--types=p2p,group` is silently downgraded to `group` |
|
||||
| `--sort-type <type>` | No | `ByCreateTimeAsc` (default), `ByActiveTimeDesc` | Result ordering |
|
||||
| `--sort <field>` | No | `create_time` (default, ascending), `active_time` (descending) | Result ordering |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive; see "Filtering muted chats" below |
|
||||
@@ -130,13 +130,13 @@ When the flag is set, the JSON envelope gains a `filter` sub-object (absent othe
|
||||
### Scenario 1: List my recent chats
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --page-size 10
|
||||
lark-cli im +chat-list --sort active_time --page-size 10
|
||||
```
|
||||
|
||||
### Scenario 2: List my non-muted chats sorted by activity
|
||||
|
||||
```bash
|
||||
lark-cli im +chat-list --sort-type ByActiveTimeDesc --exclude-muted
|
||||
lark-cli im +chat-list --sort active_time --exclude-muted
|
||||
```
|
||||
|
||||
### Scenario 3: Iterate all my chats programmatically
|
||||
|
||||
@@ -24,7 +24,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --start 2026-03-10 --end 2026-03-11
|
||||
|
||||
# Control sort order and page size (max 50)
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --sort asc --page-size 20
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --order asc --page-size 20
|
||||
|
||||
# Pagination
|
||||
lark-cli im +chat-messages-list --chat-id oc_xxx --page-token "xxx"
|
||||
@@ -41,7 +41,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
| `--user-id <id>` | One of two | Specify a DM conversation by the other user's open_id (`ou_xxx`); p2p chat_id is resolved automatically. Requires user identity (`--as user`); not supported with bot identity |
|
||||
| `--start <time>` | No | Start time (ISO 8601 or date only) |
|
||||
| `--end <time>` | No | End time (ISO 8601 or date only) |
|
||||
| `--sort <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--order <order>` | No | Sort order: `asc` / `desc` (default `desc`) |
|
||||
| `--page-size <n>` | No | Page size (default 50, max 50) |
|
||||
| `--page-token <token>` | No | Pagination token |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
@@ -49,6 +49,8 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
|
||||
|
||||
> Rule: `--chat-id` and `--user-id` are mutually exclusive. You must provide exactly one of them.
|
||||
|
||||
> **CAUTION:** `--order` is the only sort axis — messages are always ordered by creation time, `asc` or `desc`. There is no field axis: the command cannot sort by sender or any other field, so do **not** attempt `--sort sender` or similar (it is rejected). If the user asks to group or sort by sender, fetch with `--order` and aggregate client-side, and tell them this is local post-processing, not a CLI/API sort capability.
|
||||
|
||||
## Resource Rendering
|
||||
|
||||
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
|
||||
@@ -75,8 +77,8 @@ lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
| Scenario | Recommendation |
|
||||
|------|------|
|
||||
| You need context | Call `im +threads-messages-list --sort desc --page-size 10` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Use `im +threads-messages-list --sort asc --page-size 50`, then paginate if needed |
|
||||
| You need context | Call `im +threads-messages-list --order desc --page-size 10` for the discovered thread_id to inspect recent replies |
|
||||
| The user asks for the "full discussion" | Use `im +threads-messages-list --order asc --page-size 50`, then paginate if needed |
|
||||
| You only need an overview | Skip thread expansion |
|
||||
|
||||
## Output Fields
|
||||
|
||||
@@ -50,7 +50,7 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
| `--member-ids <ids>` | No (at least one of `--query` / `--member-ids` required) | Up to 50, format `ou_xxx` | Filter by member open_ids; can be used alone or combined with `--query` |
|
||||
| `--is-manager` | No | - | Only show chats you created or manage |
|
||||
| `--disable-search-by-user` | No | - | Disable member-name-based matching and search by group name only |
|
||||
| `--sort-by <field>` | No | `create_time_desc`, `update_time_desc`, `member_count_desc` | Sort field in descending order |
|
||||
| `--sort <field>` | No | `create_time`, `update_time`, `member_count` | Sort field (always descending) |
|
||||
| `--page-size <n>` | No | 1-100, default 20 | Number of results per page |
|
||||
| `--page-token <token>` | No | - | Pagination token from the previous response |
|
||||
| `--exclude-muted` | No | User identity only | Drop chats the current user has muted (do-not-disturb). Under `--as bot`, the flag is silently inactive (mute is a per-user setting); see "Filtering muted chats" below |
|
||||
@@ -59,6 +59,8 @@ lark-cli im +chat-search --query "project" --dry-run
|
||||
|
||||
> **Note:** Supports both `--as user` (default) and `--as bot`. When using bot identity, the app must have bot capability enabled.
|
||||
|
||||
> **CAUTION:** `--sort` is **always descending** — the search API only ranks the chosen field high-to-low (e.g. `member_count` = most members first). There is no ascending option. If the user asks for "fewest first / ascending / 从少到多", tell them the search API does not support ascending order; any low-to-high view requires re-sorting the fetched page client-side and is not an upstream sort. Do **not** invent values like `member_count_asc` or pass `asc` (they are rejected).
|
||||
|
||||
## Output Fields
|
||||
|
||||
| Field | Description |
|
||||
|
||||
@@ -15,7 +15,7 @@ This skill maps to the shortcut: `lark-cli im +threads-messages-list` (internall
|
||||
lark-cli im +threads-messages-list --thread omt_xxx
|
||||
|
||||
# Reverse chronological order (latest first)
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --sort desc
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --order desc
|
||||
|
||||
# Control page size
|
||||
lark-cli im +threads-messages-list --thread omt_xxx --page-size 20
|
||||
@@ -42,7 +42,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run
|
||||
| `--thread <id>` | Yes | Thread ID (`om_xxx` or `omt_xxx` format) |
|
||||
| `--no-reactions` | No | Skip auto-fetching the `reactions` block |
|
||||
| `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default |
|
||||
| `--sort <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--order <order>` | No | Sort order: `asc` (default) / `desc` |
|
||||
| `--page-size <n>` | No | Number of items per page (default 50, range 1-500) |
|
||||
| `--page-token <token>` | No | Pagination token for the next page |
|
||||
| `--format <fmt>` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` |
|
||||
@@ -68,9 +68,9 @@ Thread messages do not support `start_time` / `end_time` filtering because of Fe
|
||||
|
||||
| Scenario | Recommended Parameters |
|
||||
|------|---------|
|
||||
| Quickly inspect recent replies | `--sort desc --page-size 10` |
|
||||
| Read the full thread in chronological order | `--sort asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--sort desc --page-size 1` |
|
||||
| Quickly inspect recent replies | `--order desc --page-size 10` |
|
||||
| Read the full thread in chronological order | `--order asc --page-size 50`, then paginate as needed |
|
||||
| Just confirm whether replies exist | `--order desc --page-size 1` |
|
||||
|
||||
## Usage Scenarios
|
||||
|
||||
|
||||
125
tests/cli_e2e/apps/apps_list_workflow_test.go
Normal file
125
tests/cli_e2e/apps/apps_list_workflow_test.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestAppsListWorkflowAsUser exercises `apps +list` against the live service.
|
||||
// +list is the only apps shortcut that is read-only AND requires no pre-existing
|
||||
// app_id fixture, so it is the sole command in the domain that can be live-tested
|
||||
// without leaking tenant state (apps has no +delete endpoint). All assertions are
|
||||
// tenant-data-independent: the envelope/array shape is checked unconditionally,
|
||||
// field-level contracts only when items are present. An empty app list is valid.
|
||||
func TestAppsListWorkflowAsUser(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
// assertListEnvelope checks the shared contract for every +list invocation:
|
||||
// exit 0, ok:true envelope, and data.items is a JSON array. It returns the
|
||||
// items result for scenario-specific follow-up assertions. Failure messages
|
||||
// reference field paths only (not the full data envelope) so real tenant app
|
||||
// names are not written into test logs.
|
||||
assertListEnvelope := func(t *testing.T, result *clie2e.Result) gjson.Result {
|
||||
t.Helper()
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
items := gjson.Get(result.Stdout, "data.items")
|
||||
require.True(t, items.IsArray(), "data.items should be a JSON array")
|
||||
return items
|
||||
}
|
||||
|
||||
t.Run("default list", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
items := assertListEnvelope(t, result)
|
||||
|
||||
// Field-level contract only when the tenant has at least one app: every
|
||||
// item carries app_id + name, and the shortcut strips icon_url/created_at
|
||||
// (apps_list.go projects them away before output). The loop is a no-op on
|
||||
// an empty tenant, keeping the test non-flaky.
|
||||
for _, item := range items.Array() {
|
||||
assert.NotEmpty(t, item.Get("app_id").String(), "each item should have app_id")
|
||||
assert.NotEmpty(t, item.Get("name").String(), "each item should have name")
|
||||
assert.False(t, item.Get("icon_url").Exists(), "icon_url should be stripped from list output")
|
||||
assert.False(t, item.Get("created_at").Exists(), "created_at should be stripped from list output")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("page size honored", func(t *testing.T) {
|
||||
// Baseline uncapped list first: the page-size=1 cap is only a meaningful
|
||||
// assertion when the tenant actually has >= 2 apps. On a near-empty tenant
|
||||
// the cap is vacuously satisfied, so we skip the comparison rather than
|
||||
// claim coverage we don't have.
|
||||
baseline, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
baselineItems := assertListEnvelope(t, baseline)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--page-size", "1"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
items := assertListEnvelope(t, result)
|
||||
|
||||
if len(baselineItems.Array()) >= 2 {
|
||||
assert.LessOrEqual(t, len(items.Array()), 1, "page-size 1 should cap items at 1 when the tenant has multiple apps")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keyword no match", func(t *testing.T) {
|
||||
// A high-entropy keyword that cannot match any real app name; proves the
|
||||
// keyword filter is accepted and returns a well-formed (typically empty)
|
||||
// list rather than erroring.
|
||||
keyword := "lark-cli-e2e-nomatch-" + clie2e.GenerateSuffix()
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--keyword", keyword},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertListEnvelope(t, result)
|
||||
})
|
||||
|
||||
t.Run("ownership filter mine", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--ownership", "mine"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertListEnvelope(t, result)
|
||||
})
|
||||
|
||||
t.Run("app-type filter html", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"apps", "+list", "--app-type", "html"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
items := assertListEnvelope(t, result)
|
||||
// When the server echoes app_type on items, the html filter must return
|
||||
// exactly html apps. Conditional so the test does not assume the field is
|
||||
// always present in the list response.
|
||||
for _, item := range items.Array() {
|
||||
if at := item.Get("app_type"); at.Exists() {
|
||||
assert.Equal(t, "html", at.String(), "html filter should only return html apps when app_type is present")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
- Command coverage: 100% (9/9)
|
||||
- API dry-run coverage: 100% (7/7 API-backed commands)
|
||||
- Local E2E coverage: 100% (2/2 local-only commands)
|
||||
- Live coverage: 0%
|
||||
- Live coverage: 1/1 live-capable command (`apps +list`); all other commands blocked — see "Blocked" below
|
||||
|
||||
## Summary
|
||||
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
|
||||
@@ -17,8 +17,9 @@
|
||||
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing a Miaoda Git PAT; no body; `app_id` query metadata included.
|
||||
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
|
||||
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
|
||||
- `TestAppsListWorkflowAsUser`: live `apps +list` against the service. Five `t.Run` proof points — default list (envelope `ok:true`, `data.items` array, and when non-empty each item has `app_id`/`name` with `icon_url`/`created_at` stripped), `--page-size 1` caps items when the tenant has multiple apps, high-entropy `--keyword` returns a well-formed empty list, `--ownership mine` accepted, `--app-type html` never returns `full_stack`. All assertions are tenant-data-independent (empty list is valid); skips via `SkipWithoutUserToken` when no user credentials are present.
|
||||
|
||||
Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`.
|
||||
Blocked (live): every command except `apps +list` remains without live coverage. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so any create-and-cleanup workflow would leak tenant state. Every read command other than `+list` requires a `--app-id` (or a session/release id), and obtaining a real one means creating an app that cannot be cleaned up. A shared per-run fixture app was considered and rejected: with no delete path, each CI run would leave an orphaned app — a real and accumulating drain on Miaoda tenant resources. Only `apps +list` is read-only AND fixture-independent, so it is the sole live-covered command. Revisit the rest when the server exposes `DELETE /apps/{appId}`.
|
||||
|
||||
## Command Table
|
||||
|
||||
@@ -26,7 +27,7 @@ Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpo
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `html`/`full_stack`), `--description`, `--icon-url` | live blocked: no +delete to clean up |
|
||||
| ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete |
|
||||
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures |
|
||||
| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun; apps_list_workflow_test.go::TestAppsListWorkflowAsUser | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live covered: read-only, no app_id fixture needed; tenant-data-independent assertions |
|
||||
| ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids |
|
||||
| ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state |
|
||||
| ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API |
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
|
||||
|
||||
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
pushResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+push",
|
||||
"--local-dir", "local",
|
||||
@@ -173,7 +173,7 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
pushResult.AssertExitCode(t, 0)
|
||||
pushResult.AssertStdoutStatus(t, true)
|
||||
|
||||
@@ -313,14 +313,16 @@ func cleanupFeedShortcuts(parentT *testing.T, defaultAs string, chatIDs ...strin
|
||||
}
|
||||
|
||||
// TestIM_FeedShortcutDryRun covers all three shortcuts in --dry-run mode
|
||||
// using fake credentials. This is the only test that runs without a real
|
||||
// user token because dry-run short-circuits before any network call.
|
||||
// using env-only identity hints. strict_mode/default_as lock the command to
|
||||
// user identity without injecting a fake user token that would trigger
|
||||
// user_info verification during startup.
|
||||
func TestIM_FeedShortcutDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
|
||||
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -247,8 +247,9 @@ func TestIM_FlagDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_USER_ACCESS_TOKEN", "fake_user_token")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
t.Setenv("LARKSUITE_CLI_STRICT_MODE", "user")
|
||||
t.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
Reference in New Issue
Block a user