mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
d289566ad0
* 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>
236 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|