Files
larksuite-cli/shortcuts/base/base_execute_test.go
evandance fe72e41fb2 feat(errs): add structured CLI error contract (#984)
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.

Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
  embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift

Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.

Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.

At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.

First PR in the feat/error-contract-* series.
2026-05-26 11:42:33 +08:00

2634 lines
97 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/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"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 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 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), "full_access (可管理权限)") {
t.Fatalf("permission_grant.message = %q, want permission hint", grant["message"])
}
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)
}
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
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)
}
if got := stdout.String(); !strings.Contains(got, "grant the current CLI user full_access (可管理权限)") {
t.Fatalf("stdout=%s", got)
}
})
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 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)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
t.Fatalf("stdout=%s", got)
}
}
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
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(), "lark-base skill") {
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)
reg.Register(&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"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_primary", "name": "Primary"}}},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields/fld_primary",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"},
},
})
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)
}
}
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 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)
}
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
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/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)
}
})
}
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,
Body: "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 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 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"],"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, `"offset":0`) ||
!strings.Contains(body, `"limit":2`) {
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 legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
t.Fatalf("err=%v", err)
}
})
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{}{
"fields": []interface{}{"Name"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{[]interface{}{"Alice"}, []interface{}{"Bob"}},
},
},
})
if err := runShortcut(t, BaseRecordBatchCreate, []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"fields":["Name"],"rows":[["Alice"],["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"`) || !strings.Contains(got, `"Alice"`) {
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{}{
"has_more": false,
"record_id_list": []interface{}{"rec_1"},
"update": map[string]interface{}{"Status": "Done"},
},
},
})
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
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{}{
"record_id_list": []interface{}{"rec_1"},
},
},
}
reg.Register(updateStub)
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, 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)
}
body := string(updateStub.CapturedBody)
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
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)
}
})
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 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 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: 500,
RawBody: []byte("server error"),
})
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 || !strings.Contains(err.Error(), "download failed after 1 attachment(s) succeeded and 1 failed") {
t.Fatalf("err=%v", err)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured error, got %T %v", err, err)
}
detail, _ := exitErr.Detail.Detail.(map[string]interface{})
downloaded, _ := detail["downloaded"].([]map[string]interface{})
failed, _ := detail["failed"].([]map[string]interface{})
if len(downloaded) != 1 || downloaded[0]["file_token"] != "box_a" || len(failed) != 1 || failed[0]["file_token"] != "box_b" {
t.Fatalf("detail=%#v", exitErr.Detail.Detail)
}
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)
}
})
}