Files
larksuite__cli/shortcuts/base/base_shortcuts_test.go
huarenmin13 7c4f6c023f fix(base): improve field creation and query guidance (#2114)
* fix(base): improve field creation and query guidance

1. Document batch field creation in shortcut help and the delivered Base skill.
2. Choose field types from stored values instead of business-purpose names.
3. Add generic common filter values to the data-query quick guide with contract tests.

说明:
- Combines the accepted fixes for base_table_096, base_table_028, and base_table_087;
    MR 1275 contributes round 4 only.

```ai-signature
改动范围: Base field-create 帮助与 Skill 指南、data-query 快速指南,以及对应的 shortcuts/base 契约测试
思考过程: 保留三个实验的最终通用规则,合并 public main 上新增的字段读回提示,并排除没有 benchmark 支撑的 MR 1275 round6
改动原因: 让代理发现批量字段接口、按存储值选择字段类型,并用常见通用形状构造 data-query 过滤条件
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: a53fdb6b6b82d74e4deff5e6d32591ec897cd2dc6ad662885c961a27e67d4666

* test(base): strengthen guidance contracts

1. Assert every common filter fragment introduced by the data-query quick guide.
2. Keep the field-create argument table compliant with markdown table spacing.

```ai-signature
改动范围: data-query 指南契约测试与 field-create Markdown 表格后的空行
思考过程: 逐条核对 CodeRabbit 建议,只补会防止新增指南片段回退的断言和确定性的 MD058 格式问题,不改生产提示语义
改动原因: 关闭 PR 2114 的两条有效自动审查意见并保持变更可回归
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 81096f474001f2c9c59af48ee64150f205101710ebcf3b909ab9d300a019f46f

* fix(base): generalize data-query filter guidance

1. Replace the date-and-status scenario template with reusable Condition.value shape rules.
2. Update the contract test to require relative-date guidance and reject evaluation-shaped placehold
    ers.

```ai-signature
改动范围: lark-base data-query quick guide 与对应 shortcuts/base 契约测试
思考过程: 保留 select、datetime、empty 的通用 value shape,删除日期字段和状态字段组合模板,避免将 base_table_087 的解题路径固化到公共指南
改动原因: benchmark 显示当前文案能引导目标题,但组合示例与测试过度贴合单题,需要收敛为跨场景可复用的不变量
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 7b6916f05dccf6489dce65610fe757f7b346bcd0f8f5500cfbfb1f30c60471f3

* fix(base): generalize creation guidance

* fix(base): report partial field-create results

* fix(base): preserve partial field-create recovery metadata

* fix(base): separate partial field recovery

* perf(base): 缩短字段批量创建的空等

1. 将固定 1 秒批次等待改为 500ms 最小请求起点间隔,并让请求耗时抵扣等待
2. 新增节流计算契约测试,覆盖首次请求、快速响应和慢响应
3. 同输入 150 字段 A/B 从 269.64s 降至 118.80s,且两侧均创建 150/150

说明:
- 保持同表写入串行和 partial failure 输出不变

```ai-signature
改动范围: shortcuts/base/field_ops.go 与 shortcuts/base/base_execute_test.go,仅调整 field-create 数组批次的串行节流计算和回归测试
思考过程: 保留同表串行写入,以 500ms 作为请求起点最小间隔,并把请求耗时计入间隔,避免固定空等同时降低写冲突风险
改动原因: PR 引导 Agent 使用数组批量创建后触发既有每项固定 1 秒等待,导致 150 字段用例产生约 149 秒可归因耗时回退
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 69b888d362d2f484c4ce6ad050bdbfe2de7368948eb79ba516bcaa6806ec9107

* fix(base): 收紧不支持字段行为的终止边界

1. 派生、自动、同步或回填行为只使用已记录能力,无法实现时禁止探测、占位或虚假完成
2. 删除 data-query 契约测试对旧题模板占位符的反向黑名单,只保留原子规则和真实 case 污染检查
3. field-create 指引替换前后均为 36 个英文词,不扩大该帮助项的词数

```ai-signature
改动范围: shortcuts/base/field_create.go、base_shortcuts_test.go 与 data_query_guide_contract_test.go,仅收口通用终止规则和测试泛化
思考过程: 采纳 review 中可独立闭环的两点,不增加翻译专用规则,不修改运行时能力;用等词数替换避免帮助上下文继续增长
改动原因: 当前规则能阻止按业务名猜字段类型,却仍允许退化成普通文本占位;同时测试记住旧题模板会阻碍未来合理示例
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 759b7f4044c6381b963c8ea299b70967e634ab9d43b6ebfbe94ef38bb69ba46a

* perf(base): 压缩字段批量创建的评测开销

1. 批量成功与部分失败仅返回字段 id/name/type,保留恢复所需身份并减少大响应上下文
2. 引导数组在调用方超时范围内一次提交,并为生成的大数组推荐 @file 或 argv-safe 调用
3. 字段列表默认页大小提升到 API 上限 200,避免百字段以上场景的帮助查询与重试

说明:
- 同口径 case032:raw token 432292→428304,weighted token 126408→117761,耗时 272675ms→235022ms,两侧均读回 154 字段

```ai-signature
改动范围: Base field-create 批量输出、帮助提示、field-list 默认分页及对应契约测试与 Skill 返回说明
思考过程: 从同口径 trace 定位固定分块、大字段对象回传、100 条分页和 shell 双重转义四个确定性开销,保持单字段与部分失败恢复语义不变并逐项用测试锁定
改动原因: PR 引导数组批量创建后虽降低耗时,但多回合大输出会推高 raw token;需要在不牺牲正确率和恢复信息的前提下同时压缩 token 与耗时
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 8a2bd2d79c9abdb1e36ae45822dd04b2a3384498a1dc268cffcd47cff00d94ed

* perf(base): 收敛字段批量创建的上下文

1. 大数组成功路径推荐保留摘要的 --jq 投影,失败路径仍原样保留部分失败明细
2. 为一个或多个简单 text 字段提供 help fast path,并让 next_step:done 终止默认回读
3. 补充 Skill、帮助与执行结果契约测试,锁定有界输出和可恢复失败语义

说明:
- 最新 main 同题 A/B:两侧均回读 154 字段,raw token 下降 5.9%,耗时下降 19.3%,峰值上下文下降 13.3%,工具调用下降 20%

```ai-signature
改动范围: Base field-create 帮助、简单字段成功提示、lark-base Skill 路由与对应契约测试
思考过程: 从最终 A/B trace 分别定位批量成功展开 150 项、简单 text 读取冗余指南和成功后整表回读三类可控上下文开销,用成功摘要与失败全量明细分流来保留恢复能力
改动原因: 继续优化 PR 2114 的 token 和耗时,同时要求任何回退不能归因到 PR;需要让大批量成功路径有界且不削弱正确性或部分失败恢复
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 626b288a7f5444dfecb872e6cd29d4fe0788404f5001dcb47aac64e2d55d9b5a

* fix(base): 恢复批量字段输出与分页默认契约

1. 批量创建完整成功时保留服务端字段元数据,部分失败仍返回精简 identity
2. 将 +field-list 默认页大小恢复为 100,继续支持显式 --limit 200
3. 补充回归测试与字段创建文档,保留 --jq 有界输出指导

说明:
- 定向、Base 全量、race、仓库单测、构建、vet 与 lint 均通过

```ai-signature
改动范围: Base 批量字段创建成功输出、字段列表默认分页、对应测试与文档
思考过程: 先用契约测试复现完整字段元数据丢失和默认分页翻倍,再只恢复主干既有成功输出与默认值,同时锁定部分失败精简输出不变
改动原因: 移除可归因到 PR 的兼容性和 token 回退,并保留显式 jq 投影、节流、快速路径及部分失败恢复带来的通用收益
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 6351ed9d228611e3b6f5bc30faf278835b463e07f4eb020f225abd41e264399c

* fix(base): present batch field errors before partial output

1. Project the typed field-create error through Runtime.PresentError before copying result fields
2. Read Error, ProblemOf, and permission extensions from the presented clone
3. Cover visible scoped authorization and concealed recovery without fabricating missing_scopes

说明:
- Targeted, Base, full race, build, vet, format, lint, and module checks pass

```ai-signature
改动范围: Base 批量字段创建部分失败的错误呈现,以及 visible 和 concealed 恢复契约测试
思考过程: 先在最新 main 合并树上复现无 scope 授权提示和隐藏命令泄露,再复用兄弟批量命令的 PresentError 边界,仅替换 payload 复制时的错误来源
改动原因: OutPartialFailure 不会再次呈现根错误,必须在复制 typed error 字段前应用命令 scope 与发行隐藏策略
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: deee600dc8d4bb889629895178c1f217ba7762fbd5b3163da3b9d621c8585ac0

* fix(base): allow recovery before retrying failed field writes

1. Clarify that retryable gates unchanged automatic retries, not corrected resubmissions
2. Classify Base error 1254291 as a retryable conflict with canonical wait guidance
3. Cover authorization recovery, write conflicts, and reference contract consistency

```ai-signature
改动范围: internal/errclass/codemeta_base.go、shortcuts/base/field_ops.go、对应 Base 回归测试与 field-create 参考文档
思考过程: 将 retryable 限定为同一请求原样自动重试资格,保留授权或输入修正后重新提交,并复用现有 conflict 恢复提示
改动原因: 部分失败顶层提示会与权限恢复 hint 冲突,且 1254291 未分类导致等待重试规则无法由结构化错误驱动
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 65cd95f24968c5e0b0c43cf858ed787eabb6d9d63b3d5199a294bd284816c35e

* fix(base): preserve partial field recovery contracts

1. Preserve presented typed-error extensions without allowing them to overwrite batch ledger fields
2. Align field creation guidance with command-specific name semantics and caller-timeout recovery
3. Cover security challenges, extension collisions, and storage-type selection with regression tests

```ai-signature
改动范围: shortcuts/base 的 field-create 部分失败输出、命令提示、Base Skill 写入规则、field-create reference 与对应回归测试
思考过程: 复用 Runtime.PresentError 后 concrete typed error 的 JSON wire shape 作为扩展字段单源;对批次账本自有键统一生成无冲突 error_ 别名,并只收敛已证实的同名、fast path 与 timeout 契约矛盾
改动原因: 部分失败会丢失 challenge_url 等恢复字段,自定义 typed error 还可覆盖 status/index/error 导致错误账本;过度绝对的字段类型、同名和超时文案也会形成可归因正确率回退
Break Change: 否
```

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
AI-SHA256: 02377560f14202ea652f530389ac102647189923e45548337814ec3871f5a4de

---------

Co-authored-by: BASE Infra Harness <ai@base-infra-harness.noreply.local>
2026-08-07 16:19:19 +08:00

2627 lines
88 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")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype=%q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != param {
t.Fatalf("param=%q, want %s", validationErr.Param, param)
}
if !strings.Contains(validationErr.Message, "must be between") {
t.Fatalf("message=%q, want range limit", validationErr.Message)
}
}
func TestBaseAction(t *testing.T) {
t.Run("missing action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": false}, nil)
_, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err == nil || !strings.Contains(err.Error(), "specify one action") {
t.Fatalf("err=%v", err)
}
})
t.Run("single bool action", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": ""}, map[string]bool{"list": true}, nil)
action, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err != nil || action != "list" {
t.Fatalf("action=%q err=%v", action, err)
}
})
t.Run("mutually exclusive", func(t *testing.T) {
runtime := newBaseTestRuntime(map[string]string{"get": "tbl_1"}, map[string]bool{"list": true}, nil)
_, err := baseAction(runtime, []string{"list"}, []string{"get"})
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
}
func TestParseObjectList(t *testing.T) {
items, err := parseObjectList(testPC, "", "view")
if err != nil || items != nil {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `{"name":"grid"}`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
items, err = parseObjectList(testPC, `[{"name":"grid"}]`, "view")
if err != nil || len(items) != 1 || items[0]["name"] != "grid" {
t.Fatalf("items=%v err=%v", items, err)
}
_, err = parseObjectList(testPC, `[1]`, "view")
if err == nil || !strings.Contains(err.Error(), "must be an object") {
t.Fatalf("err=%v", err)
}
}
func TestWrapViewPropertyBody(t *testing.T) {
arr := []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}
wrapped := wrapViewPropertyBody(arr, "group_config")
wrappedMap, ok := wrapped.(map[string]interface{})
if !ok {
t.Fatalf("wrapped type=%T", wrapped)
}
if !reflect.DeepEqual(wrappedMap["group_config"], arr) {
t.Fatalf("wrapped group_config=%v want=%v", wrappedMap["group_config"], arr)
}
obj := map[string]interface{}{"group_config": arr}
if got := wrapViewPropertyBody(obj, "group_config"); !reflect.DeepEqual(got, obj) {
t.Fatalf("got=%v want=%v", got, obj)
}
}
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
}
}
func TestShortcutsCatalog(t *testing.T) {
shortcuts := Shortcuts()
want := []string{
"+url-resolve", "+title-resolve",
"+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete",
"+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+table-copy", "+table-copy-status",
"+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options",
"+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename",
"+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete",
"+record-history-list",
"+base-get", "+base-copy", "+base-create",
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
"+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable",
"+data-query",
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail",
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
"+form-submit",
"+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange",
"+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete",
}
if len(shortcuts) != len(want) {
t.Fatalf("len(shortcuts)=%d want=%d", len(shortcuts), len(want))
}
for index, command := range want {
if shortcuts[index].Command != command {
t.Fatalf("command[%d]=%q want=%q", index, shortcuts[index].Command, command)
}
}
}
func TestShortcutsDryRunCoverage(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.DryRun == nil {
t.Fatalf("shortcut %q missing DryRun", shortcut.Command)
}
}
}
func TestBaseTableDeleteRisk(t *testing.T) {
if BaseTableDelete.Risk != "high-risk-write" {
t.Fatalf("risk=%q want=%q", BaseTableDelete.Risk, "high-risk-write")
}
}
func TestBaseFieldUpdateRisk(t *testing.T) {
if BaseFieldUpdate.Risk != "high-risk-write" {
t.Fatalf("risk=%q want=%q", BaseFieldUpdate.Risk, "high-risk-write")
}
}
func TestBaseDeleteShortcutsRisk(t *testing.T) {
cases := map[string]string{
BaseFieldDelete.Command: BaseFieldDelete.Risk,
BaseViewDelete.Command: BaseViewDelete.Risk,
BaseRecordDelete.Command: BaseRecordDelete.Risk,
BaseRecordRemoveAttachment.Command: BaseRecordRemoveAttachment.Risk,
BaseFormDelete.Command: BaseFormDelete.Risk,
BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk,
BaseDashboardDelete.Command: BaseDashboardDelete.Risk,
BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk,
BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk,
BaseRoleDelete.Command: BaseRoleDelete.Risk,
}
for command, risk := range cases {
if risk != "high-risk-write" {
t.Fatalf("command=%q risk=%q want=%q", command, risk, "high-risk-write")
}
}
}
func TestBaseHighRiskShortcutsTipsGuideAgents(t *testing.T) {
for _, shortcut := range Shortcuts() {
if shortcut.Risk != "high-risk-write" {
continue
}
parent := &cobra.Command{Use: "base"}
shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup("yes")
if flag == nil {
t.Fatalf("%s missing --yes flag", shortcut.Command)
}
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
if !strings.Contains(tips, "pass --yes without asking again") {
t.Fatalf("%s tips missing agent guidance:\n%s", shortcut.Command, tips)
}
}
}
func 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",
"arrays remain sequential per-field requests",
"split only for timeout bounds, not a fixed chunk size",
"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 TestBaseFieldCreateHelpDocumentsBatchAndHidesReadGuideFlag(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`,
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Text equality filter",
"Option intersection filter",
"Query priority",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
`record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`,
"keyword for record search",
"field ID or name to search",
`filter JSON object or @file`,
`sort JSON array or @file`,
"output format: markdown (default) | json",
},
wantTips: []string{
"Example: lark-cli base +record-search",
"Example with filter/sort JSON",
"Text equality filter",
"Query priority",
"Use --json only when you need to pass the full search body directly",
"Default output is markdown",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID (repeatable)",
"field ID or name to project; repeat to keep only needed columns",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id rec_001 --record-id rec_002 --field-id Name --field-id Status",
"Default output is markdown",
"projection boundary",
"record_id is already known",
"lark-base record read SOP",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBasePaginationHelpShowsDefaults(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
flag string
defaultVal string
help string
}{
{name: "table list", shortcut: BaseTableList, flag: "limit", defaultVal: "50", help: "pagination size, range 1-100"},
{name: "field list", shortcut: BaseFieldList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "field search options", shortcut: BaseFieldSearchOptions, flag: "limit", defaultVal: "30", help: "pagination size, range 1-200"},
{name: "record list", shortcut: BaseRecordList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "record search", shortcut: BaseRecordSearch, flag: "limit", defaultVal: "10", help: "pagination size, range 1-200"},
{name: "view list", shortcut: BaseViewList, flag: "limit", defaultVal: "100", help: "pagination size, range 1-200"},
{name: "form list", shortcut: BaseFormsList, flag: "page-size", defaultVal: "100", help: "page size per request, range 1-100"},
{name: "workflow list", shortcut: BaseWorkflowList, flag: "page-size", defaultVal: "100", help: "page size per request, range 1-100"},
{name: "record history list", shortcut: BaseRecordHistoryList, flag: "page-size", defaultVal: "30", help: "pagination size, range 1-50"},
{name: "dashboard list", shortcut: BaseDashboardList, flag: "page-size", defaultVal: "100", help: "page size, range 1-100"},
{name: "dashboard block list", shortcut: BaseDashboardBlockList, flag: "page-size", defaultVal: "20", help: "page size, range 1-100"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
flag := cmd.Flags().Lookup(tt.flag)
if flag == nil {
t.Fatalf("flag --%s missing", tt.flag)
}
if flag.DefValue != tt.defaultVal {
t.Fatalf("--%s default=%q, want %q", tt.flag, flag.DefValue, tt.defaultVal)
}
help := cmd.Flags().FlagUsages()
if !strings.Contains(help, tt.help) {
t.Fatalf("flag help missing %q:\n%s", tt.help, help)
}
if !strings.Contains(help, "default "+tt.defaultVal) {
t.Fatalf("flag help missing default %s:\n%s", tt.defaultVal, help)
}
if got := strings.Count(help, "default "+tt.defaultVal); got != 1 {
t.Fatalf("flag help default %s count=%d, want 1:\n%s", tt.defaultVal, got, help)
}
})
}
}
func TestBaseLimitDeclaresPageSizeAlias(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
}{
{name: "table list", shortcut: BaseTableList},
{name: "field list", shortcut: BaseFieldList},
{name: "field search options", shortcut: BaseFieldSearchOptions},
{name: "record list", shortcut: BaseRecordList},
{name: "record search", shortcut: BaseRecordSearch},
{name: "view list", shortcut: BaseViewList},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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()
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)
}
if strings.Contains(help, "--"+aliasName) {
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, 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 text --data-config '{"text":"# Sales Dashboard"}'`,
"+table-list and +field-list",
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"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"}]}'`,
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"Block type cannot be changed",
"top-level keys",
},
},
{
name: "dashboard block delete",
shortcut: BaseDashboardBlockDelete,
wantTips: []string{
"lark-cli base +dashboard-block-delete --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --yes",
"pass --yes",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseWorkflowHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantTips []string
}{
{
name: "workflow list",
shortcut: BaseWorkflowList,
wantTips: []string{
"workflow_id values with wkf prefix",
"auto-paginates",
},
},
{
name: "workflow get",
shortcut: BaseWorkflowGet,
wantTips: []string{
"workflow-id must start with wkf",
"steps may be an empty array",
"Use +workflow-get before +workflow-update",
"lark-base-workflow-schema.md",
},
},
{
name: "workflow create",
shortcut: BaseWorkflowCreate,
wantTips: []string{
"lark-cli base +workflow-create --base-token <base_token> --json @workflow.json",
"client_token is required",
"New workflows are created disabled",
"+table-list and +field-list",
"Step ids must be unique",
"lark-base-workflow-guide.md as the entry guide",
"lark-base-workflow-schema.md as the steps JSON SSOT",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow update",
shortcut: BaseWorkflowUpdate,
wantTips: []string{
"lark-cli base +workflow-update --base-token <base_token> --workflow-id <workflow_id> --json @workflow.json",
"PUT uses full replacement semantics",
"Use +workflow-get first",
"keep title/status/steps fields",
"workflow-id must start with wkf",
"Updating does not enable or disable",
"do not invent steps[].type/data/next/children from natural language",
},
},
{
name: "workflow enable",
shortcut: BaseWorkflowEnable,
wantTips: []string{
"workflow-id must start with wkf",
"does not modify steps",
"New workflows are created disabled",
},
},
{
name: "workflow disable",
shortcut: BaseWorkflowDisable,
wantTips: []string{
"workflow-id must start with wkf",
"does not delete the workflow or its steps",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parent := &cobra.Command{Use: "base"}
tt.shortcut.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range tt.wantTips {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
})
}
}
func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
}{
{
name: "base create fields",
shortcut: BaseBaseCreate,
wantHelp: []string{
`field JSON array for the first table schema; use with --table-name`,
`first table name for the custom first table schema; use with --fields`,
},
},
{
name: "table create fields",
shortcut: BaseTableCreate,
wantHelp: []string{
`field JSON array for create, e.g. [{"name":"Title","type":"text"}`,
},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
wantHelp: []string{
`filter JSON object, e.g. {"logic":"and","conditions":[["Status","==","Todo"]]}`,
},
},
{
name: "view set sort",
shortcut: BaseViewSetSort,
wantHelp: []string{
`sort_config JSON object, e.g. {"sort_config":[{"field":"Priority","desc":true}]}`,
`use {"sort_config":[]} to clear`,
},
},
{
name: "view set group",
shortcut: BaseViewSetGroup,
wantHelp: []string{
`group JSON object with group_config array, e.g. {"group_config":[{"field":"Status","desc":false}]}`,
},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
wantHelp: []string{
`card JSON object, e.g. {"cover_field":"Cover"} or {"cover_field":null} to clear`,
},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
wantHelp: []string{
`timebar JSON object with start_time, end_time, title, e.g. {"start_time":"Start Date","end_time":"End Date","title":"Name"}`,
},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
wantHelp: []string{
`visible fields JSON object, e.g. {"visible_fields":["Name","Status"]}`,
},
},
{
name: "form question delete",
shortcut: BaseFormQuestionsDelete,
wantHelp: []string{
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
},
},
{
name: "form question create visible_rule",
shortcut: BaseFormQuestionsCreate,
wantHelp: []string{
`"visible_rule"(display condition; same shape as view filter`,
},
},
{
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 (multiple=false) -> \"Todo\"",
"select (multiple=true) -> [\"Tag A\",\"Tag B\"]",
"datetime -> \"2026-03-24 10:00:00\"",
"checkbox -> true/false",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
`location uses {"lng":116.397428,"lat":39.90923}`,
"Do not guess user/chat/linked-record IDs or location coordinates",
"lark-base-cell-value.md",
"do not invent values for fields not covered by the happy path",
},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
wantTips: []string{
"Happy path 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()
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid fields json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "view": `[1]`}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
}
if err := BaseTableCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "name": "Orders", "fields": `[{"name":"Name","type":"text"}]`, "view": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
}
func TestBaseCreateValidate(t *testing.T) {
ctx := context.Background()
if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks"}, nil, nil)); err != nil {
t.Fatalf("table-name-only should be valid, err=%v", err)
}
if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"}]`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
}
func TestBaseCreateTipsGuideFieldSchema(t *testing.T) {
parent := &cobra.Command{Use: "base"}
BaseBaseCreate.Mount(parent, &cmdutil.Factory{})
cmd := parent.Commands()[0]
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
for _, want := range []string{
"Before using --fields, read lark-base-field-json.md",
"do not invent field properties",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}
func TestBaseCreateScopesCoverFollowUpTableOperations(t *testing.T) {
requiredUserScopes := []string{
"base:app:create",
"base:table:read",
"base:table:create",
"base:table:update",
"base:table:delete",
}
if !reflect.DeepEqual(BaseBaseCreate.UserScopes, requiredUserScopes) {
t.Fatalf("UserScopes=%v want=%v", BaseBaseCreate.UserScopes, requiredUserScopes)
}
requiredBotScopes := append(append([]string{}, requiredUserScopes...), "docs:permission.member:create")
if !reflect.DeepEqual(BaseBaseCreate.BotScopes, requiredBotScopes) {
t.Fatalf("BotScopes=%v want=%v", BaseBaseCreate.BotScopes, requiredBotScopes)
}
}
func TestBaseRecordValidate(t *testing.T) {
ctx := context.Background()
if BaseRecordList.Validate == nil {
t.Fatalf("record list validate should reject invalid query flags before dry-run")
}
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON/query flags before dry-run")
}
if BaseRecordGet.Validate == nil {
t.Fatalf("record get validate should reject invalid record selection before dry-run")
}
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
}
if err := BaseRecordUpsert.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"Name":"Alice"}`}, nil, nil)); err != nil {
t.Fatalf("record upsert map validate err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `{"logic":"and","conditions":[["Status","==","Todo"]]}`},
nil,
nil,
)); err != nil {
t.Fatalf("record list filter-json validate err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntime(
map[string]string{"base-token": "b", "table-id": "tbl_1", "filter-json": `[["Status","==","Todo"]]`},
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "--filter-json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordList.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "sort-json": `[{"field":"F1"},{"field":"F2"},{"field":"F3"},{"field":"F4"},{"field":"F5"},{"field":"F6"},{"field":"F7"},{"field":"F8"},{"field":"F9"},{"field":"F10"},{"field":"F11"}]`},
nil,
nil,
nil,
)); err == nil || !strings.Contains(err.Error(), "sort supports at most 10 sort conditions") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--keyword is required unless --json is used") {
t.Fatalf("err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
map[string][]string{"search-field": {"Name"}},
nil,
nil,
)); err != nil {
t.Fatalf("record search flag validate err=%v", err)
}
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
map[string]string{
"base-token": "b",
"table-id": "tbl_1",
"json": `{"keyword":"Alice","search_fields":["Name"],"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
"sort-json": `[{"field":"Title","desc":false}]`,
},
nil,
nil,
)); err != nil {
t.Fatalf("record search json with sort-json validate err=%v", err)
}
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: "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)
}
})
}