mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
7c4f6c023f
* 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>
4048 lines
150 KiB
Go
4048 lines
150 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package base
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/errclass"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/recovery"
|
|
"github.com/larksuite/cli/internal/surface"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
|
return newExecuteFactoryWithUserOpenID(t, "ou_testuser")
|
|
}
|
|
|
|
func newExecuteFactoryWithUserOpenID(t *testing.T, userOpenID string) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
|
t.Helper()
|
|
config := &core.CliConfig{
|
|
AppID: "test-app-" + strings.ReplaceAll(strings.ToLower(t.Name()), "/", "-"),
|
|
AppSecret: "test-secret",
|
|
Brand: core.BrandFeishu,
|
|
UserOpenId: userOpenID,
|
|
}
|
|
factory, stdout, _, reg := cmdutil.TestFactory(t, config)
|
|
return factory, stdout, reg
|
|
}
|
|
|
|
func withBaseWorkingDir(t *testing.T, dir string) {
|
|
t.Helper()
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Getwd() err=%v", err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatalf("Chdir(%q) err=%v", dir, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Chdir(cwd); err != nil {
|
|
t.Fatalf("restore cwd err=%v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
|
t.Helper()
|
|
return runShortcutWithAuthTypes(t, shortcut, []string{"bot"}, args, factory, stdout)
|
|
}
|
|
|
|
func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes []string, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error {
|
|
t.Helper()
|
|
if authTypes != nil {
|
|
shortcut.AuthTypes = authTypes
|
|
}
|
|
parent := &cobra.Command{Use: "base"}
|
|
shortcut.Mount(parent, factory)
|
|
parent.SetArgs(args)
|
|
parent.SilenceErrors = true
|
|
parent.SilenceUsage = true
|
|
stdout.Reset()
|
|
if stderr, ok := factory.IOStreams.ErrOut.(*bytes.Buffer); ok {
|
|
stderr.Reset()
|
|
}
|
|
return parent.ExecuteContext(context.Background())
|
|
}
|
|
|
|
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
|
|
t.Helper()
|
|
if err == nil {
|
|
t.Fatal("expected invalid-argument validation error, got nil")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
|
|
}
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
|
}
|
|
if validationErr.Param != wantParam {
|
|
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
|
|
}
|
|
if wantParams != nil {
|
|
if len(validationErr.Params) != len(wantParams) {
|
|
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
|
}
|
|
for i, want := range wantParams {
|
|
if validationErr.Params[i].Name != want {
|
|
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
|
}
|
|
}
|
|
}
|
|
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
|
|
t.Fatalf("err=%v, want message containing %q", err, messageContains)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
|
permStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
},
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
reg.Register(permStub)
|
|
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["created"] != true {
|
|
t.Fatalf("created = %#v, want true", data["created"])
|
|
}
|
|
if !strings.Contains(stderr.String(), baseCreateHint) {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
|
}
|
|
base, _ := data["base"].(map[string]interface{})
|
|
if got := common.GetString(base, "app_token"); got != "app_x" {
|
|
t.Fatalf("base.app_token = %q, want %q", got, "app_x")
|
|
}
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantGranted {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
|
}
|
|
if grant["user_open_id"] != "ou_testuser" {
|
|
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
|
}
|
|
if grant["message"] != "Granted the current CLI user full_access on the new base." {
|
|
t.Fatalf("permission_grant.message = %#v", grant["message"])
|
|
}
|
|
|
|
body := decodeCapturedJSONBody(t, permStub)
|
|
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
|
t.Fatalf("unexpected permission request body: %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateWithFields(t *testing.T) {
|
|
oldDelay := baseCreateDefaultTableDeleteDelay
|
|
baseCreateDefaultTableDeleteDelay = 0
|
|
t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay })
|
|
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"tables": []interface{}{
|
|
map[string]interface{}{"id": "tbl_default", "name": "Table 1"},
|
|
}},
|
|
},
|
|
})
|
|
createTableStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_custom", "name": "Tasks", "fields": []interface{}{
|
|
map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
|
map[string]interface{}{"id": "fld_status", "name": "Status", "type": "text"},
|
|
}},
|
|
},
|
|
}
|
|
reg.Register(createTableStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := runShortcut(
|
|
t,
|
|
BaseBaseCreate,
|
|
[]string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks", "--fields", `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`},
|
|
factory,
|
|
stdout,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["created"] != true || data["default_table_deleted"] != true || data["deleted_default_table_id"] != "tbl_default" {
|
|
t.Fatalf("unexpected create output: %#v", data)
|
|
}
|
|
table, _ := data["table"].(map[string]interface{})
|
|
if got := common.GetString(table, "id"); got != "tbl_custom" {
|
|
t.Fatalf("table.id = %q, want tbl_custom", got)
|
|
}
|
|
fields, _ := data["fields"].([]interface{})
|
|
if len(fields) != 2 {
|
|
t.Fatalf("fields len = %d, want 2; output=%#v", len(fields), data["fields"])
|
|
}
|
|
if strings.Contains(stderr.String(), baseCreateHint) {
|
|
t.Fatalf("stderr should not contain default-table cleanup hint when --fields handled cleanup: %q", stderr.String())
|
|
}
|
|
|
|
if body := decodeCapturedJSONBody(t, createTableStub); body["name"] != "Tasks" {
|
|
t.Fatalf("create table body = %#v", body)
|
|
}
|
|
body := decodeCapturedJSONBody(t, createTableStub)
|
|
fieldsBody, _ := body["fields"].([]interface{})
|
|
if len(fieldsBody) != 2 {
|
|
t.Fatalf("create table fields body = %#v", body["fields"])
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateWithFieldsDefaultTableName(t *testing.T) {
|
|
oldDelay := baseCreateDefaultTableDeleteDelay
|
|
baseCreateDefaultTableDeleteDelay = 0
|
|
t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay })
|
|
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"tables": []interface{}{
|
|
map[string]interface{}{"id": "tbl_default", "name": "Table 1"},
|
|
}},
|
|
},
|
|
})
|
|
createTableStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_custom", "name": "Table 1", "fields": []interface{}{
|
|
map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
|
}},
|
|
},
|
|
}
|
|
reg.Register(createTableStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := runShortcut(
|
|
t,
|
|
BaseBaseCreate,
|
|
[]string{"+base-create", "--name", "Demo Base", "--fields", `[{"name":"Title","type":"text"}]`},
|
|
factory,
|
|
stdout,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := decodeCapturedJSONBody(t, createTableStub)
|
|
if body["name"] != "Table 1" {
|
|
t.Fatalf("create table body = %#v, want name Table 1", body)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateWithTableNameOnly(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"tables": []interface{}{
|
|
map[string]interface{}{"id": "tbl_default", "name": "Table 1"},
|
|
}},
|
|
},
|
|
})
|
|
renameStub := &httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_default", "name": "Tasks"},
|
|
},
|
|
}
|
|
reg.Register(renameStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
|
|
err := runShortcut(
|
|
t,
|
|
BaseBaseCreate,
|
|
[]string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks"},
|
|
factory,
|
|
stdout,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["created"] != true || data["default_table_renamed"] != true || data["renamed_default_table_id"] != "tbl_default" {
|
|
t.Fatalf("unexpected create output: %#v", data)
|
|
}
|
|
if data["default_table_deleted"] == true {
|
|
t.Fatalf("table-name-only should not delete the default table: %#v", data)
|
|
}
|
|
table, _ := data["table"].(map[string]interface{})
|
|
if got := common.GetString(table, "name"); got != "Tasks" {
|
|
t.Fatalf("table.name = %q, want Tasks", got)
|
|
}
|
|
if strings.Contains(stderr.String(), baseCreateHint) {
|
|
t.Fatalf("stderr should not contain default schema hint when --table-name handled rename: %q", stderr.String())
|
|
}
|
|
body := decodeCapturedJSONBody(t, renameStub)
|
|
if body["name"] != "Tasks" {
|
|
t.Fatalf("rename table body = %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) {
|
|
t.Run("get", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"base_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseBaseGet, []string{"+base-get", "--base-token", "app_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"base"`) || !strings.Contains(got, `"Demo Base"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("copy", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
permStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
},
|
|
}
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_src/copy",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base", "url": "https://example.com/base/app_new"},
|
|
},
|
|
})
|
|
reg.Register(permStub)
|
|
args := []string{"+base-copy", "--base-token", "app_src", "--name", "Copied Base", "--folder-token", "fld_x", "--time-zone", "Asia/Shanghai", "--without-content"}
|
|
if err := runShortcut(t, BaseBaseCopy, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["copied"] != true {
|
|
t.Fatalf("copied = %#v, want true", data["copied"])
|
|
}
|
|
base, _ := data["base"].(map[string]interface{})
|
|
if got := common.GetString(base, "base_token"); got != "app_new" {
|
|
t.Fatalf("base.base_token = %q, want %q", got, "app_new")
|
|
}
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantGranted {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
|
|
}
|
|
if grant["user_open_id"] != "ou_testuser" {
|
|
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_testuser")
|
|
}
|
|
|
|
body := decodeCapturedJSONBody(t, permStub)
|
|
if body["member_type"] != "openid" || body["member_id"] != "ou_testuser" || body["perm"] != "full_access" || body["type"] != "user" {
|
|
t.Fatalf("unexpected permission request body: %#v", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
|
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
|
|
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if !strings.Contains(stderr.String(), baseCreateHint) {
|
|
t.Fatalf("stderr = %q, want %q", stderr.String(), baseCreateHint)
|
|
}
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantSkipped {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
|
}
|
|
if _, ok := grant["user_open_id"]; ok {
|
|
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateBotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{
|
|
"code": 230001,
|
|
"msg": "no permission",
|
|
},
|
|
})
|
|
|
|
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base"}, factory, stdout); err != nil {
|
|
t.Fatalf("Base creation should still succeed when auto-grant fails, got: %v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantFailed {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
|
}
|
|
if !strings.Contains(grant["message"].(string), "retry later") {
|
|
t.Fatalf("permission_grant.message = %q, want retry guidance", grant["message"])
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"},
|
|
},
|
|
})
|
|
|
|
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if _, ok := data["permission_grant"]; ok {
|
|
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCopyBotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactoryWithUserOpenID(t, "")
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_src/copy",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
|
},
|
|
})
|
|
|
|
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantSkipped {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCopyBotAutoGrantFailureDoesNotFailCopy(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_src/copy",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"app_token": "app_new", "name": "Copied Base"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/permissions/app_new/members?need_notification=false&type=bitable",
|
|
Body: map[string]interface{}{
|
|
"code": 230001,
|
|
"msg": "no permission",
|
|
},
|
|
})
|
|
|
|
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src"}, factory, stdout); err != nil {
|
|
t.Fatalf("Base copy should still succeed when auto-grant fails, got: %v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
grant, _ := data["permission_grant"].(map[string]interface{})
|
|
if grant["status"] != common.PermissionGrantFailed {
|
|
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceExecuteCopyUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_src/copy",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"base_token": "app_new", "name": "Copied Base"},
|
|
},
|
|
})
|
|
|
|
if err := runShortcutWithAuthTypes(t, BaseBaseCopy, authTypes(), []string{"+base-copy", "--base-token", "app_src", "--as", "user"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if _, ok := data["permission_grant"]; ok {
|
|
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestBaseWorkspaceDryRunCreateAndCopyPermissionGrantHints(t *testing.T) {
|
|
t.Run("create bot", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
if err := runShortcut(t, BaseBaseCreate, []string{"+base-create", "--name", "Demo Base", "--dry-run"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
wantDesc := "After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
|
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
|
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
|
}
|
|
})
|
|
|
|
t.Run("copy bot", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
if err := runShortcut(t, BaseBaseCopy, []string{"+base-copy", "--base-token", "app_src", "--dry-run"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
wantDesc := "After Base copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new Base."
|
|
if got := stdout.String(); !strings.Contains(got, wantDesc) {
|
|
t.Fatalf("stdout=%s, want desc %q", got, wantDesc)
|
|
}
|
|
})
|
|
|
|
t.Run("create user", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
if err := runShortcutWithAuthTypes(t, BaseBaseCreate, authTypes(), []string{"+base-create", "--name", "Demo Base", "--as", "user", "--dry-run"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); strings.Contains(got, "grant the current CLI user full_access") {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func decodeBaseEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
|
t.Helper()
|
|
|
|
var envelope map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
|
}
|
|
data, _ := envelope["data"].(map[string]interface{})
|
|
if data == nil {
|
|
t.Fatalf("missing data in output envelope: %#v", envelope)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
|
|
t.Helper()
|
|
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("failed to decode captured request body: %v\nraw=%s", err, string(stub.CapturedBody))
|
|
}
|
|
return body
|
|
}
|
|
|
|
func TestBaseBlockExecuteShortcuts(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
listStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/blocks/list",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"blocks": []interface{}{
|
|
map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"},
|
|
map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"},
|
|
},
|
|
"total": 2,
|
|
},
|
|
},
|
|
}
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/blocks",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"},
|
|
},
|
|
}
|
|
moveStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"},
|
|
},
|
|
}
|
|
renameStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"},
|
|
},
|
|
}
|
|
deleteStub := &httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"block_id": "blk_doc"},
|
|
},
|
|
}
|
|
for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} {
|
|
reg.Register(stub)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil {
|
|
t.Fatalf("list err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) {
|
|
t.Fatalf("list stdout=%s", got)
|
|
}
|
|
if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil {
|
|
t.Fatalf("list body=%#v", body)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil {
|
|
t.Fatalf("create err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) {
|
|
t.Fatalf("create stdout=%s", got)
|
|
}
|
|
createBody := decodeCapturedJSONBody(t, createStub)
|
|
if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" {
|
|
t.Fatalf("create body=%#v", createBody)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil {
|
|
t.Fatalf("move err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"moved": true`) {
|
|
t.Fatalf("move stdout=%s", got)
|
|
}
|
|
moveBody := decodeCapturedJSONBody(t, moveStub)
|
|
if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" {
|
|
t.Fatalf("move body=%#v", moveBody)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil {
|
|
t.Fatalf("rename err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) {
|
|
t.Fatalf("rename stdout=%s", got)
|
|
}
|
|
if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" {
|
|
t.Fatalf("rename body=%#v", body)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("delete err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) {
|
|
t.Fatalf("delete stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseBlockValidationReturnsTypedErrors(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
tests := []struct {
|
|
name string
|
|
shortcut common.Shortcut
|
|
args []string
|
|
params []string
|
|
}{
|
|
{
|
|
name: "create blank name",
|
|
shortcut: BaseBaseBlockCreate,
|
|
args: []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " "},
|
|
params: []string{"--name"},
|
|
},
|
|
{
|
|
name: "move conflicting sibling anchors",
|
|
shortcut: BaseBaseBlockMove,
|
|
args: []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--before-id", "blk_a", "--after-id", "blk_b"},
|
|
params: []string{"--before-id", "--after-id"},
|
|
},
|
|
{
|
|
name: "rename blank name",
|
|
shortcut: BaseBaseBlockRename,
|
|
args: []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " "},
|
|
params: []string{"--name"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed problem, got %T %v", err, err)
|
|
}
|
|
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Fatalf("category/subtype=%s/%s", p.Category, p.Subtype)
|
|
}
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) {
|
|
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
|
}
|
|
if validationErr.Param != tt.params[0] {
|
|
t.Fatalf("param=%q, want %q", validationErr.Param, tt.params[0])
|
|
}
|
|
if len(validationErr.Params) != len(tt.params) {
|
|
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
|
}
|
|
for i, param := range tt.params {
|
|
if validationErr.Params[i].Name != param {
|
|
t.Fatalf("params=%#v, want %v", validationErr.Params, tt.params)
|
|
}
|
|
if validationErr.Params[i].Reason == "" {
|
|
t.Fatalf("params[%d] missing reason: %#v", i, validationErr.Params)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseHistoryExecute(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/record_history",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x"}}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id": "rec_x"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteUpdate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
field interface{}
|
|
submitted map[string]interface{}
|
|
hintContains []string
|
|
}{
|
|
{
|
|
name: "direct complex server type overrides simple submitted type",
|
|
field: map[string]interface{}{"type": "auto_number"},
|
|
submitted: map[string]interface{}{"type": "number"},
|
|
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
|
|
},
|
|
{
|
|
name: "nested simple server type still recommends readback",
|
|
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
|
|
submitted: map[string]interface{}{"type": "auto_number"},
|
|
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
|
|
},
|
|
{
|
|
name: "submitted simple type still recommends readback when response omits type",
|
|
field: map[string]interface{}{"id": "fld_x"},
|
|
submitted: map[string]interface{}{"type": "text"},
|
|
hintContains: []string{`type "text"`, "cannot determine the previous type"},
|
|
},
|
|
{
|
|
name: "missing type is conservative",
|
|
field: map[string]interface{}{"id": "fld_x"},
|
|
submitted: map[string]interface{}{"name": "Amount"},
|
|
hintContains: []string{"unknown or uncommon field type", "+field-get"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
|
|
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
|
|
t.Fatalf("result=%#v, want readback recommendation", got)
|
|
}
|
|
hint, _ := got["verification_hint"].(string)
|
|
for _, want := range tc.hintContains {
|
|
if !strings.Contains(hint, want) {
|
|
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{
|
|
"code": 800070003,
|
|
"msg": "no operation produced",
|
|
},
|
|
})
|
|
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected the API no-op response to surface as an error, got nil")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected a typed API error, got %T %v", err, err)
|
|
}
|
|
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
|
|
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
|
|
}
|
|
var apiErr *errs.APIError
|
|
if !errors.As(err, &apiErr) {
|
|
t.Fatalf("expected APIError, got %T %v", err, err)
|
|
}
|
|
if got := stdout.String(); strings.TrimSpace(got) != "" {
|
|
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
stub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(stub)
|
|
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
|
|
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
gotBody := string(stub.CapturedBody)
|
|
for _, want := range []string{
|
|
`"name":"编号"`,
|
|
`"type":"auto_number"`,
|
|
`"rules":[`,
|
|
`"date_format":"yyyyMM"`,
|
|
`"length":4`,
|
|
} {
|
|
if !strings.Contains(gotBody, want) {
|
|
t.Fatalf("request body missing %q:\n%s", want, gotBody)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
|
|
if strings.Contains(gotBody, forbidden) {
|
|
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
|
|
}
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, forbidden := range []string{`"reformat_existing_records"`} {
|
|
if strings.Contains(got, forbidden) {
|
|
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
stub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
|
},
|
|
}
|
|
reg.Register(stub)
|
|
// Unknown v3 keys are forwarded unchanged; the server remains the source of
|
|
// truth for whether a field-update property is supported.
|
|
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
|
|
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
|
|
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
|
t.Fatalf("expected successful update, got: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
|
|
ctx := context.Background()
|
|
tests := []struct {
|
|
name string
|
|
shortcut common.Shortcut
|
|
runtime *common.RuntimeContext
|
|
}{
|
|
{
|
|
name: "create",
|
|
shortcut: BaseFieldCreate,
|
|
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
|
},
|
|
{
|
|
name: "update",
|
|
shortcut: BaseFieldUpdate,
|
|
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
|
|
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
shortcut common.Shortcut
|
|
args []string
|
|
}{
|
|
{
|
|
name: "field update",
|
|
shortcut: BaseFieldUpdate,
|
|
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "record search",
|
|
shortcut: BaseRecordSearch,
|
|
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "record upsert",
|
|
shortcut: BaseRecordUpsert,
|
|
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "record batch create",
|
|
shortcut: BaseRecordBatchCreate,
|
|
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "record batch update",
|
|
shortcut: BaseRecordBatchUpdate,
|
|
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "view set filter",
|
|
shortcut: BaseViewSetFilter,
|
|
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "view set visible fields",
|
|
shortcut: BaseViewSetVisibleFields,
|
|
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "view set card",
|
|
shortcut: BaseViewSetCard,
|
|
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
{
|
|
name: "view set timebar",
|
|
shortcut: BaseViewSetTimebar,
|
|
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !strings.Contains(err.Error(), "--json must be a JSON object") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "match the documented shape") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if strings.Contains(err.Error(), "array") {
|
|
t.Fatalf("err should not mention array: %v", err)
|
|
}
|
|
if got := stdout.String(); got != "" {
|
|
t.Fatalf("stdout=%q, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseTableExecuteCreate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
createTableStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"id": "tbl_new",
|
|
"name": "Orders",
|
|
"fields": []interface{}{
|
|
map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(createTableStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/views",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "vew_main", "name": "Main", "type": "grid"},
|
|
},
|
|
})
|
|
args := []string{"+table-create", "--base-token", "app_x", "--name", "Orders", "--fields", `[{"name":"OrderNo","type":"text"}]`, "--view", `{"name":"Main","type":"grid"}`}
|
|
if err := runShortcut(t, BaseTableCreate, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"table"`) || !strings.Contains(got, `"vew_main"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := decodeCapturedJSONBody(t, createTableStub)
|
|
fieldsBody, _ := body["fields"].([]interface{})
|
|
if body["name"] != "Orders" || len(fieldsBody) != 1 {
|
|
t.Fatalf("create table body = %#v", body)
|
|
}
|
|
}
|
|
|
|
func TestBaseTableExecuteUpdate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_x", "name": "Orders Updated"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseTableUpdate, []string{"+table-update", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Orders Updated"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"Orders Updated"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseRecordExecuteUpsertUpdate(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
updateStub := &httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"record_id": "rec_x", "fields": map[string]interface{}{"Name": "Alice"}},
|
|
},
|
|
}
|
|
reg.Register(updateStub)
|
|
if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--json", `{"Name":"Alice"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := decodeCapturedJSONBody(t, updateStub)
|
|
if body["Name"] != "Alice" {
|
|
t.Fatalf("request body=%v", body)
|
|
}
|
|
if _, ok := body["fields"]; ok {
|
|
t.Fatalf("request body must not contain fields wrapper: %v", body)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"rec_x"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseViewExecuteRename(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "vew_x", "name": "Renamed", "type": "grid"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewRename, []string{"+view-rename", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--name", "Renamed"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"Renamed"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseViewExecutePropertyActions(t *testing.T) {
|
|
t.Run("set-group", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("set-sort", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
func TestFieldCreateBatchDelayUsesLowerBoundOfWriteConflictGuidance(t *testing.T) {
|
|
want := 500 * time.Millisecond
|
|
if fieldCreateBatchDelay != want {
|
|
t.Fatalf("fieldCreateBatchDelay=%s, want %s", fieldCreateBatchDelay, want)
|
|
}
|
|
}
|
|
|
|
func TestFieldCreateThrottleDelayCountsRequestTime(t *testing.T) {
|
|
startedAt := time.Unix(0, 0)
|
|
for _, tc := range []struct {
|
|
name string
|
|
now time.Time
|
|
want time.Duration
|
|
}{
|
|
{name: "first request", want: 0},
|
|
{name: "fast response waits only for remainder", now: startedAt.Add(200 * time.Millisecond), want: 300 * time.Millisecond},
|
|
{name: "slow response needs no extra wait", now: startedAt.Add(600 * time.Millisecond), want: 0},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
previousStartedAt := startedAt
|
|
if tc.name == "first request" {
|
|
previousStartedAt = time.Time{}
|
|
}
|
|
if got := fieldCreateThrottleDelay(previousStartedAt, tc.now); got != tc.want {
|
|
t.Fatalf("fieldCreateThrottleDelay()=%s, want %s", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteCRUD(t *testing.T) {
|
|
t.Run("list", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"fields": []interface{}{
|
|
map[string]interface{}{"id": "fld_2", "name": "Amount", "type": "number"},
|
|
}, "total": 2},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldList, []string{"+field-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"name": "Amount"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"field_name": "Amount"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_x", "name": "Amount", "type": "number"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldGet, []string{"+field-get", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"field"`) || !strings.Contains(got, `"fld_x"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_new", "name": "Status", "type": "text"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("create generated field recommends readback", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("create array sequentially", func(t *testing.T) {
|
|
oldDelay := fieldCreateBatchDelay
|
|
fieldCreateBatchDelay = 0
|
|
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
|
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
firstStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool {
|
|
return strings.Contains(string(body), `"name":"A"`)
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"id": "fld_a",
|
|
"name": "A",
|
|
"type": "text",
|
|
"default_value": nil,
|
|
"description": "verbose server field metadata",
|
|
"style": map[string]interface{}{"type": "plain"},
|
|
},
|
|
},
|
|
}
|
|
secondStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool {
|
|
return strings.Contains(string(body), `"name":"B"`)
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"},
|
|
},
|
|
}
|
|
reg.Register(firstStub)
|
|
reg.Register(secondStub)
|
|
|
|
err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout)
|
|
if err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["created"] != true || data["total"] != float64(2) {
|
|
t.Fatalf("unexpected output: %#v", data)
|
|
}
|
|
fields, _ := data["fields"].([]interface{})
|
|
if len(fields) != 2 {
|
|
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
|
}
|
|
firstField, _ := fields[0].(map[string]interface{})
|
|
if firstField["id"] != "fld_a" || firstField["name"] != "A" || firstField["type"] != "text" {
|
|
t.Fatalf("batch output should preserve field identity, got %#v", firstField)
|
|
}
|
|
if firstField["description"] != "verbose server field metadata" || firstField["default_value"] != nil {
|
|
t.Fatalf("batch output should preserve server field metadata, got %#v", firstField)
|
|
}
|
|
style, _ := firstField["style"].(map[string]interface{})
|
|
if style["type"] != "plain" {
|
|
t.Fatalf("batch output should preserve server field style, got %#v", firstField)
|
|
}
|
|
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
|
|
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
|
|
}
|
|
hint := common.GetString(data, "verification_hint")
|
|
for _, want := range []string{"do not list or get fields", "filter +field-list with --jq"} {
|
|
if !strings.Contains(hint, want) {
|
|
t.Fatalf("verification_hint=%q, want %q", hint, want)
|
|
}
|
|
}
|
|
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
|
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
|
}
|
|
})
|
|
|
|
t.Run("create array reports progress when a later field fails", func(t *testing.T) {
|
|
oldDelay := fieldCreateBatchDelay
|
|
fieldCreateBatchDelay = 0
|
|
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
|
|
|
runPartial := func(input, createdType, failedName string, failedResponse map[string]interface{}) map[string]interface{} {
|
|
t.Helper()
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
register := func(name string, response map[string]interface{}) {
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"`+name+`"`) },
|
|
Body: response,
|
|
})
|
|
}
|
|
register("A", map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"id": "fld_a",
|
|
"name": "A",
|
|
"type": createdType,
|
|
"default_value": nil,
|
|
"description": "verbose server field metadata",
|
|
"style": map[string]interface{}{"type": "plain"},
|
|
},
|
|
})
|
|
register(failedName, failedResponse)
|
|
err := runShortcut(t, BaseFieldCreate, []string{
|
|
"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input,
|
|
}, factory, stdout)
|
|
var partialErr *output.PartialFailureError
|
|
if !errors.As(err, &partialErr) {
|
|
t.Fatalf("expected partial failure error, got %T: %v", err, err)
|
|
}
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]interface{} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String())
|
|
}
|
|
if envelope.OK || envelope.Data == nil {
|
|
t.Fatalf("unexpected partial failure envelope: %#v", envelope)
|
|
}
|
|
return envelope.Data
|
|
}
|
|
|
|
conflictResponse := map[string]interface{}{
|
|
"code": 1254090,
|
|
"msg": "field already exists",
|
|
"error": map[string]interface{}{
|
|
"log_id": "202607300001",
|
|
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists",
|
|
"details": []interface{}{map[string]interface{}{"value": "choose a different field name"}},
|
|
},
|
|
}
|
|
data := runPartial(`[{"name":"A","type":"auto_number"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "auto_number", "B", conflictResponse)
|
|
summary, _ := data["summary"].(map[string]interface{})
|
|
if summary["requested"] != float64(3) || summary["attempted"] != float64(2) ||
|
|
summary["created"] != float64(1) || summary["failed"] != float64(1) || summary["not_attempted"] != float64(1) {
|
|
t.Fatalf("unexpected summary: %#v", summary)
|
|
}
|
|
|
|
items, _ := data["items"].([]interface{})
|
|
if len(items) != 3 {
|
|
t.Fatalf("items=%#v, want three outcomes", items)
|
|
}
|
|
created, _ := items[0].(map[string]interface{})
|
|
createdField, _ := created["field"].(map[string]interface{})
|
|
failed, _ := items[1].(map[string]interface{})
|
|
failedField, _ := failed["field"].(map[string]interface{})
|
|
notAttempted, _ := items[2].(map[string]interface{})
|
|
notAttemptedField, _ := notAttempted["field"].(map[string]interface{})
|
|
if created["status"] != "created" || createdField["id"] != "fld_a" ||
|
|
failed["status"] != "failed" || failed["index"] != float64(1) || failedField["name"] != "B" ||
|
|
notAttempted["status"] != "not_attempted" || notAttemptedField["name"] != "C" {
|
|
t.Fatalf("unexpected item outcomes: %#v", items)
|
|
}
|
|
if len(createdField) != 3 {
|
|
t.Fatalf("partial failure should keep only compact created-field identity, got %#v", createdField)
|
|
}
|
|
if !strings.Contains(common.GetString(failed, "error"), "field already exists") {
|
|
t.Fatalf("failed item must include the API error: %#v", failed)
|
|
}
|
|
for key, want := range map[string]interface{}{
|
|
"type": "api",
|
|
"subtype": "unknown",
|
|
"code": float64(1254090),
|
|
"hint": "choose a different field name",
|
|
"retryable": false,
|
|
"log_id": "202607300001",
|
|
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/field-exists",
|
|
} {
|
|
if failed[key] != want {
|
|
t.Fatalf("failed[%q]=%#v, want %#v; failed=%#v", key, failed[key], want, failed)
|
|
}
|
|
}
|
|
if _, ok := failed["error_type"]; ok {
|
|
t.Fatalf("failed item must use canonical type/subtype fields: %#v", failed)
|
|
}
|
|
writeConflictData := runPartial(`[{"name":"A","type":"text"},{"name":"W","type":"text"}]`, "text", "W", map[string]interface{}{
|
|
"code": 1254291,
|
|
"msg": "write conflict",
|
|
})
|
|
items, _ = writeConflictData["items"].([]interface{})
|
|
writeConflict, _ := items[1].(map[string]interface{})
|
|
if writeConflict["type"] != "api" || writeConflict["subtype"] != "conflict" || writeConflict["retryable"] != true ||
|
|
!strings.Contains(common.GetString(writeConflict, "hint"), "retry later") {
|
|
t.Fatalf("1254291 must remain a retryable conflict with wait guidance: %#v", writeConflict)
|
|
}
|
|
if !strings.Contains(data["hint"].(string), "Automatically retry a failed item unchanged only when retryable is true") {
|
|
t.Fatalf("hint=%#v", data["hint"])
|
|
}
|
|
if data["field_get_recommended"] != true || data["next_step"] != "inspect_items" || data["verification_hint"] == nil {
|
|
t.Fatalf("partial success with auto_number must recommend readback: %#v", data)
|
|
}
|
|
|
|
simpleData := runPartial(`[{"name":"A","type":"text"},{"name":"B","type":"text"},{"name":"C","type":"text"}]`, "text", "B", conflictResponse)
|
|
if simpleData["field_get_recommended"] != false || simpleData["next_step"] != "inspect_items" {
|
|
t.Fatalf("simple-field partial failure must inspect items, not report done: %#v", simpleData)
|
|
}
|
|
|
|
permissionData := runPartial(`[{"name":"A","type":"text"},{"name":"P","type":"text"}]`, "text", "P", map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "app scope not applied",
|
|
"error": map[string]interface{}{
|
|
"permission_violations": []interface{}{map[string]interface{}{"subject": "base:field:create"}},
|
|
},
|
|
})
|
|
items, _ = permissionData["items"].([]interface{})
|
|
permissionFailure, _ := items[1].(map[string]interface{})
|
|
missingScopes, _ := permissionFailure["missing_scopes"].([]interface{})
|
|
if len(missingScopes) != 1 || missingScopes[0] != "base:field:create" ||
|
|
permissionFailure["identity"] != "bot" || common.GetString(permissionFailure, "console_url") == "" {
|
|
t.Fatalf("permission failure must retain typed extensions: %#v", permissionFailure)
|
|
}
|
|
|
|
policyData := runPartial(`[{"name":"A","type":"text"},{"name":"S","type":"text"}]`, "text", "S", map[string]interface{}{
|
|
"code": 21000,
|
|
"msg": "challenge required",
|
|
"data": map[string]interface{}{
|
|
"challenge_url": "https://passport.feishu.cn/challenge/field-create",
|
|
"hint": "complete MFA in the browser, then retry",
|
|
},
|
|
})
|
|
items, _ = policyData["items"].([]interface{})
|
|
policyFailure, _ := items[1].(map[string]interface{})
|
|
if policyFailure["type"] != "policy" || policyFailure["subtype"] != "challenge_required" ||
|
|
policyFailure["code"] != float64(21000) || policyFailure["retryable"] != false ||
|
|
policyFailure["challenge_url"] != "https://passport.feishu.cn/challenge/field-create" ||
|
|
common.GetString(policyFailure, "hint") != "complete MFA in the browser, then retry" {
|
|
t.Fatalf("policy failure must retain typed extensions: %#v", policyFailure)
|
|
}
|
|
})
|
|
|
|
t.Run("create array presents partial failure recovery", func(t *testing.T) {
|
|
oldDelay := fieldCreateBatchDelay
|
|
fieldCreateBatchDelay = 0
|
|
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
|
|
|
tests := []struct {
|
|
name string
|
|
plan *surface.Plan
|
|
}{
|
|
{name: "visible"},
|
|
{
|
|
name: "concealed",
|
|
plan: surface.NewPlan(map[surface.CommandID]surface.CommandState{
|
|
surface.CommandAuthLogin: surface.CommandConcealed,
|
|
}),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
factory.Recovery = recovery.NewProjector(func() *surface.Plan { return tt.plan })
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"A"`) },
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool { return strings.Contains(string(body), `"name":"B"`) },
|
|
Body: map[string]interface{}{"code": 230027, "msg": "operation unauthorized"},
|
|
})
|
|
|
|
err := runShortcutWithAuthTypes(t, BaseFieldCreate, []string{"bot", "user"}, []string{
|
|
"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--as", "user",
|
|
"--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`,
|
|
}, factory, stdout)
|
|
var partialErr *output.PartialFailureError
|
|
if !errors.As(err, &partialErr) {
|
|
t.Fatalf("expected partial failure error, got %T: %v", err, err)
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data struct {
|
|
Items []map[string]interface{} `json:"items"`
|
|
Hint string `json:"hint"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode partial failure output: %v\nstdout=%s", err, stdout.String())
|
|
}
|
|
if envelope.OK || len(envelope.Data.Items) != 2 {
|
|
t.Fatalf("unexpected partial failure envelope: %#v", envelope)
|
|
}
|
|
|
|
failed := envelope.Data.Items[1]
|
|
wantHint := errclass.PermissionRecovery(
|
|
[]string{"base:field:create"}, "user", errs.SubtypeUserUnauthorized, "",
|
|
).Render(tt.plan)
|
|
gotHint := common.GetString(failed, "hint")
|
|
if gotHint != wantHint {
|
|
t.Errorf("failed hint = %q, want %q", gotHint, wantHint)
|
|
}
|
|
if failed["type"] != "authorization" || failed["subtype"] != "user_unauthorized" || failed["identity"] != "user" || failed["retryable"] != false {
|
|
t.Errorf("failed typed metadata = %#v", failed)
|
|
}
|
|
for _, want := range []string{
|
|
"Automatically retry a failed item unchanged only when retryable is true",
|
|
"otherwise follow its hint to authorize or correct the input before resubmitting it",
|
|
} {
|
|
if !strings.Contains(envelope.Data.Hint, want) {
|
|
t.Errorf("partial failure hint = %q, want %q", envelope.Data.Hint, want)
|
|
}
|
|
}
|
|
if strings.Contains(envelope.Data.Hint, "Do not retry failed items") {
|
|
t.Errorf("partial failure hint must allow recovery before resubmission: %q", envelope.Data.Hint)
|
|
}
|
|
if _, ok := failed["missing_scopes"]; ok {
|
|
t.Errorf("presentation must not fabricate missing_scopes: %#v", failed)
|
|
}
|
|
if tt.plan == nil {
|
|
if !strings.Contains(gotHint, `auth login --scope "base:field:create"`) {
|
|
t.Errorf("visible recovery lost precise auth path: %q", gotHint)
|
|
}
|
|
} else if strings.Contains(gotHint, "auth login") || !strings.Contains(gotHint, "base:field:create") {
|
|
t.Errorf("concealed recovery leaked command or lost scope: %q", gotHint)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("create array with generated field recommends readback", func(t *testing.T) {
|
|
oldDelay := fieldCreateBatchDelay
|
|
fieldCreateBatchDelay = 0
|
|
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
|
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool {
|
|
return strings.Contains(string(body), `"name":"Title"`)
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
BodyFilter: func(body []byte) bool {
|
|
return strings.Contains(string(body), `"name":"编号"`)
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
|
|
},
|
|
})
|
|
|
|
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
if data["created"] != true || data["total"] != float64(2) {
|
|
t.Fatalf("unexpected output: %#v", data)
|
|
}
|
|
if _, ok := data["fields"].([]interface{}); !ok {
|
|
t.Fatalf("batch create must keep fields array: %#v", data)
|
|
}
|
|
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
|
|
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
|
})
|
|
if err := runShortcut(t, BaseFieldDelete, []string{"+field-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"field_id": "fld_x"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
type fieldCreateCollidingTypedError struct {
|
|
errs.Problem
|
|
Index int `json:"index"`
|
|
Status string `json:"status"`
|
|
Field string `json:"field"`
|
|
ErrorText string `json:"error"`
|
|
}
|
|
|
|
func TestFieldCreateTypedErrorExtensionsAliasLedgerCollisions(t *testing.T) {
|
|
extensions := fieldCreateTypedErrorExtensions(&fieldCreateCollidingTypedError{
|
|
Problem: errs.Problem{
|
|
Category: errs.CategoryConfig,
|
|
Subtype: errs.SubtypeInvalidConfig,
|
|
Message: "invalid config",
|
|
},
|
|
Index: 0,
|
|
Status: "created",
|
|
Field: "app_id",
|
|
ErrorText: "masked failure",
|
|
})
|
|
|
|
for key, want := range map[string]interface{}{
|
|
"error_index": float64(0),
|
|
"error_status": "created",
|
|
"error_field": "app_id",
|
|
"error_error": "masked failure",
|
|
} {
|
|
if extensions[key] != want {
|
|
t.Errorf("%s=%#v, want %#v", key, extensions[key], want)
|
|
}
|
|
}
|
|
for _, key := range []string{"index", "status", "field", "error"} {
|
|
if _, exists := extensions[key]; exists {
|
|
t.Errorf("ledger key %q must not be exposed by typed extensions: %#v", key, extensions)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseTableExecuteReadAndDelete(t *testing.T) {
|
|
t.Run("list", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"tables": []interface{}{
|
|
map[string]interface{}{"id": "tbl_a", "name": "Alpha"},
|
|
}, "total": 2},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x", "--limit", "1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"total": 2`) || !strings.Contains(got, `"tables"`) || !strings.Contains(got, `"name": "Alpha"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"table_name": "Alpha"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list-http-404", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Status: 404,
|
|
RawBody: []byte("404 page not found"),
|
|
Headers: map[string][]string{
|
|
"Content-Type": {"text/plain"},
|
|
},
|
|
})
|
|
err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "HTTP 404") || !strings.Contains(err.Error(), "404 page not found") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_x", "name": "Orders", "primary_field": "fld_x"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_x", "name": "OrderNo", "type": "text"}}},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_x", "name": "Main", "type": "grid"}}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseTableGet, []string{"+table-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"name": "Orders"`) || !strings.Contains(got, `"primary_field": "fld_x"`) || !strings.Contains(got, `"id": "fld_x"`) || !strings.Contains(got, `"name": "OrderNo"`) || !strings.Contains(got, `"id": "vew_x"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"field_name": "OrderNo"`) || strings.Contains(got, `"view_name": "Main"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
|
})
|
|
if err := runShortcut(t, BaseTableDelete, []string{"+table-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"table_id": "tbl_x"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
|
t.Run("list with fields and view", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=Name&field_id=Age&limit=1&offset=0&view_id=vew_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"record_id_list": []interface{}{"rec_fields"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list with comma field", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=A%2CB&field_id=C&limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"A,B", "C"},
|
|
"record_id_list": []interface{}{"rec_json_fields"},
|
|
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list field names alias", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"record_id_list": []interface{}{"rec_alias"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name,Age", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alice"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"A,B", "@Owner"},
|
|
"record_id_list": []interface{}{"rec_alias_special"},
|
|
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{
|
|
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
|
|
"--field-names", `"A,B",@Owner`, "--format", "json",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list json format", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
|
"record_id_list": []interface{}{"rec_2"},
|
|
"data": []interface{}{[]interface{}{"Bob", 20}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list json alias", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name"},
|
|
"field_id_list": []interface{}{"fld_name"},
|
|
"record_id_list": []interface{}{"rec_alias"},
|
|
"data": []interface{}{[]interface{}{"Carol"}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) || !strings.Contains(got, `"rec_alias"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list markdown format", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"field_id_list": []interface{}{"fld_name", "fld_age"},
|
|
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
|
"data": []interface{}{
|
|
[]interface{}{"Alice", 18},
|
|
[]interface{}{"Bob", 20},
|
|
},
|
|
"has_more": false,
|
|
"query_context": map[string]interface{}{
|
|
"record_scope": "all_records",
|
|
"field_scope": "selected_fields",
|
|
},
|
|
"ignored_fields": []interface{}{"Formula"},
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"`_record_id` is metadata for record operations, not a table field.",
|
|
"| _record_id | Name | Age |",
|
|
"| rec_1 | Alice | 18 |",
|
|
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
|
|
"Ignored fields: Formula",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("search", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
searchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Title", "Owner"},
|
|
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
|
"has_more": false,
|
|
"query_context": map[string]interface{}{
|
|
"record_scope": "filtered_records",
|
|
"field_scope": "selected_fields",
|
|
"search_scope": "fld_title(Title)",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(searchStub)
|
|
if err := runShortcut(
|
|
t,
|
|
BaseRecordSearch,
|
|
[]string{
|
|
"+record-search",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"filter":{"logic":"and","conditions":[["Status","!=","Done"]]},"sort":{"sort_config":[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]},"offset":0,"limit":2}`,
|
|
"--format", "json",
|
|
},
|
|
factory,
|
|
stdout,
|
|
); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || !strings.Contains(got, `"query_context"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(searchStub.CapturedBody)
|
|
if !strings.Contains(body, `"view_id":"vew_x"`) ||
|
|
!strings.Contains(body, `"keyword":"Created"`) ||
|
|
!strings.Contains(body, `"search_fields":["Title","fld_owner"]`) ||
|
|
!strings.Contains(body, `"select_fields":["Title","fld_owner"]`) ||
|
|
!strings.Contains(body, `"filter":{"conditions":[["Status","!=","Done"]],"logic":"and"}`) ||
|
|
!strings.Contains(body, `"sort":[{"desc":true,"field":"Updated At"},{"desc":false,"field":"Title"}]`) ||
|
|
!strings.Contains(body, `"offset":0`) ||
|
|
!strings.Contains(body, `"limit":2`) {
|
|
t.Fatalf("captured body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("search with flag filter sort and projection", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
searchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Title", "Status"},
|
|
"field_id_list": []interface{}{"fld_title", "fld_status"},
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"data": []interface{}{[]interface{}{"Created by AI", "Todo"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
}
|
|
reg.Register(searchStub)
|
|
if err := runShortcut(
|
|
t,
|
|
BaseRecordSearch,
|
|
[]string{
|
|
"+record-search",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--keyword", "Created",
|
|
"--search-field", "Title",
|
|
"--field-id", "Title",
|
|
"--field-id", "Status",
|
|
"--filter-json", `{"logic":"and","conditions":[["Status","==","Todo"],["Score",">=",80]]}`,
|
|
"--sort-json", `[{"field":"Updated At","desc":true},{"field":"Title","desc":false}]`,
|
|
"--limit", "20",
|
|
"--format", "json",
|
|
},
|
|
factory,
|
|
stdout,
|
|
); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("captured body json err=%v body=%s", err, string(searchStub.CapturedBody))
|
|
}
|
|
if body["keyword"] != "Created" || body["limit"].(float64) != 20 {
|
|
t.Fatalf("captured body=%#v", body)
|
|
}
|
|
filter := body["filter"].(map[string]interface{})
|
|
if filter["logic"] != "and" {
|
|
t.Fatalf("filter=%#v", filter)
|
|
}
|
|
conditions := filter["conditions"].([]interface{})
|
|
if len(conditions) != 2 {
|
|
t.Fatalf("conditions=%#v", conditions)
|
|
}
|
|
sortConfig := body["sort"].([]interface{})
|
|
if len(sortConfig) != 2 {
|
|
t.Fatalf("sort=%#v", sortConfig)
|
|
}
|
|
firstSort := sortConfig[0].(map[string]interface{})
|
|
if firstSort["field"] != "Updated At" || firstSort["desc"] != true {
|
|
t.Fatalf("sort=%#v", sortConfig)
|
|
}
|
|
})
|
|
|
|
t.Run("search with filter json file", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
tmp := t.TempDir()
|
|
withBaseWorkingDir(t, tmp)
|
|
if err := os.WriteFile(filepath.Join(tmp, "filter.json"), []byte(`{"logic":"or","conditions":[["Status","==","Todo"]]}`), 0600); err != nil {
|
|
t.Fatalf("write filter err=%v", err)
|
|
}
|
|
searchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Title"},
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"data": []interface{}{[]interface{}{"A"}},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
}
|
|
reg.Register(searchStub)
|
|
if err := runShortcut(
|
|
t,
|
|
BaseRecordSearch,
|
|
[]string{
|
|
"+record-search",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--keyword", "A",
|
|
"--search-field", "Title",
|
|
"--filter-json", "@filter.json",
|
|
"--format", "json",
|
|
},
|
|
factory,
|
|
stdout,
|
|
); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := string(searchStub.CapturedBody)
|
|
if !strings.Contains(body, `"filter":{"conditions":[["Status","==","Todo"]],"logic":"or"}`) {
|
|
t.Fatalf("captured body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("search markdown format", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Title", "Owner"},
|
|
"field_id_list": []interface{}{"fld_title", "fld_owner"},
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
|
|
"has_more": false,
|
|
"query_context": map[string]interface{}{
|
|
"record_scope": "view_filtered_records",
|
|
"field_scope": "selected_fields",
|
|
"search_scope": "fld_title(Title)",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(
|
|
t,
|
|
BaseRecordSearch,
|
|
[]string{
|
|
"+record-search",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
|
|
},
|
|
factory,
|
|
stdout,
|
|
); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"| _record_id | Title | Owner |",
|
|
"| rec_1 | Created by AI | Alice |",
|
|
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"record_id_list": []interface{}{"rec_fields"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"record_id_list": []interface{}{"rec_fields"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
"total": 1,
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
|
|
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
wantParam string
|
|
wantParams []string
|
|
}{
|
|
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
|
|
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
|
|
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
args := append(append([]string{}, baseArgs...), tc.args...)
|
|
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
|
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
|
|
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordSearch, []string{
|
|
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
|
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
|
"--field-names", "Age",
|
|
}, factory, stdout)
|
|
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
|
|
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
|
|
}
|
|
})
|
|
|
|
t.Run("search json conflict reports canonical pagination flag", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordSearch, []string{
|
|
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
|
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
|
"--limit", "10", "--page-size", "201",
|
|
}, factory, stdout)
|
|
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--page-size"}, "mutually exclusive")
|
|
})
|
|
|
|
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
args []string
|
|
param string
|
|
}{
|
|
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
|
|
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
|
|
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
|
|
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
|
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
|
|
})
|
|
}
|
|
})
|
|
|
|
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
searchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"record_id_list": []interface{}{"rec_search"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(searchStub)
|
|
if err := runShortcut(t, BaseRecordSearch, []string{
|
|
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
|
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
|
t.Fatalf("captured body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{
|
|
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
|
|
"--field-names", "Name", "--field-names", "Age", "--format", "json",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"`_record_id` is metadata for record operations, not a table field.",
|
|
"- `_record_id`: rec_1",
|
|
"- `Name`: Alice",
|
|
"- `Age`: 18",
|
|
"Meta: count=1",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_1"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get json format", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Age"`) || strings.Contains(got, `"record":`) || strings.Contains(got, `"raw"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_1"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get with selected fields", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
"fields": []interface{}{"Name", "Age"},
|
|
"data": []interface{}{[]interface{}{"Alice", 18}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"fields"`) || !strings.Contains(got, `"Name"`) || !strings.Contains(got, `"Age"`) || !strings.Contains(got, `"Alice"`) || strings.Contains(got, `"record":`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get batch with repeated record-id flags", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
|
"fields": []interface{}{"Name"},
|
|
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"| _record_id | Name |",
|
|
"| rec_2 | Bob |",
|
|
"| rec_1 | Alice |",
|
|
"Meta: count=2",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get batch json format", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
|
"fields": []interface{}{"Name"},
|
|
"data": []interface{}{[]interface{}{"Bob"}, []interface{}{"Alice"}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) || !strings.Contains(got, `"Bob"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get batch with json selector", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_3"},
|
|
"fields": []interface{}{"Name"},
|
|
"data": []interface{}{[]interface{}{"Carol"}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"],"select_fields":["Name"]}`, "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_3"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get single returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Status: 404,
|
|
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
|
}
|
|
reg.Register(batchStub)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1"}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected batch_get error")
|
|
}
|
|
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
|
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout=%s", stdout.String())
|
|
}
|
|
})
|
|
|
|
t.Run("get single missing record renders not found markdown", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_missing"},
|
|
"fields": []interface{}{"Name"},
|
|
"data": []interface{}{[]interface{}{nil}},
|
|
"has_more": false,
|
|
"record_not_found": []interface{}{"rec_missing"},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_missing"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
got := stdout.String()
|
|
for _, want := range []string{
|
|
"Record not found.",
|
|
"- `_record_id`: rec_missing",
|
|
"Meta: count=1; has_more=false; record_not_found=1",
|
|
"Missing records: rec_missing",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("stdout missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "- `Name`:") {
|
|
t.Fatalf("missing record output should not render business fields:\n%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get batch returns batch_get error when batch_get is unavailable", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Status: 404,
|
|
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
|
}
|
|
reg.Register(batchStub)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--field-id", "Name"}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected batch_get error")
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) || !strings.Contains(body, `"select_fields":["Name"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout=%s", stdout.String())
|
|
}
|
|
})
|
|
|
|
t.Run("get batch with json record ids and field flags", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_4"},
|
|
"fields": []interface{}{"Status"},
|
|
"data": []interface{}{[]interface{}{"Done"}},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_4"]}`, "--field-id", "Status", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"Done"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_4"]`) || !strings.Contains(body, `"select_fields":["Status"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("get rejects duplicate record ids", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("get rejects duplicate field ids", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--field-id", "Name", "--field-id", "Name"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate field id") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("get rejects mixed record-id and json", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("get rejects mixed field-id and json select_fields", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_2"],"select_fields":["Name"]}`, "--field-id", "Age"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "select_fields") || !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("get rejects empty selection", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordGet, []string{"+record-get", "--base-token", "app_x", "--table-id", "tbl_x"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "provide at least one --record-id") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"record_id": "rec_new", "fields": map[string]interface{}{"Name": "Alice"}},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
if err := runShortcut(t, BaseRecordUpsert, []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"Name":"Alice"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := decodeCapturedJSONBody(t, createStub)
|
|
if body["Name"] != "Alice" {
|
|
t.Fatalf("request body=%v", body)
|
|
}
|
|
if _, ok := body["fields"]; ok {
|
|
t.Fatalf("request body must not contain fields wrapper: %v", body)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"rec_new"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("batch create", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_create",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1", "rec_2"},
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"create_records":[{"Name":"Alice"},{"Name":"Bob"}]}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("batch update", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"ignored_fields": []interface{}{"Formula"},
|
|
},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("batch update passthrough", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
updateStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{},
|
|
},
|
|
}
|
|
reg.Register(updateStub)
|
|
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
|
|
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := string(updateStub.CapturedBody)
|
|
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_1"},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) || strings.Contains(got, `"deleted": true`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
|
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
|
}
|
|
})
|
|
|
|
t.Run("delete returns batch_delete error when unavailable", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
|
Status: 404,
|
|
Body: map[string]interface{}{"code": 404, "msg": "not found"},
|
|
}
|
|
reg.Register(batchStub)
|
|
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected batch_delete error")
|
|
}
|
|
if !strings.Contains(string(batchStub.CapturedBody), `"record_id_list":["rec_1"]`) {
|
|
t.Fatalf("request body=%s", string(batchStub.CapturedBody))
|
|
}
|
|
if stdout.Len() != 0 {
|
|
t.Fatalf("stdout=%s", stdout.String())
|
|
}
|
|
})
|
|
|
|
t.Run("delete batch with repeated record-id flags", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_2", "rec_1"},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_2"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_2","rec_1"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("delete batch with json selector", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
batchStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_delete",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"record_id_list": []interface{}{"rec_3"},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(batchStub)
|
|
if err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_3"]}`, "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_3"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(batchStub.CapturedBody)
|
|
if !strings.Contains(body, `"record_id_list":["rec_3"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("delete requires yes for batch", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_2", "--record-id", "rec_1"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("delete rejects duplicate record ids", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--record-id", "rec_1", "--yes"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "duplicate record id") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("delete rejects mixed record-id and json", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(t, BaseRecordDelete, []string{"+record-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1", "--json", `{"record_id_list":["rec_2"]}`, "--yes"}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("upload attachment", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-*.png")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() err=%v", err)
|
|
}
|
|
img := image.NewRGBA(image.Rect(0, 0, 3, 2))
|
|
img.Set(0, 0, color.RGBA{R: 255, A: 255})
|
|
if err := png.Encode(tmpFile, img); err != nil {
|
|
t.Fatalf("png.Encode() err=%v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("Close() err=%v", err)
|
|
}
|
|
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
|
},
|
|
})
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"file_token": "file_tok_1"},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
appendStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{
|
|
"file_token": "file_tok_1",
|
|
"name": "base-attachment.png",
|
|
"size": 73,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(appendStub)
|
|
|
|
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
|
"+record-upload-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_att",
|
|
"--file", "./" + filepath.Base(tmpFile.Name()),
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"file_tok_1"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
|
|
uploadBody := string(uploadStub.CapturedBody)
|
|
if !strings.Contains(uploadBody, `name="parent_type"`) || !strings.Contains(uploadBody, "bitable_file") || !strings.Contains(uploadBody, `name="parent_node"`) || !strings.Contains(uploadBody, "app_x") {
|
|
t.Fatalf("upload body=%s", uploadBody)
|
|
}
|
|
|
|
appendBody := string(appendStub.CapturedBody)
|
|
if !strings.Contains(appendBody, `"rec_x"`) ||
|
|
!strings.Contains(appendBody, `"fld_att"`) ||
|
|
!strings.Contains(appendBody, `"file_token":"file_tok_1"`) ||
|
|
!strings.Contains(appendBody, `"image_width":3`) ||
|
|
!strings.Contains(appendBody, `"image_height":2`) {
|
|
t.Fatalf("append body=%s", appendBody)
|
|
}
|
|
})
|
|
|
|
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() err=%v", err)
|
|
}
|
|
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
|
t.Fatalf("Truncate() err=%v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("Close() err=%v", err)
|
|
}
|
|
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
|
},
|
|
})
|
|
|
|
prepareStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_prepare",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"upload_id": "upload_big_1",
|
|
"block_size": float64(8 * 1024 * 1024),
|
|
"block_num": float64(3),
|
|
},
|
|
},
|
|
}
|
|
reg.Register(prepareStub)
|
|
|
|
partStubs := make([]*httpmock.Stub, 0, 3)
|
|
for i := 0; i < 3; i++ {
|
|
stub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_part",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "ok",
|
|
},
|
|
}
|
|
partStubs = append(partStubs, stub)
|
|
reg.Register(stub)
|
|
}
|
|
|
|
finishStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/medias/upload_finish",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"file_token": "file_tok_big"},
|
|
},
|
|
}
|
|
reg.Register(finishStub)
|
|
|
|
appendStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/append_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "file_tok_big"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(appendStub)
|
|
|
|
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
|
|
"+record-upload-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_att",
|
|
"--file", "./" + filepath.Base(tmpFile.Name()),
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
|
|
if got := stdout.String(); !strings.Contains(got, `"file_tok_big"`) || strings.Contains(got, `"updated"`) || strings.Contains(got, `"uploaded"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
|
|
prepareBody := string(prepareStub.CapturedBody)
|
|
if !strings.Contains(prepareBody, `"file_name":"`+filepath.Base(tmpFile.Name())+`"`) ||
|
|
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
|
|
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
|
|
!strings.Contains(prepareBody, `"size":20971521`) {
|
|
t.Fatalf("prepare body=%s", prepareBody)
|
|
}
|
|
|
|
firstPartBody := string(partStubs[0].CapturedBody)
|
|
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
|
|
!strings.Contains(firstPartBody, "upload_big_1") ||
|
|
!strings.Contains(firstPartBody, `name="seq"`) ||
|
|
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
|
|
!strings.Contains(firstPartBody, `name="size"`) ||
|
|
!strings.Contains(firstPartBody, "8388608") {
|
|
t.Fatalf("first part body=%s", firstPartBody)
|
|
}
|
|
|
|
lastPartBody := string(partStubs[2].CapturedBody)
|
|
if !strings.Contains(lastPartBody, `name="seq"`) ||
|
|
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
|
|
!strings.Contains(lastPartBody, `name="size"`) ||
|
|
!strings.Contains(lastPartBody, "4194305") {
|
|
t.Fatalf("last part body=%s", lastPartBody)
|
|
}
|
|
|
|
finishBody := string(finishStub.CapturedBody)
|
|
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
|
|
!strings.Contains(finishBody, `"block_num":3`) {
|
|
t.Fatalf("finish body=%s", finishBody)
|
|
}
|
|
|
|
appendBody := string(appendStub.CapturedBody)
|
|
if !strings.Contains(appendBody, `"rec_x"`) ||
|
|
!strings.Contains(appendBody, `"fld_att"`) ||
|
|
!strings.Contains(appendBody, `"file_token":"file_tok_big"`) {
|
|
t.Fatalf("append body=%s", appendBody)
|
|
}
|
|
})
|
|
|
|
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "base-not-attachment-*.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() err=%v", err)
|
|
}
|
|
if _, err := tmpFile.WriteString("hello"); err != nil {
|
|
t.Fatalf("WriteString() err=%v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("Close() err=%v", err)
|
|
}
|
|
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_status",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_status", "name": "状态", "type": "text"},
|
|
},
|
|
})
|
|
|
|
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
|
"+record-upload-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_status",
|
|
"--file", "./" + filepath.Base(tmpFile.Name()),
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "expected attachment") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() err=%v", err)
|
|
}
|
|
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
|
|
t.Fatalf("Truncate() err=%v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("Close() err=%v", err)
|
|
}
|
|
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
|
|
|
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
|
"+record-upload-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_att",
|
|
"--file", "./" + filepath.Base(tmpFile.Name()),
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatal("expected validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), filepath.Base(tmpFile.Name())) {
|
|
t.Fatalf("err=%v should name the offending file", err)
|
|
}
|
|
})
|
|
|
|
t.Run("upload attachment rejects deprecated name flag", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
|
|
tmpFile, err := os.CreateTemp(t.TempDir(), "base-name-*.txt")
|
|
if err != nil {
|
|
t.Fatalf("CreateTemp() err=%v", err)
|
|
}
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Fatalf("Close() err=%v", err)
|
|
}
|
|
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
|
|
|
|
err = runShortcut(t, BaseRecordUploadAttachment, []string{
|
|
"+record-upload-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_att",
|
|
"--file", "./" + filepath.Base(tmpFile.Name()),
|
|
"--name", "renamed.txt",
|
|
}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "--name is no longer supported") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("download attachment uses extra info", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
extra := `{"bitablePerm":{"tableId":"tbl_x","attachments":{"fld_att":{"rec_x":["box_a"]}}}}`
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{
|
|
"file_token": "box_a",
|
|
"name": "pic.png",
|
|
"size": 7,
|
|
"extra_info": extra,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
downloadStub := &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_a/download?" + url.Values{"extra": []string{extra}}.Encode(),
|
|
RawBody: []byte("payload"),
|
|
ContentType: "image/png",
|
|
}
|
|
reg.Register(downloadStub)
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := os.Mkdir("downloads", 0700); err != nil {
|
|
t.Fatalf("Mkdir() err=%v", err)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--file-token", "box_a",
|
|
"--output", "downloads",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "pic.png")); err != nil {
|
|
t.Fatalf("expected downloaded file: %v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
gotItems, _ := data["downloaded"].([]interface{})
|
|
if len(gotItems) != 1 {
|
|
t.Fatalf("downloaded=%#v", data["downloaded"])
|
|
}
|
|
got, _ := gotItems[0].(map[string]interface{})
|
|
if got["file_token"] != "box_a" || got["saved_path"] == "" || got["extra_info_used"] != nil {
|
|
t.Fatalf("download output=%#v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("download all row attachments when file token omitted", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
|
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_a/download",
|
|
RawBody: []byte("payload-a"),
|
|
ContentType: "text/plain",
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_b/download",
|
|
RawBody: []byte("payload-b"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := os.Mkdir("downloads", 0700); err != nil {
|
|
t.Fatalf("Mkdir() err=%v", err)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "downloads",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
|
t.Fatalf("expected downloaded file a.txt: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "b.txt")); err != nil {
|
|
t.Fatalf("expected downloaded file b.txt: %v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
gotItems, _ := data["downloaded"].([]interface{})
|
|
if len(gotItems) != 2 {
|
|
t.Fatalf("downloaded=%#v", data["downloaded"])
|
|
}
|
|
})
|
|
|
|
t.Run("download without file token requires output directory", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
|
|
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "file.txt",
|
|
}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "--output must be an existing directory") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("download surfaces unsafe output path instead of directory hint", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
|
|
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "../escape",
|
|
}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "unsafe output path") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("download all disambiguates duplicate attachment names with file token", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
|
map[string]interface{}{"file_token": "box_a", "name": "same.txt", "size": 7},
|
|
map[string]interface{}{"file_token": "box_b", "name": "same.txt", "size": 8},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_a/download",
|
|
RawBody: []byte("payload-a"),
|
|
ContentType: "text/plain",
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_b/download",
|
|
RawBody: []byte("payload-b"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := os.Mkdir("downloads", 0700); err != nil {
|
|
t.Fatalf("Mkdir() err=%v", err)
|
|
}
|
|
|
|
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "downloads",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_a.txt")); err != nil {
|
|
t.Fatalf("expected downloaded file same_box_a.txt: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "same_box_b.txt")); err != nil {
|
|
t.Fatalf("expected downloaded file same_box_b.txt: %v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
gotItems, _ := data["downloaded"].([]interface{})
|
|
if len(gotItems) != 2 {
|
|
t.Fatalf("downloaded=%#v", data["downloaded"])
|
|
}
|
|
})
|
|
|
|
t.Run("download duplicate requested file token only once", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_a/download",
|
|
RawBody: []byte("payload-a"),
|
|
ContentType: "text/plain",
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--file-token", "box_a",
|
|
"--file-token", "box_a",
|
|
"--output", "a.txt",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
data := decodeBaseEnvelope(t, stdout)
|
|
gotItems, _ := data["downloaded"].([]interface{})
|
|
if len(gotItems) != 1 {
|
|
t.Fatalf("downloaded=%#v", data["downloaded"])
|
|
}
|
|
})
|
|
|
|
t.Run("download all preflights local target conflicts before writing", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
|
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := os.Mkdir("downloads", 0700); err != nil {
|
|
t.Fatalf("Mkdir() err=%v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("downloads", "b.txt"), []byte("existing"), 0600); err != nil {
|
|
t.Fatalf("WriteFile() err=%v", err)
|
|
}
|
|
|
|
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "downloads",
|
|
}, factory, stdout)
|
|
if err == nil || !strings.Contains(err.Error(), "output file already exists: downloads/b.txt") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err == nil {
|
|
t.Fatalf("a.txt should not be written after preflight conflict")
|
|
}
|
|
})
|
|
|
|
t.Run("download reports progress and log_id when later attachment fails", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/get_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{
|
|
"fld_att": []interface{}{
|
|
map[string]interface{}{"file_token": "box_a", "name": "a.txt", "size": 7},
|
|
map[string]interface{}{"file_token": "box_b", "name": "b.txt", "size": 8},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_a/download",
|
|
RawBody: []byte("payload-a"),
|
|
ContentType: "text/plain",
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/drive/v1/medias/box_b/download",
|
|
Status: 403,
|
|
RawBody: []byte("server error"),
|
|
Headers: http.Header{"X-Tt-Logid": []string{"202605270001"}},
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withBaseWorkingDir(t, tmpDir)
|
|
if err := os.Mkdir("downloads", 0700); err != nil {
|
|
t.Fatalf("Mkdir() err=%v", err)
|
|
}
|
|
|
|
err := runShortcut(t, BaseRecordDownloadAttachment, []string{
|
|
"+record-download-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--output", "downloads",
|
|
}, factory, stdout)
|
|
if err == nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
var partialErr *output.PartialFailureError
|
|
if !errors.As(err, &partialErr) {
|
|
t.Fatalf("expected partial failure error, got %T %v", err, err)
|
|
}
|
|
|
|
var envelope map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("failed to decode partial failure output: %v\nraw=%s", err, stdout.String())
|
|
}
|
|
if envelope["ok"] != false {
|
|
t.Fatalf("ok=%#v, want false; envelope=%#v", envelope["ok"], envelope)
|
|
}
|
|
data, _ := envelope["data"].(map[string]interface{})
|
|
if msg, _ := data["message"].(string); !strings.Contains(msg, "download failed after 1 attachment(s) succeeded and 1 failed") {
|
|
t.Fatalf("message=%q", msg)
|
|
}
|
|
downloaded, _ := data["downloaded"].([]interface{})
|
|
failed, _ := data["failed"].([]interface{})
|
|
if len(downloaded) != 1 || len(failed) != 1 {
|
|
t.Fatalf("data=%#v", data)
|
|
}
|
|
downloadedItem, _ := downloaded[0].(map[string]interface{})
|
|
failedItem, _ := failed[0].(map[string]interface{})
|
|
if downloadedItem["file_token"] != "box_a" || failedItem["file_token"] != "box_b" {
|
|
t.Fatalf("data=%#v", data)
|
|
}
|
|
if data["log_id"] != "202605270001" {
|
|
t.Fatalf("data=%#v, want log_id", data)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "downloads", "a.txt")); err != nil {
|
|
t.Fatalf("expected first file to remain: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("remove attachment", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
|
|
},
|
|
})
|
|
removeStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/remove_attachments",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"attachments": map[string]interface{}{
|
|
"rec_x": map[string]interface{}{"fld_att": []interface{}{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(removeStub)
|
|
|
|
if err := runShortcut(t, BaseRecordRemoveAttachment, []string{
|
|
"+record-remove-attachment",
|
|
"--base-token", "app_x",
|
|
"--table-id", "tbl_x",
|
|
"--record-id", "rec_x",
|
|
"--field-id", "fld_att",
|
|
"--file-token", "box_a",
|
|
"--file-token", "box_b",
|
|
"--yes",
|
|
}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); strings.Contains(got, `"removed"`) || strings.Contains(got, `"updated"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
body := string(removeStub.CapturedBody)
|
|
if !strings.Contains(body, `"rec_x"`) ||
|
|
!strings.Contains(body, `"fld_att"`) ||
|
|
!strings.Contains(body, `"file_token":"box_a"`) ||
|
|
!strings.Contains(body, `"file_token":"box_b"`) {
|
|
t.Fatalf("remove body=%s", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
|
|
t.Run("list", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "limit=1&offset=0",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"views": []interface{}{map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"}}, "total": 3},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewList, []string{"+view-list", "--base-token", "app_x", "--table-id", "tbl_x", "--offset", "0", "--limit", "1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"total": 3`) || !strings.Contains(got, `"views"`) || !strings.Contains(got, `"name": "Main"`) || strings.Contains(got, `"items"`) || strings.Contains(got, `"offset"`) || strings.Contains(got, `"limit"`) || strings.Contains(got, `"count"`) || strings.Contains(got, `"view_name": "Main"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewGet, []string{"+view-get", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"view"`) || !strings.Contains(got, `"vew_1"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("create", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "vew_1", "name": "Main", "type": "grid"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewCreate, []string{"+view-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Main","type":"grid"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"views"`) || !strings.Contains(got, `"vew_1"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("delete", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1",
|
|
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}},
|
|
})
|
|
if err := runShortcut(t, BaseViewDelete, []string{"+view-delete", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--yes"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"view_id": "vew_1"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("set-filter", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/filter",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewSetFilter, []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"conditions":[{"field_name":"Status"}]}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get-visible-fields", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": []interface{}{"fld_primary", "fld_status"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseViewGetVisibleFields, []string{"+view-get-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"visible_fields"`) || !strings.Contains(got, `"fld_primary"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("set-visible-fields-array-invalid", func(t *testing.T) {
|
|
factory, stdout, _ := newExecuteFactory(t)
|
|
err := runShortcut(
|
|
t,
|
|
BaseViewSetVisibleFields,
|
|
[]string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `["fld_status"]`},
|
|
factory,
|
|
stdout,
|
|
)
|
|
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("set-visible-fields-object", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
updateStub := &httpmock.Stub{
|
|
Method: "PUT",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_1/visible_fields",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": []interface{}{"fld_primary", "fld_status"},
|
|
},
|
|
}
|
|
reg.Register(updateStub)
|
|
if err := runShortcut(t, BaseViewSetVisibleFields, []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_1", "--json", `{"visible_fields":["fld_status"]}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
body := string(updateStub.CapturedBody)
|
|
if !strings.Contains(body, `"visible_fields":["fld_status"]`) {
|
|
t.Fatalf("request body=%s", body)
|
|
}
|
|
if strings.Contains(body, `{"visible_fields":{"visible_fields":`) {
|
|
t.Fatalf("request body double wrapped: %s", body)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBaseTableExecuteListFallbackShapes(t *testing.T) {
|
|
t.Run("items-payload", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"id": "tbl_items", "name": "ItemsOnly"}}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"ItemsOnly"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("single-object-payload", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"id": "tbl_single", "name": "SingleOnly"},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseTableList, []string{"+table-list", "--base-token", "app_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"SingleOnly"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "view_id=vew_x",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"records": map[string]interface{}{
|
|
"schema": []interface{}{"Name", "Index"},
|
|
"record_ids": []interface{}{"rec_last"},
|
|
"rows": []interface{}{[]interface{}{"Tail", 200}},
|
|
}, "total": 201},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseHistoryExecuteWithLinkFieldLimit(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "max_version=2",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"items": []interface{}{map[string]interface{}{"record_id": "rec_x", "field_name": "History"}}},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseRecordHistoryList, []string{"+record-history-list", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_x", "--page-size", "10", "--max-version", "2"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"field_name": "History"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseFieldExecuteSearchOptions(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_amount/options",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"options": []interface{}{map[string]interface{}{"id": "opt_1", "name": "已完成"}}, "total": 1},
|
|
},
|
|
})
|
|
if err := runShortcut(t, BaseFieldSearchOptions, []string{"+field-search-options", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_amount", "--keyword", "已", "--limit", "10"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"options"`) || !strings.Contains(got, `"已完成"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseViewExecutePropertyGettersAndExtendedSetters(t *testing.T) {
|
|
t.Run("get-group", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/group", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}}}})
|
|
if err := runShortcut(t, BaseViewGetGroup, []string{"+view-get-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get-filter", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/filter", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"conditions": []interface{}{map[string]interface{}{"field_name": "Status"}}}}})
|
|
if err := runShortcut(t, BaseViewGetFilter, []string{"+view-get-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"filter"`) || !strings.Contains(got, `"Status"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get-sort", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_x/sort", Body: map[string]interface{}{"code": 0, "data": []interface{}{map[string]interface{}{"field": "fld_priority", "desc": true}}}})
|
|
if err := runShortcut(t, BaseViewGetSort, []string{"+view-get-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_priority"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get-timebar", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}})
|
|
if err := runShortcut(t, BaseViewGetTimebar, []string{"+view-get-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_start"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("set-timebar", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_time/timebar", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"start_time": "fld_start", "end_time": "fld_end", "title": "fld_title"}}})
|
|
args := []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_time", "--json", `{"start_time":"fld_start","end_time":"fld_end","title":"fld_title"}`}
|
|
if err := runShortcut(t, BaseViewSetTimebar, args, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"timebar"`) || !strings.Contains(got, `"fld_end"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("get-card", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "GET", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}})
|
|
if err := runShortcut(t, BaseViewGetCard, []string{"+view-get-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card"}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("set-card", func(t *testing.T) {
|
|
factory, stdout, reg := newExecuteFactory(t)
|
|
reg.Register(&httpmock.Stub{Method: "PUT", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/views/vew_card/card", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"cover_field": "fld_cover"}}})
|
|
if err := runShortcut(t, BaseViewSetCard, []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_card", "--json", `{"cover_field":"fld_cover"}`}, factory, stdout); err != nil {
|
|
t.Fatalf("err=%v", err)
|
|
}
|
|
if got := stdout.String(); !strings.Contains(got, `"card"`) || !strings.Contains(got, `"fld_cover"`) {
|
|
t.Fatalf("stdout=%s", got)
|
|
}
|
|
})
|
|
}
|