mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.
Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift
Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.
Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.
At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.
First PR in the feat/error-contract-* series.
673 lines
20 KiB
Go
673 lines
20 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package api
|
|
|
|
import (
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Method != "GET" {
|
|
t.Errorf("expected method GET, got %s", gotOpts.Method)
|
|
}
|
|
if gotOpts.Path != "/open-apis/test" {
|
|
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
|
|
}
|
|
if gotOpts.As != core.AsBot {
|
|
t.Errorf("expected as=bot, got %s", gotOpts.As)
|
|
}
|
|
if !gotOpts.DryRun {
|
|
t.Error("expected DryRun=true")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_DryRun(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
output := stdout.String()
|
|
if !strings.Contains(output, "Dry Run") {
|
|
t.Error("expected dry run output")
|
|
}
|
|
if !strings.Contains(output, "/open-apis/test") {
|
|
t.Error("expected path in dry run output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_BotMode(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register API endpoint stub
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stdout.String(), "success") {
|
|
t.Error("expected 'success' in output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_MissingArgs(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET"}) // missing path
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Error("expected error for missing args")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Error("expected validation error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestApiValidArgsFunction(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
fn := cmd.ValidArgsFunction
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
toComplete string
|
|
wantComps []string
|
|
wantDir cobra.ShellCompDirective
|
|
}{
|
|
{
|
|
name: "no args returns HTTP methods",
|
|
args: []string{},
|
|
toComplete: "",
|
|
wantComps: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
{
|
|
name: "one arg returns nil with NoFileComp",
|
|
args: []string{"GET"},
|
|
toComplete: "",
|
|
wantComps: nil,
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
{
|
|
name: "two args returns nil with NoFileComp",
|
|
args: []string{"GET", "/path"},
|
|
toComplete: "",
|
|
wantComps: nil,
|
|
wantDir: cobra.ShellCompDirectiveNoFileComp,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
comps, dir := fn(cmd, tt.args, tt.toComplete)
|
|
if dir != tt.wantDir {
|
|
t.Errorf("directive = %d, want %d", dir, tt.wantDir)
|
|
}
|
|
if tt.wantComps == nil {
|
|
if comps != nil {
|
|
t.Errorf("completions = %v, want nil", comps)
|
|
}
|
|
return
|
|
}
|
|
sort.Strings(comps)
|
|
sort.Strings(tt.wantComps)
|
|
if len(comps) != len(tt.wantComps) {
|
|
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
|
|
return
|
|
}
|
|
for i := range comps {
|
|
if comps[i] != tt.wantComps[i] {
|
|
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
|
|
break
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
flag := cmd.Flags().Lookup("as")
|
|
if flag == nil {
|
|
t.Fatal("expected --as flag to be registered")
|
|
}
|
|
if !flag.Hidden {
|
|
t.Fatal("expected --as flag to be hidden in strict mode")
|
|
}
|
|
if got := flag.DefValue; got != "bot" {
|
|
t.Fatalf("default value = %q, want %q", got, "bot")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.PageLimit != 10 {
|
|
t.Errorf("expected default PageLimit=10, got %d", gotOpts.PageLimit)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error when both --params and --data use stdin")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
|
t.Errorf("expected stdin conflict error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--page-all", "--output", "file.bin"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --output + --page-all conflict")
|
|
}
|
|
if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/drive/v1/files/xxx/download",
|
|
RawBody: []byte("fake-binary-content"),
|
|
ContentType: "application/octet-stream",
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(stderr.String(), "binary response detected") {
|
|
t.Error("expected binary response hint in stderr")
|
|
}
|
|
if !strings.Contains(stdout.String(), "saved_path") {
|
|
t.Error("expected saved_path in output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register a non-batch API that returns scalar data (no array field)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users/u123",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"user_id": "u123",
|
|
"name": "Test User",
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should print fallback warning to stderr
|
|
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
|
|
t.Error("expected fallback warning in stderr")
|
|
}
|
|
if !strings.Contains(stderr.String(), "falling back to json") {
|
|
t.Error("expected 'falling back to json' in stderr")
|
|
}
|
|
// Should output JSON result to stdout
|
|
if !strings.Contains(stdout.String(), "u123") {
|
|
t.Error("expected user_id in JSON output")
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Non-batch API that returns a business error (code != 0)
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
|
|
Body: map[string]interface{}{
|
|
"code": 230001, "msg": "no permission",
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
|
err := cmd.Execute()
|
|
// Should return an error
|
|
if err == nil {
|
|
t.Fatal("expected an error for non-zero code")
|
|
}
|
|
// Should still output the response body so user can see the error details
|
|
if !strings.Contains(stdout.String(), "230001") {
|
|
t.Errorf("expected error response in stdout, got: %s", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), "no permission") {
|
|
t.Errorf("expected error message in stdout, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
|
|
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
// Register a batch API that returns an array field
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Should NOT print fallback warning
|
|
if strings.Contains(stderr.String(), "warning: this API does not return a list") {
|
|
t.Error("expected no fallback warning for batch API")
|
|
}
|
|
// Should stream ndjson items
|
|
if !strings.Contains(stdout.String(), `"id"`) {
|
|
t.Error("expected streamed items in output")
|
|
}
|
|
}
|
|
|
|
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
|
for _, tt := range []struct {
|
|
name string
|
|
raw string
|
|
want string
|
|
}{
|
|
{"plain path", "/open-apis/test", "/open-apis/test"},
|
|
{"with query", "/open-apis/test?admin=true", "/open-apis/test"},
|
|
{"with fragment", "/open-apis/test#section", "/open-apis/test"},
|
|
{"with both", "/open-apis/test?a=1#frag", "/open-apis/test"},
|
|
{"full URL with query", "https://open.feishu.cn/open-apis/foo?bar=1", "/open-apis/foo"},
|
|
{"short path with query", "contact/v3/users?page_size=50", "/open-apis/contact/v3/users"},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := normalisePath(tt.raw)
|
|
if got != tt.want {
|
|
t.Errorf("normalisePath(%q) = %q, want %q", tt.raw, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.JqExpr != ".data" {
|
|
t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --output conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/test/jq",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{
|
|
map[string]interface{}{"name": "Alice"},
|
|
map[string]interface{}{"name": "Bob"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") {
|
|
t.Errorf("expected jq-filtered names, got: %s", out)
|
|
}
|
|
// Should NOT contain the full envelope structure
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --jq + --format ndjson conflict")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_JqInvalidExpression(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid jq expression")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid jq expression") {
|
|
t.Errorf("expected 'invalid jq expression' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_PageAll_WithJq(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
URL: "/open-apis/contact/v3/users",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") {
|
|
t.Errorf("expected jq-filtered ids, got: %s", out)
|
|
}
|
|
if strings.Contains(out, `"code"`) {
|
|
t.Errorf("expected jq to filter out envelope, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_MethodUppercase(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"post", "/test"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.Method != "POST" {
|
|
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
var gotOpts *APIOptions
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--file", "image=photo.jpg", "--data", `{"image_type":"message"}`})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if gotOpts.File != "image=photo.jpg" {
|
|
t.Errorf("expected File = %q, got %q", "image=photo.jpg", gotOpts.File)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --file with --output")
|
|
}
|
|
if !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Errorf("expected mutual exclusion error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_FileWithGET(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --file with GET")
|
|
}
|
|
if !strings.Contains(err.Error(), "requires POST") {
|
|
t.Errorf("expected method error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
|
return apiRun(opts)
|
|
})
|
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for --file stdin with --data stdin")
|
|
}
|
|
if !strings.Contains(err.Error(), "cannot both read from stdin") {
|
|
t.Errorf("expected stdin conflict error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApiCmd_DryRunWithFile(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
tmpFile := tmpDir + "/test.jpg"
|
|
if err := os.WriteFile(tmpFile, []byte("fake-image"), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
cmd := NewCmdApi(f, nil)
|
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, "image") {
|
|
t.Errorf("expected dry-run output to mention file field, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, "Dry Run") {
|
|
t.Errorf("expected dry-run header, got: %s", out)
|
|
}
|
|
}
|