Files
larksuite__cli/shortcuts/base/base_shortcuts_test.go
wanghm-bytedance a257fcbaf9 feat(base): support ranking dashboard blocks (#2528)
* feat(base): support ranking dashboard blocks

* fix(base): align ranking validation with strict schema

* fix(base): scope ranking validation to dashboards
2026-08-31 15:33:13 +08:00

2888 lines
99 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 {
if name == "field-names" {
cmd.Flags().StringSlice(name, nil, "")
} else {
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")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T: %v", err, err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category=%q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
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", "+table-copy", "+table-copy-status",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+field-extension-get", "+field-extension-update", "+field-extension-update-cells",
"+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",
"+template-categories", "+template-list", "+template-search",
"+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",
"+button-rule-bind", "+button-rule-get", "+button-rule-unbind",
"+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", "+form-share-get", "+form-share-update",
"+dashboard-list", "+dashboard-get", "+dashboard-share-get", "+dashboard-share-update", "+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",
"+workspace-create", "+workspace-entity-list", "+workspace-move-in",
"+app-create", "+app-get",
"+app-page-list", "+app-page-get", "+app-page-create", "+app-page-update", "+app-page-delete",
"+app-block-list", "+app-block-get", "+app-block-get-data", "+app-block-create", "+app-block-update",
}
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 TestShareManagementShortcutScopes(t *testing.T) {
tests := []struct {
name string
scopes []string
want []string
}{
{
name: "dashboard get requires update scope",
scopes: BaseDashboardShareGet.Scopes,
want: []string{"base:dashboard:update"},
},
{
name: "dashboard update requires update scope",
scopes: BaseDashboardShareUpdate.Scopes,
want: []string{"base:dashboard:update"},
},
{
name: "form get requires update scope",
scopes: BaseFormShareGet.Scopes,
want: []string{"base:form:update"},
},
{
name: "form update requires update scope",
scopes: BaseFormShareUpdate.Scopes,
want: []string{"base:form:update"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if !reflect.DeepEqual(tt.scopes, tt.want) {
t.Fatalf("Scopes=%v want=%v", tt.scopes, tt.want)
}
})
}
}
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 TestBaseFieldCreateTipsGuideTypeSelectionByStoredValue(t *testing.T) {
tips := strings.Join(BaseFieldCreate.Tips, "\n")
for _, want := range []string{
"+field-create defines storage schema only",
"a documented field type",
"explicit stored-value requirements and the user's semantics",
"field name or business purpose only as a clue to confirm",
"do not use it to invent derived behavior",
"Use style only to format the chosen type",
"explicitly requested derived, automatic, synchronized, or backfilled behavior",
"use documented formula, lookup, link, workflow, or automation only",
"formula, lookup, link, workflow, or automation",
"If unsupported, do not probe code/web/OpenAPI, create a storage placeholder, or claim completion",
"report the boundary and alternatives",
"for multiple fields in one table, prefer one array",
"array items are created sequentially",
"prefer --json @file or an argv-safe subprocess call",
"do not double-escape JSON inside shell command substitution",
"For large arrays, bound successful stdout with --jq",
"if .ok then (.data | {created,total,field_get_recommended,next_step,verification_hint}) else . end",
"preserves the full partial-failure envelope",
"next_step:done means stop",
"filter +field-list with --jq",
} {
if !strings.Contains(tips, want) {
t.Fatalf("field-create tips should contain %q, got:\n%s", want, tips)
}
}
lowerTips := strings.ToLower(tips)
for _, caseArtifact := range []string{
"base_table_",
"larkoffice.com/base/",
"grading_pass_rate",
"benchmark",
} {
if strings.Contains(lowerTips, caseArtifact) {
t.Fatalf("field-create tips should remain generic, found %q:\n%s", caseArtifact, tips)
}
}
}
func TestTemplateCenterShortcutContract(t *testing.T) {
ctx := context.Background()
for _, shortcut := range []common.Shortcut{BaseTemplateCategories, BaseTemplateList, BaseTemplateSearch} {
if shortcut.Risk != "read" {
t.Fatalf("%s risk=%q, want read", shortcut.Command, shortcut.Risk)
}
if !reflect.DeepEqual(shortcut.Scopes, []string{templateReadScope}) {
t.Fatalf("%s scopes=%v, want [%s]", shortcut.Command, shortcut.Scopes, templateReadScope)
}
if !reflect.DeepEqual(shortcut.AuthTypes, authTypes()) {
t.Fatalf("%s authTypes=%v, want %v", shortcut.Command, shortcut.AuthTypes, authTypes())
}
}
err := BaseTemplateSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"keyword": " "}, nil, map[string]int{"limit": 10}))
assertInvalidArgumentValidation(t, err, "--keyword", nil, "must not be blank")
}
func TestFieldExtensionShortcutContract(t *testing.T) {
ctx := context.Background()
if BaseFieldExtensionGet.Risk != "read" {
t.Fatalf("get risk=%q, want read", BaseFieldExtensionGet.Risk)
}
if !reflect.DeepEqual(BaseFieldExtensionGet.Scopes, []string{fieldExtensionReadScope}) {
t.Fatalf("get scopes=%v, want [%s]", BaseFieldExtensionGet.Scopes, fieldExtensionReadScope)
}
for _, shortcut := range []common.Shortcut{BaseFieldExtensionUpdate, BaseFieldExtensionUpdateCells} {
if shortcut.Risk != "high-risk-write" {
t.Fatalf("%s risk=%q, want high-risk-write", shortcut.Command, shortcut.Risk)
}
if !reflect.DeepEqual(shortcut.AuthTypes, authTypes()) {
t.Fatalf("%s authTypes=%v, want %v", shortcut.Command, shortcut.AuthTypes, authTypes())
}
}
if !reflect.DeepEqual(BaseFieldExtensionUpdate.Scopes, []string{fieldExtensionUpdateScope}) {
t.Fatalf("update scopes=%v, want [%s]", BaseFieldExtensionUpdate.Scopes, fieldExtensionUpdateScope)
}
if !reflect.DeepEqual(BaseFieldExtensionUpdateCells.Scopes, []string{fieldExtensionUpdateCellsScope}) {
t.Fatalf("update-cells scopes=%v, want [%s]", BaseFieldExtensionUpdateCells.Scopes, fieldExtensionUpdateCellsScope)
}
clearRT := newBaseTestRuntime(map[string]string{"json": `{}`}, nil, nil)
if err := BaseFieldExtensionUpdate.Validate(ctx, clearRT); err != nil {
t.Fatalf("clear validation err=%v", err)
}
updateRT := newBaseTestRuntime(map[string]string{"json": `{"extension_id":"builtin_llm_completion","inputs":{"prompt":[{"type":"text","text":"Summarize"},{"type":"field_ref","field":"Description"}]}}`}, nil, nil)
if err := BaseFieldExtensionUpdate.Validate(ctx, updateRT); err != nil {
t.Fatalf("update validation err=%v", err)
}
unsupportedExtension := newBaseTestRuntime(map[string]string{"json": `{"extension_id":"builtin_summary","inputs":{"prompt":[]}}`}, nil, nil)
err := BaseFieldExtensionUpdate.Validate(ctx, unsupportedExtension)
assertInvalidArgumentValidation(t, err, "--json", nil, "builtin_llm_completion")
missingPrompt := newBaseTestRuntime(map[string]string{"json": `{"extension_id":"builtin_llm_completion","inputs":{}}`}, nil, nil)
err = BaseFieldExtensionUpdate.Validate(ctx, missingPrompt)
assertInvalidArgumentValidation(t, err, "--json", nil, "inputs.prompt")
rowWithoutRecords := newBaseTestRuntime(map[string]string{"type": "row"}, nil, nil)
err = BaseFieldExtensionUpdateCells.Validate(ctx, rowWithoutRecords)
assertInvalidArgumentValidation(t, err, "--record-id", nil, "--record-id is required")
columnWithRecords := newBaseTestRuntimeWithArrays(
map[string]string{"type": "column"},
map[string][]string{"record-id": {"rec_1"}},
nil,
nil,
)
err = BaseFieldExtensionUpdateCells.Validate(ctx, columnWithRecords)
assertInvalidArgumentValidation(t, err, "--record-id", nil, "--record-id is only valid")
}
func TestBaseFieldCreateHelpHidesReadGuideFlag(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFieldCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
if !strings.Contains(cmd.Short, "one or more fields") {
t.Fatalf("help should describe creating one or more fields, got %q", cmd.Short)
}
jsonFlag := cmd.Flags().Lookup("json")
if jsonFlag == nil {
t.Fatal("flag json must exist")
}
if !strings.Contains(jsonFlag.Usage, "JSON object or non-empty array") || !strings.Contains(jsonFlag.Usage, "supports @file") {
t.Fatalf("json flag help should document object and array input, got %q", jsonFlag.Usage)
}
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`,
"maximum records to return; range 1-200, or 1-2000 for ndjson",
"json raw matrix (current inline behavior may be deprecated",
"ndjson artifact (records file plus manifest summary and column schema/stats",
"preferred analysis output: relative .ndjson output path",
},
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",
"Example for analysis",
"prefer --format ndjson --output ./records.ndjson",
"keep long user data out of model context",
"process the records file with Python or another data analysis engine",
"Follow lark-base-record-query-and-analysis-sop.md",
"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`,
"json raw matrix (current inline behavior may be deprecated",
"ndjson artifact (records file plus manifest summary and column schema/stats",
"preferred analysis output: relative .ndjson output path",
},
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",
"Example for analysis",
"prefer --format ndjson --output ./records.ndjson",
"keep long user data out of model context",
"process the records file with Python or another data analysis engine",
"Follow lark-base-record-query-and-analysis-sop.md",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"json raw matrix (current inline behavior may be deprecated",
"ndjson artifact (records file plus manifest summary and column schema/stats",
"preferred analysis output: relative .ndjson output path",
},
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",
"Example for analysis input",
"prefer --format ndjson --output ./records.ndjson",
"keep long user data out of model context",
"process the records file with Python or another data analysis engine",
"Follow lark-base-record-query-and-analysis-sop.md",
"projection boundary",
"record_id is already known",
},
},
}
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)
}
}
for _, flagName := range []string{"minimal-stdout", "jq-records"} {
flag := cmd.Flags().Lookup(flagName)
if flag == nil || !flag.Hidden {
t.Fatalf("--%s should remain available but hidden", flagName)
}
if strings.Contains(help, "--"+flagName) || strings.Contains(tips, "--"+flagName) {
t.Fatalf("--%s should not appear in help or tips", flagName)
}
}
})
}
}
func TestBaseDataQueryHelpRoutesThroughAnalysisSOP(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseDataQuery.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
if !strings.Contains(help, "first follow lark-base-record-query-and-analysis-sop.md") {
t.Fatalf("flag help should route through the analysis SOP:\n%s", help)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range []string{
"Read lark-base-record-query-and-analysis-sop.md before using this command",
"use +data-query only when that SOP selects the Cloud aggregation path",
"After the SOP selects +data-query, read lark-base-data-query.md",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
assertHelpOrder(t, tips, "lark-base-record-query-and-analysis-sop.md", "lark-base-data-query.md")
}
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: "template list", shortcut: BaseTemplateList, flag: "limit", defaultVal: "10", help: "pagination size, range 1-100"},
{name: "template search", shortcut: BaseTemplateSearch, flag: "limit", defaultVal: "10", 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: "maximum records to return; range 1-200, or 1-2000 for ndjson"},
{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 TestBaseRecordSearchLimitHasNoStaticDefault(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseRecordSearch.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("limit")
if flag == nil {
t.Fatal("flag --limit missing")
}
if flag.DefValue != "0" {
t.Fatalf("--limit default=%q, want zero-value registration", flag.DefValue)
}
if help := cmd.Flags().FlagUsages(); strings.Contains(help, "(default 10)") {
t.Fatalf("--limit help exposes a static default:\n%s", help)
}
}
func TestBaseLimitDeclaresPageSizeAlias(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "table list", shortcut: BaseTableList},
{name: "template list", shortcut: BaseTemplateList},
{name: "template search", shortcut: BaseTemplateSearch},
{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) {
var declared *common.Flag
for i := range tt.shortcut.Flags {
if tt.shortcut.Flags[i].Name == "limit" {
declared = &tt.shortcut.Flags[i]
break
}
}
if declared == nil || len(declared.Aliases) != 1 || declared.Aliases[0] != "page-size" {
t.Fatalf("--limit aliases = %#v, want [page-size]", declared)
}
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("page-size")
if flag == nil || flag.Name != "limit" {
t.Fatalf("Lookup(page-size) = %#v, want canonical --limit", flag)
}
if strings.Contains(cmd.Flags().FlagUsages(), "--page-size") {
t.Fatalf("help should not list alias --page-size:\n%s", cmd.Flags().FlagUsages())
}
})
}
}
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "record get", shortcut: BaseRecordGet},
}
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]
primary := cmd.Flags().Lookup("field-id")
if primary == nil || primary.Hidden {
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
}
help := cmd.Flags().FlagUsages()
fieldAlias := cmd.Flags().Lookup("field")
if fieldAlias == nil || fieldAlias.Name != "field-id" {
t.Fatalf("Lookup(field) = %#v, want canonical --field-id", fieldAlias)
}
for _, aliasName := range []string{"fields", "field-names"} {
alias := cmd.Flags().Lookup(aliasName)
if alias == nil || !alias.Hidden {
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
}
for _, line := range strings.Split(help, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "--"+aliasName+" ") || strings.HasPrefix(line, "--"+aliasName+",") {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
}
}
}
for _, line := range strings.Split(help, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "--field ") || strings.HasPrefix(line, "--field,") {
t.Fatalf("help should not list canonical alias --field:\n%s", help)
}
}
})
}
}
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",
"--page-size 100",
"until has_more=false",
},
},
{
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",
"Text block content is stored in data_config.text",
"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",
"complete dashboard export",
"data_config.text",
"does not support computed data",
"use +data-query",
"do not omit the block or guess values",
},
},
{
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 ranking --data-config '{"table_name":"Orders"`,
`--type text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
"lark-base-dashboard-block-config.md as the SSOT",
"do not invent data_config from natural language",
"set the intended group_by.sort in the initial create request",
"do not create first and then issue a second update",
"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"}]}'`,
`--data-config '{"limit_size":20}'`,
"lark-base-dashboard-block-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.md as the module entry",
"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",
},
},
{
name: "button rule bind",
shortcut: BaseButtonRuleBind,
wantTips: []string{
"Use this after +workflow-create and +field-create",
"do not put workflow_id in the field JSON",
"public wkf ID",
"retry this command instead of recreating them",
},
},
{
name: "button rule get",
shortcut: BaseButtonRuleGet,
wantTips: []string{
"bound=false",
"public wkf ID",
"Use this after +button-rule-bind",
},
},
{
name: "button rule unbind",
shortcut: BaseButtonRuleUnbind,
wantTips: []string{
"does not delete the field or workflow",
"Repeat unbind is safe",
"+button-rule-get after unbind",
},
},
}
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 defining the table schema; must hold at least one field, 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 (field IDs) to remove from the form`,
`Default behavior also deletes the underlying fields and their record data`,
`use_existing_field=true and field_id`,
},
},
{
name: "form question create visible_rule",
shortcut: BaseFormQuestionsCreate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
`"use_existing_field":true`,
},
},
{
name: "form question update visible_rule",
shortcut: BaseFormQuestionsUpdate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
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{
"create_records contains one field map per record",
`{"create_records":[{"Name":"Task A","Status":["Todo"]},{"Name":"Task B","Score":20}]}`,
},
},
{
name: "record batch update json",
shortcut: BaseRecordBatchUpdate,
wantHelp: []string{
"update_records maps each record ID to its field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
},
},
}
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",
"Sub-record/child-record path",
"set that link field to a parent record reference array",
`{"Parent Link":[{"id":"rec_xxx"}]}`,
"do not look for parent_record_id or a separate child-record API",
"CellValue happy path: text/phone/url",
"select -> [\"Todo\"] or [\"Tag A\",\"Tag B\"]",
"when multiple=false, the array can contain only one option",
"datetime -> \"2026-03-24 10:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"User and group fields always use arrays",
"when multiple=false, the array can contain only one item",
`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 field: create_records",
"create_records is an array of independent record field maps",
`{"create_records":[{"Name":"Task A","Status":["Todo"]},{"Name":"Task B","Score":20}]}`,
"use +field-list to confirm real writable fields",
"Batch create supports max 200 records per call",
"do not immediately +record-list the same table",
"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 field: update_records",
"update_records maps each record ID to its own field map",
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
"contains only optional ignored_fields",
"does not check whether record IDs exist",
"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)
}
}
if strings.Contains(help, "reformat-existing-records") {
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", 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"}]`,
`Example auto_number update: lark-cli base +field-update`,
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
"just submit the target field definition and do not add extra low-level parameters",
"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)
}
}
if strings.Contains(tips, "--reformat-existing-records") {
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
}
}
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
wantHelp := []string{
"Update uses full question overwrite semantics",
"run +form-questions-list first",
"include existing values you want to keep",
"pass null or omit to clear",
}
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{
"full question overwrite semantics, not a patch",
"Run +form-questions-list first",
"title/description/required/option_display_mode/visible_rule",
"Omitted fields reset to defaults",
"empty strings, null, and empty arrays are written as empty/clear",
}
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":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil {
t.Fatalf("array create validate err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","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":"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)
}
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
t.Fatalf("auto number update validate err=%v", err)
}
}
func TestBaseTableValidate(t *testing.T) {
ctx := context.Background()
// --fields carries the whole table schema, so it is parsed at validate time:
// an unusable schema must fail before the table exists, not after. The
// rejection has to stay machine-readable, so assert the typed metadata and
// the preserved parse cause rather than the message alone.
err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil))
assertInvalidArgumentValidation(t, err, "--fields", []string{"--fields"}, "invalid JSON array")
var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("invalid fields json must preserve the json parse cause, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "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-schema.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)
}
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,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
map[string][]string{"field-id": {"fld_name"}},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
}
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
ctx := context.Background()
fields := make([]string, 51)
for i := range fields {
fields[i] = "Field " + strconv.Itoa(i+1)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
nil,
nil,
)); err != nil {
t.Fatalf("50 projection fields should be accepted: %v", err)
}
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}, "field-id": fields},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
body, marshalErr := json.Marshal(map[string]interface{}{
"keyword": "Alice",
"search_fields": []string{"Name"},
"select_fields": fields,
})
if marshalErr != nil {
t.Fatalf("marshal search body: %v", marshalErr)
}
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
}
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
}, nil, nil)
body, err := recordSearchJSONBody(runtime)
if err != nil {
t.Fatalf("recordSearchJSONBody() error = %v", err)
}
if _, exists := body["select_fields"]; exists {
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
}
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
}
}
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
ctx := context.Background()
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
},
nil,
nil,
))
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
}
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: "template list",
shortcut: BaseTemplateList,
runtime: newBaseTestRuntime(map[string]string{"category-key": "office"}, nil, map[string]int{"limit": 101}),
param: "--limit",
},
{
name: "template search",
shortcut: BaseTemplateSearch,
runtime: newBaseTestRuntime(map[string]string{"keyword": "project"}, 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: "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 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 != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-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}}`,
"--yes",
}
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"]}}`,
"--yes",
}
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"]}}`,
"--yes",
}
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)
}
})
}
// TestFormSubmitRequiresConfirmation pins the high-risk-write classification:
// without --yes the runner's confirmation gate must fire before Execute runs,
// returning a typed confirmation_required error and touching no API.
func TestFormSubmitRequiresConfirmation(t *testing.T) {
if BaseFormSubmit.Risk != "high-risk-write" {
t.Fatalf("Risk=%q want high-risk-write", BaseFormSubmit.Risk)
}
factory, stdout, _ := newExecuteFactory(t)
args := []string{
"+form-submit",
"--share-token", "shr_confirm",
"--json", `{"fields":{"Rating":5}}`,
}
err := runShortcut(t, BaseFormSubmit, args, factory, stdout)
if err == nil {
t.Fatal("expected confirmation_required error without --yes")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T: %v", err, err)
}
if problem.Subtype != errs.SubtypeConfirmationRequired {
t.Fatalf("subtype=%q want %q", problem.Subtype, errs.SubtypeConfirmationRequired)
}
}
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"]}}`,
"--yes",
}
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"]}}`,
"--yes",
}
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)
}
})
}