Files
larksuite__cli/tests/cli_e2e/base/helpers_test.go
CarolSum d289566ad0 feat(base): require --fields on +table-create (#2221)
* feat(base): require --fields on +table-create

A table created without --fields gets the platform default schema. Those
default fields then sit in the table alongside every field the caller adds
afterwards, and no field command removes them all, so the only clean recovery
is to drop the table and start over.

Make --fields required so the schema is declared up front, the way
+base-create already recommends via --table-name + --fields.

- Mark --fields Required on +table-create, and reject blank / non-array /
  empty-array values in Validate: cobra's MarkFlagRequired only checks that the
  flag was set, so --fields "" and --fields "[]" would still reach the API with
  no fields body and fall back to the default schema.
- Validate runs ahead of the dry-run branch, so --dry-run can no longer preview
  an invocation the real call would reject.
- Update the lark-base skill, e2e coverage notes and the live e2e helper.

BREAKING CHANGE: `lark-cli base +table-create --base-token <t> --name <n>`
without --fields now fails with a validation error instead of creating a
default-schema table. Callers that relied on create-empty-then-add-fields must
pass the schema to --fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(base): pin typed metadata on the invalid-schema rejection

Address review feedback on the +table-create schema validation.

- The invalid-fields-JSON rejection now asserts category, subtype and param
  through assertInvalidArgumentValidation, plus the preserved *json.SyntaxError
  cause, instead of only asserting that some error came back.
- Document why the missing-flag test asserts cobra's text rather than errs
  metadata, and pin that layer boundary: cobra's ValidateRequiredFlags emits a
  plain error and the dispatcher types it later (cmd/root_test.go). The test now
  fails if that boundary moves, so the weaker assertion cannot silently outlive
  its reason.
- Reword the --fields tip and the lark-base skill note: both described the
  fieldless path as if it were still reachable through +table-create. They now
  say the command rejects omitted / blank / empty schemas up front, while
  keeping why the schema must be declared here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(base): enrich +table-create field example and drop redundant tips

The cobra Required declaration and flag Desc already advertise the
--fields requirement, so the tips paragraph restating it (and its
SKILL.md / coverage.md echoes) is dropped. The select-field example
now carries multiple/hue/lightness so agents copy a complete option
shape.

* chore: remove useless example

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:56:41 +08:00

236 lines
6.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strconv"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const cleanupTimeout = 30 * time.Second
func runBaseDryRun(t *testing.T, wantExitCode int, args ...string) *clie2e.Result {
t.Helper()
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
requestArgs := append([]string(nil), args...)
requestArgs = append(requestArgs, "--dry-run")
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: requestArgs, DefaultAs: "user"})
require.NoError(t, err)
result.AssertExitCode(t, wantExitCode)
return result
}
func reportCleanupFailure(parentT *testing.T, prefix string, result *clie2e.Result, err error) {
parentT.Helper()
if err != nil {
parentT.Errorf("%s: %v", prefix, err)
return
}
if result == nil {
parentT.Errorf("%s: nil result", prefix)
return
}
if isCleanupSuppressedResult(result) {
return
}
parentT.Errorf("%s failed: exit=%d stdout=%s stderr=%s", prefix, result.ExitCode, result.Stdout, result.Stderr)
}
func cleanupContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), cleanupTimeout)
}
func isCleanupSuppressedResult(result *clie2e.Result) bool {
if result == nil {
return false
}
raw := strings.TrimSpace(result.Stdout)
if raw == "" {
raw = strings.TrimSpace(result.Stderr)
}
if raw == "" {
return false
}
start := strings.LastIndex(raw, "\n{")
if start >= 0 {
start++
} else {
start = strings.Index(raw, "{")
}
if start < 0 {
return false
}
payload := raw[start:]
if !gjson.Valid(payload) {
return false
}
if gjson.Get(payload, "error.type").String() != "api_error" {
return false
}
if gjson.Get(payload, "error.detail.type").String() == "not_found" ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), "not found") {
return true
}
return gjson.Get(payload, "error.code").Int() == 800004135 ||
strings.Contains(strings.ToLower(gjson.Get(payload, "error.message").String()), " limited")
}
func createBaseWithRetry(t *testing.T, ctx context.Context, name string) string {
t.Helper()
const defaultAs = "bot"
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+base-create", "--name", name, "--time-zone", "Asia/Shanghai"},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
baseToken := gjson.Get(result.Stdout, "data.base.app_token").String()
if baseToken == "" {
baseToken = gjson.Get(result.Stdout, "data.base.base_token").String()
}
require.NotEmpty(t, baseToken, "stdout:\n%s", result.Stdout)
t.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := drive.DeleteDriveResourceAndVerify(cleanupCtx, baseToken, "bitable", defaultAs)
clie2e.ReportCleanupFailure(t, "delete base "+baseToken, deleteResult, deleteErr)
})
return baseToken
}
func createTableWithRetry(t *testing.T, parentT *testing.T, ctx context.Context, baseToken string, name string, fieldsJSON string, viewJSON string) (tableID string, primaryFieldID string, primaryViewID string) {
t.Helper()
// +table-create requires --fields; an empty schema here would fail at the
// CLI surface with a validation error rather than exercising the API.
require.NotEmpty(t, fieldsJSON, "createTableWithRetry requires a field schema")
args := []string{"base", "+table-create", "--base-token", baseToken, "--name", name, "--fields", fieldsJSON}
if viewJSON != "" {
args = append(args, "--view", viewJSON)
}
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
tableID = gjson.Get(result.Stdout, "data.table.id").String()
if tableID == "" {
tableID = gjson.Get(result.Stdout, "data.table.table_id").String()
}
require.NotEmpty(t, tableID, "stdout:\n%s", result.Stdout)
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.id").String()
if primaryFieldID == "" {
primaryFieldID = gjson.Get(result.Stdout, "data.fields.0.field_id").String()
}
primaryViewID = gjson.Get(result.Stdout, "data.views.0.id").String()
if primaryViewID == "" {
primaryViewID = gjson.Get(result.Stdout, "data.views.0.view_id").String()
}
parentT.Cleanup(func() {
cleanupCtx, cancel := cleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"},
DefaultAs: "bot",
})
if deleteErr != nil || deleteResult.ExitCode != 0 {
reportCleanupFailure(parentT, "delete table "+tableID, deleteResult, deleteErr)
}
})
return tableID, primaryFieldID, primaryViewID
}
func createRole(t *testing.T, ctx context.Context, baseToken string, body string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"base", "+role-create", "--base-token", baseToken, "--json", body},
DefaultAs: "bot",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return gjson.Get(result.Stdout, "data.role_id").String()
}
func findBaseTableByID(t *testing.T, ctx context.Context, baseToken string, tableID string) gjson.Result {
t.Helper()
require.NotEmpty(t, baseToken, "base token is required")
require.NotEmpty(t, tableID, "table ID is required")
const pageLimit = 50
offset := 0
seenOffsets := map[int]struct{}{}
for {
if _, seen := seenOffsets[offset]; seen {
t.Fatalf("base table list pagination loop detected for base %q, repeated offset %d", baseToken, offset)
}
seenOffsets[offset] = struct{}{}
args := []string{
"base", "+table-list",
"--base-token", baseToken,
"--limit", strconv.Itoa(pageLimit),
"--offset", strconv.Itoa(offset),
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: args,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
table := gjson.Get(result.Stdout, `data.tables.#(id=="`+tableID+`")`)
if table.Exists() {
return table
}
tables := gjson.Get(result.Stdout, "data.tables").Array()
if len(tables) == 0 || len(tables) < pageLimit {
t.Fatalf("table %q not found in listed pages, last stdout:\n%s", tableID, result.Stdout)
}
offset += len(tables)
}
}