Files
larksuite-cli/shortcuts/base/base_shortcuts_test.go
zgz2048 7df37ed715 feat(base): Add Base URL and title resolve shortcuts (#1338)
* feat(base): add URL and title resolve shortcuts

* docs: clarify base coordinate resolution

* fix(base): address resolve shortcut ci

* fix(base): format resolved record share hint

* fix(base): simplify record share hint data

* fix(base): use field ids in resolved record data

* fix(base): guide record share resolve to update record

* fix(base): include record upsert example in resolve hint

* fix(base): reject add-record urls in resolver

* fix(base): validate title resolve query length

* fix(base): hide resolve alias flags from help

* fix(base): prefer title flag for title resolve

* docs(base): clarify token resolution wording
2026-06-24 22:26:29 +08:00

2422 lines
80 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArrays(stringFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
}
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
for name := range intFlags {
cmd.Flags().Int(name, 0, "")
}
_ = cmd.ParseFlags(nil)
for name, value := range stringFlags {
_ = cmd.Flags().Set(name, value)
}
for name, values := range stringArrayFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
}
}
for name, value := range intFlags {
_ = cmd.Flags().Set(name, strconv.Itoa(value))
}
return &common.RuntimeContext{Cmd: cmd, Config: &core.CliConfig{UserOpenId: "ou_test"}}
}
func assertBasePaginationValidation(t *testing.T, err error, param string) {
t.Helper()
if err == nil {
t.Fatal("expected validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != param {
t.Fatalf("param=%q, want %s", validationErr.Param, param)
}
if !strings.Contains(validationErr.Message, "must be between") {
t.Fatalf("message=%q, want range limit", validationErr.Message)
}
}
func TestBaseAction(t *testing.T) {
t.Run("missing action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil)
_, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err == nil || !strings.Contains(err.Error(), "specify one action") {
t.Fatalf("err=%v", err)
}
})
t.Run("single bool action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": true}, nil)
action, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err != nil || action != "list" {
t.Fatalf("action=%q err=%v", action, err)
}
})
t.Run("mutually exclusive", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": "tbl_1"}, map[string]bool{"list": true}, nil)
_, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
}
func TestParseObjectList(t *testing.T) {
items, err := parseObjectList(testPC, "", "view")
if err != nil || items != nil {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `{"name":"grid"}`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `[{"name":"grid"}]`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
_, err = parseObjectList(testPC, `[1]`, "view")
if err == nil || !strings.Contains(err.Error(), "must be an object") {
t.Fatalf("err=%v", err)
}
}
func TestWrapViewPropertyBody(t *testing.T) {
arr := []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}
wrapped := wrapViewPropertyBody(arr, "group_config")
wrappedMap, ok := wrapped.(map[string]interface{})
if !ok {
t.Fatalf("wrapped type=%T", wrapped)
}
if !reflect.DeepEqual(wrappedMap["group_config"], arr) {
t.Fatalf("wrapped group_config=%v want=%v", wrappedMap["group_config"], arr)
}
obj := map[string]interface{}{"group_config": arr}
if got := wrapViewPropertyBody(obj, "group_config"); !reflect.DeepEqual(got, obj) {
t.Fatalf("got=%v want=%v", got, obj)
}
}
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
}
}
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+url-resolve", "+title-resolve",
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
"+base-get", "+base-copy", "+base-create",
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
"+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable",
"+data-query",
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail",
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {
t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want))
}
for index, command := range want {
if shortcuts[index].Command != command {
t.Fatalf("command[%d]=%q want=%q", index, shortcuts[index].Command, command)
}
}
}
func TestShortcutsDryRunCoverage(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.DryRun == nil {
t.Fatalf("shortcut %q missing DryRun", shortcut.Command)
}
}
}
func TestBaseTableDeleteRisk(t *testing.T) {
if BaseTableDelete.Risk != "high-risk-write" {
t.Fatalf("risk=%q want=%q", BaseTableDelete.Risk, "high-risk-write")
}
}
func TestBaseFieldUpdateRisk(t *testing.T) {
if BaseFieldUpdate.Risk != "high-risk-write" {
t.Fatalf("risk=%q want=%q", BaseFieldUpdate.Risk, "high-risk-write")
}
}
func TestBaseDeleteShortcutsRisk(t *testing.T) {
cases := map[string]string{
BaseFieldDelete.Command: BaseFieldDelete.Risk,
BaseViewDelete.Command: BaseViewDelete.Risk,
BaseRecordDelete.Command: BaseRecordDelete.Risk,
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
BaseFormDelete.Command: BaseFormDelete.Risk,
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
for command, risk := range cases {
if risk != "high-risk-write" {
t.Fatalf("command=%q risk=%q want=%q", command, risk, "high-risk-write")
}
}
}
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Risk != "high-risk-write" {
continue
}
parent := &cobra.Command{Use: "base"}
shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("yes")
if flag == nil {
t.Fatalf("%s missing --yes flag", shortcut.Command)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
if !strings.Contains(tips, "pass --yes without asking again") {
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
}
}
}
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
if cmd.Flags().Lookup("i-have-read-guide") == nil {
t.Fatalf("flag i-have-read-guide must exist for runtime validation")
}
if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") {
t.Fatalf("help should not include --i-have-read-guide")
}
}
func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
if cmd.Flags().Lookup("i-have-read-guide") == nil {
t.Fatalf("flag i-have-read-guide must exist for runtime validation")
}
if strings.Contains(cmd.Flags().FlagUsages(), "--i-have-read-guide") {
t.Fatalf("help should not include --i-have-read-guide")
}
}
func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) {
runtime := newBaseTestRuntime(
map[string]string{"before-id": "blk_before", "after-id": "blk_after"},
nil,
nil,
)
err := validateBaseBlockMove(runtime)
if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBaseBlockCreateAndRenameRequireName(t *testing.T) {
createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil)
if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("create err=%v", err)
}
renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil)
if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") {
t.Fatalf("rename err=%v", err)
}
}
func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "record list",
shortcut: BaseRecordList,
wantHelp: []string{
"field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
`filter JSON object or @file`,
`sort JSON array or @file`,
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Text equality filter",
"Option intersection filter",
"Query priority",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
"keyword for record search",
"field ID or name to search",
`filter JSON object or @file`,
`sort JSON array or @file`,
"output format: markdown (default) | json",
},
wantTips: []string{
"Example: lark-cli base +record-search",
"Example with filter/sort JSON",
"Text equality filter",
"Query priority",
"Use --json only when you need to pass the full search body directly",
"Default output is markdown",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown",
"projection boundary",
"record_id is already known",
"lark-base record read SOP",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBasePaginationHelpShowsDefaults(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
flag string
defaultVal string
help string
}{
{name: "table list", shortcut: BaseTableList, flag: "limit", defaultVal: "50", help: "pagination size, range 1-100"},
{name: "field list", shortcut: BaseFieldList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "field search options", shortcut: BaseFieldSearchOptions, flag: "limit", defaultVal: "30", help: "pagination size, range 1-200"},
{name: "record list", shortcut: BaseRecordList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "record search", shortcut: BaseRecordSearch, flag: "limit", defaultVal: "10", help: "pagination size, range 1-200"},
{name: "view list", shortcut: BaseViewList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "form list", shortcut: BaseFormsList, flag: "page-size", defaultVal: "100", help: "page size per request, range 1-100"},
{name: "workflow list", shortcut: BaseWorkflowList, flag: "page-size", defaultVal: "100", help: "page size per request, range 1-100"},
{name: "record history list", shortcut: BaseRecordHistoryList, flag: "page-size", defaultVal: "30", help: "pagination size, range 1-50"},
{name: "dashboard list", shortcut: BaseDashboardList, flag: "page-size", defaultVal: "100", help: "page size, range 1-100"},
{name: "dashboard block list", shortcut: BaseDashboardBlockList, flag: "page-size", defaultVal: "20", help: "page size, range 1-100"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup(tt.flag)
if flag == nil {
t.Fatalf("flag --%s missing", tt.flag)
}
if flag.DefValue != tt.defaultVal {
t.Fatalf("--%s default=%q, want %q", tt.flag, flag.DefValue, tt.defaultVal)
}
help := cmd.Flags().FlagUsages()
if !strings.Contains(help, tt.help) {
t.Fatalf("flag help missing %q:\n%s", tt.help, help)
}
if !strings.Contains(help, "default "+tt.defaultVal) {
t.Fatalf("flag help missing default %s:\n%s", tt.defaultVal, help)
}
if got := strings.Count(help, "default "+tt.defaultVal); got != 1 {
t.Fatalf("flag help default %s count=%d, want 1:\n%s", tt.defaultVal, got, help)
}
})
}
}
func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "table list", shortcut: BaseTableList},
{name: "field list", shortcut: BaseFieldList},
{name: "field search options", shortcut: BaseFieldSearchOptions},
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "view list", shortcut: BaseViewList},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("page-size")
if flag == nil {
t.Fatal("flag --page-size missing")
}
if !flag.Hidden {
t.Fatal("flag --page-size must be hidden")
}
if strings.Contains(cmd.Flags().FlagUsages(), "--page-size") {
t.Fatalf("help should not include hidden --page-size:\n%s", cmd.Flags().FlagUsages())
}
})
}
}
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "dashboard list",
shortcut: BaseDashboardList,
wantTips: []string{
"Use returned dashboard_id values",
},
},
{
name: "dashboard get",
shortcut: BaseDashboardGet,
wantTips: []string{
"block-level details",
},
},
{
name: "dashboard create",
shortcut: BaseDashboardCreate,
wantTips: []string{
"Record the returned dashboard_id",
},
},
{
name: "dashboard update",
shortcut: BaseDashboardUpdate,
wantTips: []string{},
},
{
name: "dashboard delete",
shortcut: BaseDashboardDelete,
wantTips: []string{
"lark-cli base +dashboard-delete --base-token <base_token> --dashboard-id <dashboard_id> --yes",
"also deletes its blocks",
"pass --yes",
},
},
{
name: "dashboard arrange",
shortcut: BaseDashboardArrange,
wantTips: []string{
"not deterministic or position-specific",
},
},
{
name: "dashboard block list",
shortcut: BaseDashboardBlockList,
wantTips: []string{
"lark-cli base +dashboard-block-list --base-token <base_token> --dashboard-id <dashboard_id>",
"Use returned block_id and type values",
},
},
{
name: "dashboard block get",
shortcut: BaseDashboardBlockGet,
wantTips: []string{
"lark-cli base +dashboard-block-get --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id>",
"metadata such as name, type, layout, and data_config",
"computed chart result",
},
},
{
name: "dashboard block get data",
shortcut: BaseDashboardBlockGetData,
wantTips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",
"does not need --dashboard-id",
"computed chart protocol JSON",
},
},
{
name: "dashboard block create",
shortcut: BaseDashboardBlockCreate,
wantTips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"sequentially",
},
},
{
name: "dashboard block update",
shortcut: BaseDashboardBlockUpdate,
wantTips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,
`--data-config '{"series":[{"field_name":"Amount","rollup":"SUM"}]}'`,
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"Block type cannot be changed",
"top-level keys",
},
},
{
name: "dashboard block delete",
shortcut: BaseDashboardBlockDelete,
wantTips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
"pass --yes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "workflow list",
shortcut: BaseWorkflowList,
wantTips: []string{
"workflow_id values with wkf prefix",
"auto-paginates",
},
},
{
name: "workflow get",
shortcut: BaseWorkflowGet,
wantTips: []string{
"workflow-id must start with wkf",
"steps may be an empty array",
"Use +workflow-get before +workflow-update",
"lark-base-workflow-schema.md",
},
},
{
name: "workflow create",
shortcut: BaseWorkflowCreate,
wantTips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required",
"New workflows are created disabled",
"+table-list and +field-list",
"Step ids must be unique",
"lark-base-workflow-guide.md as the entry guide",
"lark-base-workflow-schema.md as the steps JSON SSOT",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow update",
shortcut: BaseWorkflowUpdate,
wantTips: []string{
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
"PUT uses full replacement semantics",
"Use +workflow-get first",
"keep title/status/steps fields",
"workflow-id must start with wkf",
"Updating does not enable or disable",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow enable",
shortcut: BaseWorkflowEnable,
wantTips: []string{
"workflow-id must start with wkf",
"does not modify steps",
"New workflows are created disabled",
},
},
{
name: "workflow disable",
shortcut: BaseWorkflowDisable,
wantTips: []string{
"workflow-id must start with wkf",
"does not delete the workflow or its steps",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
}{
{
name: "base create fields",
shortcut: BaseBaseCreate,
wantHelp: []string{
`field JSON array for the first table schema; use with --table-name`,
`first table name for the custom first table schema; use with --fields`,
},
},
{
name: "table create fields",
shortcut: BaseTableCreate,
wantHelp: []string{
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
wantHelp: []string{
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
},
},
{
name: "view set sort",
shortcut: BaseViewSetSort,
wantHelp: []string{
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
`use {"sort_config":[]} to clear`,
},
},
{
name: "view set group",
shortcut: BaseViewSetGroup,
wantHelp: []string{
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
wantHelp: []string{
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
wantHelp: []string{
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
wantHelp: []string{
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
},
},
{
name: "form question delete",
shortcut: BaseFormQuestionsDelete,
wantHelp: []string{
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "record search json",
shortcut: BaseRecordSearch,
wantHelp: []string{
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
},
},
{
name: "record upsert json",
shortcut: BaseRecordUpsert,
wantHelp: []string{
`record field map JSON object, e.g. {"Name":"Alice","Status":"Todo"}; do not wrap in fields`,
},
},
{
name: "record batch create json",
shortcut: BaseRecordBatchCreate,
wantHelp: []string{
`batch create JSON object, e.g. {"fields":["Name","Status"],"rows":[["Task A","Todo"],["Task B",null]]}; rows follow fields order`,
},
},
{
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
})
}
}
func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "record upsert",
shortcut: BaseRecordUpsert,
wantTips: []string{
"Happy path JSON is a top-level field map",
"Without --record-id this creates a record",
"does not auto-upsert by business key",
"use +field-list to confirm real writable fields",
"do not write system fields, formula, lookup, or attachment fields",
"CellValue happy path: text/phone/url",
"select -> \"Todo\"",
"multi-select -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
`location uses {"lng":116.397428,"lat":39.90923}`,
"Do not guess user/chat/linked-record IDs or location coordinates",
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path fields: fields is the column order",
"rows is an array of row arrays",
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
wantTips: []string{
"Happy path fields: record_id_list is the target record IDs",
"patch is a field map applied unchanged to every target record",
"Do not use +record-batch-update for per-row different values",
"use +field-list to confirm real writable fields",
"Batch update supports max 200 records per call",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseBlockHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "list",
shortcut: BaseBaseBlockList,
wantTips: []string{
"lark-cli base +base-block-list --base-token <base_token>",
"lark-cli base +base-block-list --base-token <base_token> --type table",
"lark-cli base +base-block-list --base-token <base_token> --parent-id <folder_block_id>",
`jq '.blocks[] | {type, name, block_id: .id, parent_id}'`,
`--type docx | jq '.blocks[] | {name, docx_token}'`,
"returned id is the table-id, dashboard-id, or workflow-id",
"For docx blocks, use the returned docx_token with docx commands.",
},
},
{
name: "create",
shortcut: BaseBaseBlockCreate,
wantTips: []string{
`lark-cli base +base-block-create --base-token <base_token> --type folder --name "Project Docs"`,
`lark-cli base +base-block-create --base-token <base_token> --type table --name "Tasks"`,
`lark-cli base +base-block-create --base-token <base_token> --type docx --name "Spec" --parent-id <folder_block_id>`,
`lark-cli base +base-block-create --base-token <base_token> --type dashboard --name "Metrics"`,
`lark-cli base +base-block-create --base-token <base_token> --type workflow --name "Approval Flow"`,
},
},
{
name: "move",
shortcut: BaseBaseBlockMove,
wantTips: []string{
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --parent-id <folder_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --after-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id> --before-id <sibling_block_id>",
"lark-cli base +base-block-move --base-token <base_token> --block-id <block_id>",
},
},
{
name: "rename",
shortcut: BaseBaseBlockRename,
wantTips: []string{
`lark-cli base +base-block-rename --base-token <base_token> --block-id <block_id> --name "New name"`,
},
},
{
name: "delete",
shortcut: BaseBaseBlockDelete,
wantTips: []string{
"lark-cli base +base-block-delete --base-token <base_token> --block-id <block_id> --yes",
"Recursive folder deletion is not supported.",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"complete field definition JSON object; update uses full PUT semantics, not a patch",
}
for _, want := range wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
wantTips := []string{
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
"full field-definition PUT semantics",
"Read the current field first with +field-get",
"Type conversion is allowlist-based",
"web UI",
"Formula and lookup updates require reading the corresponding guide first.",
"lark-base skill's field-update guide",
}
for _, want := range wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "upload attachment",
shortcut: BaseRecordUploadAttachment,
wantHelp: []string{
"repeat to append multiple attachments in one cell",
"max 50 files, max 2GB each",
},
wantTips: []string{
"lark-cli base +record-upload-attachment",
"Repeat --file to append multiple attachments",
"Reuse returned file_token values for download/remove",
},
},
{
name: "download attachment",
shortcut: BaseRecordDownloadAttachment,
wantHelp: []string{
"repeat to download selected files",
"omit to download all attachments in the record",
"with multiple or omitted file tokens this must be an existing directory",
},
wantTips: []string{
"lark-cli base +record-download-attachment",
"Omit --file-token to download every attachment in the record",
"Base attachments should be downloaded with this command",
"other download commands may fail",
},
},
{
name: "remove attachment",
shortcut: BaseRecordRemoveAttachment,
wantHelp: []string{
"remove from the target cell",
"max 50 tokens",
},
wantTips: []string{
"lark-cli base +record-remove-attachment",
"Repeat --file-token",
"requires --yes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
afterIndex := strings.Index(help, after)
if beforeIndex < 0 || afterIndex < 0 {
return
}
if beforeIndex > afterIndex {
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
}
}
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula create validate err=%v", err)
}
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"Amount"}`}, nil, nil)); err != nil {
t.Fatalf("update validate err=%v", err)
}
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"lookup"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
t.Fatalf("formula update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
ctx := context.Background()
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid fields json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "view": `[1]`}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
}
func TestBaseCreateValidate(t *testing.T) {
ctx := context.Background()
if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks"}, nil, nil)); err != nil {
t.Fatalf("table-name-only should be valid, err=%v", err)
}
if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"}]`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
}
func TestBaseCreateTipsGuideFieldSchema(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseBaseCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range []string{
"Before using --fields, read lark-base-field-json.md",
"do not invent field properties",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseCreateScopesCoverFollowUpTableOperations(t *testing.T) {
requiredUserScopes := []string{
"base:app:create",
"base:table:read",
"base:table:create",
"base:table:update",
"base:table:delete",
}
if !reflect.DeepEqual(BaseBaseCreate.UserScopes, requiredUserScopes) {
t.Fatalf("UserScopes=%v want=%v", BaseBaseCreate.UserScopes, requiredUserScopes)
}
requiredBotScopes := append(append([]string{}, requiredUserScopes...), "docs:permission.member:create")
if !reflect.DeepEqual(BaseBaseCreate.BotScopes, requiredBotScopes) {
t.Fatalf("BotScopes=%v want=%v", BaseBaseCreate.BotScopes, requiredBotScopes)
}
}
func TestBaseRecordValidate(t *testing.T) {
ctx := context.Background()
if BaseRecordList.Validate == nil {
t.Fatalf("record list validate should reject invalid query flags before dry-run")
}
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
}
if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
}
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
t.Fatalf("record upsert map validate err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
nil,
nil,
)); err != nil {
t.Fatalf("record list filter-json validate err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
nil,
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
"sort-json": `[{"field":"Title","desc":false}]`,
},
nil,
nil,
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
t.Fatalf("err=%v", err)
}
}
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
param string
}{
{
name: "table list",
shortcut: BaseTableList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"limit": 101}),
param: "--limit",
},
{
name: "field list",
shortcut: BaseFieldList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"limit": 201}),
param: "--limit",
},
{
name: "field search options",
shortcut: BaseFieldSearchOptions,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "field-id": "fld_1"}, nil, map[string]int{"limit": 201}),
param: "--limit",
},
{
name: "view list",
shortcut: BaseViewList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"limit": 201}),
param: "--limit",
},
{
name: "record list",
shortcut: BaseRecordList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"limit": 0}),
param: "--limit",
},
{
name: "record search",
shortcut: BaseRecordSearch,
runtime: newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
map[string]int{"limit": 201},
),
param: "--limit",
},
{
name: "table list page-size alias",
shortcut: BaseTableList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"page-size": 101}),
param: "--page-size",
},
{
name: "field list page-size alias",
shortcut: BaseFieldList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
param: "--page-size",
},
{
name: "field search options page-size alias",
shortcut: BaseFieldSearchOptions,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "field-id": "fld_1"}, nil, map[string]int{"page-size": 201}),
param: "--page-size",
},
{
name: "view list page-size alias",
shortcut: BaseViewList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 201}),
param: "--page-size",
},
{
name: "record list page-size alias",
shortcut: BaseRecordList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 0}),
param: "--page-size",
},
{
name: "record search page-size alias",
shortcut: BaseRecordSearch,
runtime: newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
map[string]int{"page-size": 201},
),
param: "--page-size",
},
{
name: "form list",
shortcut: BaseFormsList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, map[string]int{"page-size": 101}),
param: "--page-size",
},
{
name: "workflow list",
shortcut: BaseWorkflowList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"page-size": 101}),
param: "--page-size",
},
{
name: "record history list",
shortcut: BaseRecordHistoryList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "record-id": "rec_1"}, nil, map[string]int{"page-size": 51}),
param: "--page-size",
},
{
name: "dashboard list",
shortcut: BaseDashboardList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "page-size": "101"}, nil, nil),
param: "--page-size",
},
{
name: "dashboard block list",
shortcut: BaseDashboardBlockList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b", "dashboard-id": "dash_1", "page-size": "101"}, nil, nil),
param: "--page-size",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.shortcut.Validate == nil {
t.Fatalf("%s missing Validate", tt.shortcut.Command)
}
assertBasePaginationValidation(t, tt.shortcut.Validate(ctx, tt.runtime), tt.param)
})
}
}
func TestBaseLimitPageSizeAliasRejectsConflict(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
shortcut common.Shortcut
runtime *common.RuntimeContext
}{
{
name: "table list",
shortcut: BaseTableList,
runtime: newBaseTestRuntime(map[string]string{"base-token": "b"}, nil, map[string]int{"limit": 50, "page-size": 50}),
},
{
name: "record search",
shortcut: BaseRecordSearch,
runtime: newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
map[string]int{"limit": 10, "page-size": 10},
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.shortcut.Validate == nil {
t.Fatalf("%s missing Validate", tt.shortcut.Command)
}
err := tt.shortcut.Validate(ctx, tt.runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != "--page-size" {
t.Fatalf("param=%q, want --page-size", validationErr.Param)
}
if !strings.Contains(validationErr.Message, "mutually exclusive") {
t.Fatalf("message=%q, want mutually exclusive", validationErr.Message)
}
})
}
}
func TestBaseViewValidate(t *testing.T) {
ctx := context.Background()
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
}
// --- base_form_submit.go 子函数单测 ---
func TestValidateFormSubmit(t *testing.T) {
t.Run("invalid json", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": "{invalid",
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "invalid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
})
t.Run("fields only - valid", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{"fields":{"Rating":5}}`,
}, nil, nil)
if err := validateFormSubmit(rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("missing both fields and attachments", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "must contain at least") {
t.Fatalf("expected missing fields/attachments error, got: %v", err)
}
})
t.Run("attachments without base-token", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{"attachments":{"File":["./a.pdf"]}}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "--base-token is required") {
t.Fatalf("expected base-token required error, got: %v", err)
}
})
t.Run("attachments not an object", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"base-token": "bas_test",
"json": `{"attachments":"not_an_object"}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "must be a JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
})
t.Run("attachment value not array", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"base-token": "bas_test",
"json": `{"attachments":{"File":"not_array"}}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "must be a file path array") {
t.Fatalf("expected array error, got: %v", err)
}
})
t.Run("attachment path item not string", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"base-token": "bas_test",
"json": `{"attachments":{"File":[123]}}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "must be a file path string") {
t.Fatalf("expected string error, got: %v", err)
}
})
t.Run("empty attachment paths", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"base-token": "bas_test",
"json": `{"attachments":{"File":[]}}`,
}, nil, nil)
err := validateFormSubmit(rt)
if err == nil || !strings.Contains(err.Error(), "must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
})
t.Run("attachments valid with base-token", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"base-token": "bas_test",
"json": `{"fields":{"Rating":5},"attachments":{"File":["./a.pdf"]}}`,
}, nil, nil)
if err := validateFormSubmit(rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
func TestParseFormSubmitJSON(t *testing.T) {
t.Run("fields only", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"fields":{"Rating":5,"Review":"Good"}}`,
}, nil, nil)
fields, attMap, err := parseFormSubmitJSON(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fields) != 2 || fields["Rating"] != float64(5) || fields["Review"] != "Good" {
t.Fatalf("fields=%v", fields)
}
if attMap != nil {
t.Fatalf("expected nil attMap, got %v", attMap)
}
})
t.Run("no fields key returns empty map", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":{"File":["./a.pdf"]}}`,
}, nil, nil)
fields, _, err := parseFormSubmitJSON(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fields) != 0 {
t.Fatalf("expected empty fields, got %v", fields)
}
})
t.Run("with attachments", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"fields":{"Rating":5},"attachments":{"File":["./a.pdf","./b.png"],"Photo":["./c.jpg"]}}`,
}, nil, nil)
fields, attMap, err := parseFormSubmitJSON(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fields["Rating"] != float64(5) {
t.Fatalf("missing Rating field")
}
if len(attMap) != 2 {
t.Fatalf("attMap size=%d want=2", len(attMap))
}
if len(attMap["File"]) != 2 || attMap["File"][0] != "./a.pdf" || attMap["File"][1] != "./b.png" {
t.Fatalf("File paths=%v", attMap["File"])
}
if len(attMap["Photo"]) != 1 || attMap["Photo"][0] != "./c.jpg" {
t.Fatalf("Photo paths=%v", attMap["Photo"])
}
})
t.Run("invalid json", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{"json": "{"}, nil, nil)
_, _, err := parseFormSubmitJSON(rt)
if err == nil || !strings.Contains(err.Error(), "invalid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
})
t.Run("attachments not object", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":"bad"}`,
}, nil, nil)
_, _, err := parseFormSubmitJSON(rt)
if err == nil || !strings.Contains(err.Error(), "must be a JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
})
t.Run("attachment value not array", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":{"File":"str"}}`,
}, nil, nil)
_, _, err := parseFormSubmitJSON(rt)
if err == nil || !strings.Contains(err.Error(), "must be a file path array") {
t.Fatalf("expected array error, got: %v", err)
}
})
t.Run("attachment item not string", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":{"File":[42]}}`,
}, nil, nil)
_, _, err := parseFormSubmitJSON(rt)
if err == nil || !strings.Contains(err.Error(), "file path strings only") {
t.Fatalf("expected string error, got: %v", err)
}
})
t.Run("empty attachments object returns nil map", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":{}}`,
}, nil, nil)
_, attMap, err := parseFormSubmitJSON(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if attMap != nil {
t.Fatalf("expected nil attMap for empty, got %v", attMap)
}
})
t.Run("empty attachment path list excluded from map", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"json": `{"attachments":{"File":[],"Photo":["./x.jpg"]}}`,
}, nil, nil)
_, attMap, err := parseFormSubmitJSON(rt)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := attMap["File"]; ok {
t.Fatalf("empty File should be excluded from attMap")
}
if len(attMap["Photo"]) != 1 {
t.Fatalf("Photo should have 1 entry")
}
})
}
func TestBuildFormSubmitBody(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_abc123",
}, nil, nil)
content := map[string]interface{}{"Rating": float64(5), "Review": "Good"}
body := buildFormSubmitBody(rt, content)
if body["share_token"] != "shr_abc123" {
t.Fatalf("share_token=%q want shr_abc123", body["share_token"])
}
gotContent, ok := body["content"].(map[string]interface{})
if !ok {
t.Fatalf("content type=%T want map", body["content"])
}
if gotContent["Rating"] != float64(5) || gotContent["Review"] != "Good" {
t.Fatalf("content=%v want Rating=5 Review=Good", gotContent)
}
}
func TestCollectUniquePaths(t *testing.T) {
t.Run("dedup across fields", func(t *testing.T) {
m := map[string][]string{
"Field1": {"./a.pdf", "./b.png"},
"Field2": {"./b.png", "./c.jpg"},
"Field3": {"./a.pdf", "./d.txt"},
}
result := collectUniquePaths(m)
// Should preserve first-seen order, deduplicated
wantLen := 4 // a.pdf, b.png, c.jpg, d.txt
if len(result) != wantLen {
t.Fatalf("len=%d want=%d result=%v", len(result), wantLen, result)
}
// Check no duplicates
seen := make(map[string]bool)
for _, p := range result {
if seen[p] {
t.Fatalf("duplicate path: %s", p)
}
seen[p] = true
}
})
t.Run("empty map", func(t *testing.T) {
result := collectUniquePaths(map[string][]string{})
if len(result) != 0 {
t.Fatalf("expected empty, got %v", result)
}
})
t.Run("single field single path", func(t *testing.T) {
m := map[string][]string{"F": {"./only.pdf"}}
result := collectUniquePaths(m)
if len(result) != 1 || result[0] != "./only.pdf" {
t.Fatalf("result=%v", result)
}
})
t.Run("same path in same field", func(t *testing.T) {
m := map[string][]string{"F": {"./same.pdf", "./same.pdf"}}
result := collectUniquePaths(m)
if len(result) != 1 {
t.Fatalf("expected 1 unique, got %d: %v", len(result), result)
}
})
}
func TestBaseFormAttachmentUploadTarget(t *testing.T) {
target := baseFormAttachmentUploadTarget("bas_xyz", "shr_abc")
if target.ParentType != baseFormAttachmentParentType {
t.Fatalf("ParentType=%q want %q", target.ParentType, baseFormAttachmentParentType)
}
if target.ParentNode != "bas_xyz" {
t.Fatalf("ParentNode=%q want bas_xyz", target.ParentNode)
}
// Extra should contain share_token
if !strings.Contains(target.Extra, "shr_abc") {
t.Fatalf("Extra=%q should contain share_token", target.Extra)
}
}
func TestBaseFormAttachmentExtra(t *testing.T) {
t.Run("normal token", func(t *testing.T) {
extra := baseFormAttachmentExtra("shr_test123")
var parsed map[string]string
if err := json.Unmarshal([]byte(extra), &parsed); err != nil {
t.Fatalf("extra is not valid JSON: %v", err)
}
if parsed["share_token"] != "shr_test123" {
t.Fatalf("share_token=%q want shr_test123", parsed["share_token"])
}
})
t.Run("empty token", func(t *testing.T) {
extra := baseFormAttachmentExtra("")
var parsed map[string]string
if err := json.Unmarshal([]byte(extra), &parsed); err != nil {
t.Fatalf("extra is not valid JSON: %v", err)
}
if parsed["share_token"] != "" {
t.Fatalf("share_token=%q want empty", parsed["share_token"])
}
})
}
// --- dryRunFormSubmit & BaseFormDetail DryRun 测试 ---
func TestDryRunFormSubmitInvalidJSON(t *testing.T) {
ctx := context.Background()
t.Run("invalid json returns desc-only dry run", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_xyz",
"json": `{invalid`,
}, nil, nil)
dry := dryRunFormSubmit(ctx, rt)
if dry == nil {
t.Fatal("dry result is nil")
}
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
// Should have description about validation failure, no api calls
if _, ok := parsed["description"]; !ok {
t.Fatalf("expected description key for validation failure, got: %s", data)
}
desc := parsed["description"].(string)
if !strings.Contains(desc, "validation failed") {
t.Fatalf("description=%q should mention validation failed", desc)
}
})
}
func TestDryRunFormSubmitStructural(t *testing.T) {
ctx := context.Background()
t.Run("fields only - single POST submit with body check", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_xyz",
"json": `{"fields":{"Rating":5,"Review":"Good"}}`,
}, nil, nil)
dry := dryRunFormSubmit(ctx, rt)
if dry == nil {
t.Fatal("dry result is nil")
}
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, ok := parsed["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("expected 1 api call, got: %s", data)
}
call := api[0].(map[string]interface{})
if call["method"] != "POST" {
t.Fatalf("method=%q want POST", call["method"])
}
body, _ := call["body"].(map[string]interface{})
if body["share_token"] != "shr_xyz" {
t.Fatalf("body.share_token=%q want shr_xyz", body["share_token"])
}
content, _ := body["content"].(map[string]interface{})
if content == nil || content["Rating"] != float64(5) {
t.Fatalf("content missing or wrong Rating, got: %v", content)
}
})
t.Run("with attachments - upload count and submit order", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_xyz",
"base-token": "bas_abc",
"json": `{"fields":{"Name":"test"},"attachments":{"File":["./report.pdf","./img.png"]}}`,
}, nil, nil)
dry := dryRunFormSubmit(ctx, rt)
if dry == nil {
t.Fatal("dry result is nil")
}
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, ok := parsed["api"].([]interface{})
if !ok {
t.Fatalf("api missing in output: %s", data)
}
// 2 uploads + 1 submit = 3 calls
if len(api) != 3 {
t.Fatalf("expected 3 api calls (2 upload + 1 submit), got %d: %s", len(api), data)
}
for i := 0; i < 2; i++ {
call := api[i].(map[string]interface{})
if call["method"] != "POST" {
t.Fatalf("call[%d] method=%q want POST", i, call["method"])
}
if !strings.Contains(call["url"].(string), "medias/upload_all") {
t.Fatalf("call[%d] url=%q should contain medias/upload_all", i, call["url"])
}
}
submitCall := api[2].(map[string]interface{})
if !strings.Contains(submitCall["url"].(string), "forms/submit") {
t.Fatalf("last call url=%q should contain forms/submit", submitCall["url"])
}
})
}
func TestBaseFormDetailDryRun(t *testing.T) {
ctx := context.Background()
t.Run("correct method and url", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "detail123",
}, nil, nil)
dry := BaseFormDetail.DryRun(ctx, rt)
if dry == nil {
t.Fatal("dry result is nil")
}
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, ok := parsed["api"].([]interface{})
if !ok || len(api) != 1 {
t.Fatalf("expected 1 api call, got: %s", data)
}
call := api[0].(map[string]interface{})
if call["method"] != "POST" {
t.Fatalf("method=%q want POST", call["method"])
}
if !strings.Contains(call["url"].(string), "forms/detail") {
t.Fatalf("url=%q should contain forms/detail", call["url"])
}
body, _ := call["body"].(map[string]interface{})
if body["share_token"] != "detail123" {
t.Fatalf("body.share_token=%q want detail123", body["share_token"])
}
})
t.Run("shortcut metadata", func(t *testing.T) {
if BaseFormDetail.Command != "+form-detail" {
t.Fatalf("command=%q want +form-detail", BaseFormDetail.Command)
}
if BaseFormDetail.Risk != "read" {
t.Fatalf("risk=%q want read", BaseFormDetail.Risk)
}
if BaseFormDetail.Validate != nil {
t.Fatalf("Validate should be nil for form-detail")
}
})
}
// --- 通过 BaseFormSubmit / BaseFormDetail 公开接口测试 ---
func TestBaseFormSubmitShortcut(t *testing.T) {
ctx := context.Background()
t.Run("metadata", func(t *testing.T) {
s := BaseFormSubmit
if s.Command != "+form-submit" {
t.Fatalf("Command=%q want +form-submit", s.Command)
}
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "write" {
t.Fatalf("Risk=%q want write", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
}
})
t.Run("flags", func(t *testing.T) {
flags := BaseFormSubmit.Flags
flagNames := make(map[string]bool)
for _, f := range flags {
flagNames[f.Name] = true
}
for _, name := range []string{"share-token", "base-token", "json"} {
if !flagNames[name] {
t.Fatalf("missing flag %q", name)
}
}
// share-token and json are required
for _, f := range flags {
if f.Name == "share-token" && !f.Required {
t.Fatalf("share-token should be Required")
}
if f.Name == "json" && !f.Required {
t.Fatalf("json should be Required")
}
if f.Name == "base-token" && f.Required {
t.Fatalf("base-token should NOT be required (only needed with attachments)")
}
}
})
t.Run("scopes contain base:form:update and docs:document.media:upload", func(t *testing.T) {
scopes := BaseFormSubmit.Scopes
foundFormUpdate := false
foundMediaUpload := false
for _, s := range scopes {
if s == "base:form:update" {
foundFormUpdate = true
}
if s == "docs:document.media:upload" {
foundMediaUpload = true
}
}
if !foundFormUpdate {
t.Fatalf("Scopes=%v missing base:form:update", scopes)
}
if !foundMediaUpload {
t.Fatalf("Scopes=%v missing docs:document.media:upload", scopes)
}
})
t.Run("auth types", func(t *testing.T) {
authTypes := BaseFormSubmit.AuthTypes
if len(authTypes) == 0 {
t.Fatal("AuthTypes should not be empty")
}
hasUser, hasBot := false, false
for _, at := range authTypes {
if at == "user" {
hasUser = true
}
if at == "bot" {
hasBot = true
}
}
if !hasUser || !hasBot {
t.Fatalf("AuthTypes=%v should include both user and bot", authTypes)
}
})
t.Run("validate via shortcut interface - fields only valid", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{"fields":{"Rating":5}}`,
}, nil, nil)
if err := BaseFormSubmit.Validate(ctx, rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("validate via shortcut interface - missing both fields and attachments", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{}`,
}, nil, nil)
err := BaseFormSubmit.Validate(ctx, rt)
if err == nil || !strings.Contains(err.Error(), "must contain at least") {
t.Fatalf("expected validation error, got: %v", err)
}
})
t.Run("validate via shortcut interface - attachments without base-token", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_test",
"json": `{"attachments":{"File":["./a.pdf"]}}`,
}, nil, nil)
err := BaseFormSubmit.Validate(ctx, rt)
if err == nil || !strings.Contains(err.Error(), "--base-token is required") {
t.Fatalf("expected base-token error, got: %v", err)
}
})
t.Run("dryrun via shortcut interface - fields only", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_dry1",
"json": `{"fields":{"Name":"Alice"}}`,
}, nil, nil)
dry := BaseFormSubmit.DryRun(ctx, rt)
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, _ := parsed["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("expected 1 call, got %d", len(api))
}
call := api[0].(map[string]interface{})
if call["method"] != "POST" {
t.Fatalf("method=%q want POST", call["method"])
}
body, _ := call["body"].(map[string]interface{})
if body["share_token"] != "shr_dry1" {
t.Fatalf("share_token=%q want shr_dry1", body["share_token"])
}
})
t.Run("dryrun via shortcut interface - with attachments", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_dry2",
"base-token": "bas_dry2",
"json": `{"attachments":{"File":["./x.pdf"]}}`,
}, nil, nil)
dry := BaseFormSubmit.DryRun(ctx, rt)
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, _ := parsed["api"].([]interface{})
// 1 upload + 1 submit = 2 calls
if len(api) != 2 {
t.Fatalf("expected 2 calls (upload+submit), got %d: %s", len(api), data)
}
// First call is upload
uploadCall := api[0].(map[string]interface{})
if !strings.Contains(uploadCall["url"].(string), "medias/upload_all") {
t.Fatalf("first call url should be upload_all, got: %v", uploadCall["url"])
}
// Second call is submit
submitCall := api[1].(map[string]interface{})
if !strings.Contains(submitCall["url"].(string), "forms/submit") {
t.Fatalf("second call url should be forms/submit, got: %v", submitCall["url"])
}
})
t.Run("description contains useful info", func(t *testing.T) {
desc := BaseFormSubmit.Description
if desc == "" {
t.Fatal("Description should not be empty")
}
if !strings.Contains(strings.ToLower(desc), "submit") &&
!strings.Contains(strings.ToLower(desc), "form") {
t.Fatalf("Description=%q should mention form or submit", desc)
}
})
t.Run("tips not empty", func(t *testing.T) {
if len(BaseFormSubmit.Tips) == 0 {
t.Fatal("Tips should not be empty")
}
})
}
func TestBaseFormDetailShortcut(t *testing.T) {
ctx := context.Background()
t.Run("metadata", func(t *testing.T) {
s := BaseFormDetail
if s.Command != "+form-detail" {
t.Fatalf("Command=%q want +form-detail", s.Command)
}
if s.Service != "base" {
t.Fatalf("Service=%q want base", s.Service)
}
if s.Risk != "read" {
t.Fatalf("Risk=%q want read", s.Risk)
}
if !s.HasFormat {
t.Fatal("HasFormat should be true")
}
})
t.Run("flags - only share-token required", func(t *testing.T) {
flags := BaseFormDetail.Flags
if len(flags) != 1 {
t.Fatalf("expected 1 flag, got %d", len(flags))
}
f := flags[0]
if f.Name != "share-token" {
t.Fatalf("flag Name=%q want share-token", f.Name)
}
if !f.Required {
t.Fatal("share-token should be Required")
}
})
t.Run("scopes contain base:form:read", func(t *testing.T) {
scopes := BaseFormDetail.Scopes
found := false
for _, s := range scopes {
if s == "base:form:read" {
found = true
}
}
if !found {
t.Fatalf("Scopes=%v missing base:form:read", scopes)
}
})
t.Run("auth types user and bot", func(t *testing.T) {
authTypes := BaseFormDetail.AuthTypes
if len(authTypes) != 2 {
t.Fatalf("expected 2 auth types, got %d: %v", len(authTypes), authTypes)
}
})
t.Run("validate is nil (no extra CLI-side validation)", func(t *testing.T) {
if BaseFormDetail.Validate != nil {
t.Fatal("Validate should be nil for form-detail")
}
})
t.Run("dryrun via shortcut interface", func(t *testing.T) {
rt := newBaseTestRuntime(map[string]string{
"share-token": "shr_via_detail",
}, nil, nil)
dry := BaseFormDetail.DryRun(ctx, rt)
data, err := dry.MarshalJSON()
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
api, _ := parsed["api"].([]interface{})
if len(api) != 1 {
t.Fatalf("expected 1 call, got %d", len(api))
}
call := api[0].(map[string]interface{})
if call["method"] != "POST" {
t.Fatalf("method=%q want POST", call["method"])
}
if !strings.Contains(call["url"].(string), "forms/detail") {
t.Fatalf("url=%q should contain forms/detail", call["url"])
}
body, _ := call["body"].(map[string]interface{})
if body["share_token"] != "shr_via_detail" {
t.Fatalf("share_token=%q want shr_via_detail", body["share_token"])
}
})
t.Run("description", func(t *testing.T) {
desc := BaseFormDetail.Description
if desc == "" {
t.Fatal("Description should not be empty")
}
if !strings.Contains(strings.ToLower(desc), "detail") {
t.Fatalf("Description=%q should mention detail", desc)
}
})
}
// --- executeFormSubmit & uploadAttachmentsParallel 单元测试 ---
func TestExecuteFormSubmit(t *testing.T) {
t.Run("fields only - no attachments", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/tables/forms/submit",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_submit1",
},
},
})
args := []string{
"+form-submit",
"--share-token", "shr_exec1",
"--json", `{"fields":{"Name":"Alice","Rating":5}}`,
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"record_id"`) || !strings.Contains(got, `"rec_submit1"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("invalid json returns error", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_exec3",
"--json", `{not valid`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "invalid JSON") {
t.Fatalf("error should mention invalid JSON, got: %v", err)
}
})
t.Run("missing both fields and attachments returns error", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_exec4",
"--json", `{}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected error for empty JSON")
}
if !strings.Contains(err.Error(), "must contain at least") {
t.Fatalf("error should mention fields/attachments, got: %v", err)
}
})
t.Run("attachments without base-token returns error", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_exec5",
"--json", `{"attachments":{"File":["./x.pdf"]}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected error for missing base-token")
}
if !strings.Contains(err.Error(), "--base-token is required") {
t.Fatalf("error should mention base-token, got: %v", err)
}
})
t.Run("attachment file not found returns error", func(t *testing.T) {
tmpDir := t.TempDir()
withBaseWorkingDir(t, tmpDir)
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_exec6",
"--base-token", "bas_exec6",
"--json", `{"attachments":{"File":["./nonexistent.pdf"]}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected error for nonexistent file")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "not accessible") && !strings.Contains(errMsg, "no such file") {
t.Fatalf("error should mention file not found, got: %v", errMsg)
}
})
t.Run("duplicate file paths across fields deduplicated in upload", func(t *testing.T) {
tmpDir := t.TempDir()
sharedFile := filepath.Join(tmpDir, "shared.pdf")
if err := os.WriteFile(sharedFile, []byte("%PDF shared"), 0644); err != nil {
t.Fatalf("create file: %v", err)
}
withBaseWorkingDir(t, tmpDir)
factory, stdout, reg := newExecuteFactory(t)
// Only ONE upload expected (same file referenced by two fields)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "ft_shared_001",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/tables/forms/submit",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_dedup",
},
},
})
args := []string{
"+form-submit",
"--share-token", "shr_dedup",
"--base-token", "bas_dedup",
"--json", `{"attachments":{"FieldA":["./shared.pdf"],"FieldB":["./shared.pdf"]}}`,
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"rec_dedup"`) {
t.Fatalf("stdout should contain record, got: %s", got)
}
})
}
func TestUploadAttachmentsParallel(t *testing.T) {
t.Run("single file upload via execute path", func(t *testing.T) {
tmpDir := t.TempDir()
singleFile := filepath.Join(tmpDir, "doc.txt")
if err := os.WriteFile(singleFile, []byte("single file content"), 0644); err != nil {
t.Fatalf("create file: %v", err)
}
withBaseWorkingDir(t, tmpDir)
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"file_token": "ft_single_001",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/tables/forms/submit",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_parallel1",
},
},
})
args := []string{
"+form-submit",
"--share-token", "shr_para1",
"--base-token", "bas_para1",
"--json", `{"attachments":{"Doc":["./doc.txt"]}}`,
}
if err := runShortcut(t, BaseFormSubmit, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
if !strings.Contains(got, `"rec_parallel1"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("upload failure propagates error", func(t *testing.T) {
tmpDir := t.TempDir()
badFile := filepath.Join(tmpDir, "bad.txt")
if err := os.WriteFile(badFile, []byte("bad"), 0644); err != nil {
t.Fatalf("create file: %v", err)
}
withBaseWorkingDir(t, tmpDir)
factory, stdout, reg := newExecuteFactory(t)
// Upload returns non-zero code → error
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "medias/upload_all",
Body: map[string]interface{}{
"code": 12345,
"msg": "upload quota exceeded",
},
})
args := []string{
"+form-submit",
"--share-token", "shr_err",
"--base-token", "bas_err",
"--json", `{"attachments":{"Bad":["./bad.txt"]}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected error from failed upload")
}
// Error should mention upload failure
errMsg := err.Error()
if !strings.Contains(errMsg, "upload") && !strings.Contains(errMsg, "failed") {
t.Fatalf("error should mention upload failure, got: %v", errMsg)
}
})
}