diff --git a/internal/errclass/codemeta_base.go b/internal/errclass/codemeta_base.go new file mode 100644 index 0000000000..207cec18f8 --- /dev/null +++ b/internal/errclass/codemeta_base.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import "github.com/larksuite/cli/errs" + +var baseCodeMeta = map[int]CodeMeta{ + // Copy Table domain errors (technical design chapter 18.2). + 800020304: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, + 800010102: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, + 800080105: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, + 800040819: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict}, + 800070003: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}, + 800100112: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown}, + 800100113: {Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown}, + 800040114: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, + 800070115: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}, + 800010109: {Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument}, + 800030110: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, + 800070111: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}, + + // Shared RPC errors used by Copy Table (technical design chapter 18.3). + 800040802: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, + 800040803: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, + 800020812: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, + 800040832: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, + 800040817: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, + 800080821: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied}, + 800070831: {Category: errs.CategoryAPI, Subtype: errs.SubtypeUnknown}, +} + +func init() { + mergeCodeMeta(baseCodeMeta, "base") +} diff --git a/internal/errclass/codemeta_base_test.go b/internal/errclass/codemeta_base_test.go new file mode 100644 index 0000000000..c6ba2181b3 --- /dev/null +++ b/internal/errclass/codemeta_base_test.go @@ -0,0 +1,51 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package errclass + +import ( + "fmt" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestLookupCodeMetaBaseTableCopyCodes(t *testing.T) { + tests := []struct { + code int + category errs.Category + subtype errs.Subtype + retryable bool + }{ + // Copy Table domain errors documented in chapter 18.2. + {code: 800020304, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied}, + {code: 800010102, category: errs.CategoryValidation, subtype: errs.SubtypeFailedPrecondition}, + {code: 800080105, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded}, + {code: 800040819, category: errs.CategoryAPI, subtype: errs.SubtypeConflict}, + {code: 800070003, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown}, + {code: 800100112, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown}, + {code: 800100113, category: errs.CategoryInternal, subtype: errs.SubtypeUnknown}, + {code: 800040114, category: errs.CategoryAPI, subtype: errs.SubtypeConflict, retryable: true}, + {code: 800070115, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown}, + {code: 800010109, category: errs.CategoryValidation, subtype: errs.SubtypeInvalidArgument}, + {code: 800030110, category: errs.CategoryAPI, subtype: errs.SubtypeNotFound}, + {code: 800070111, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown}, + + // Shared RPC errors used by Copy Table, documented in chapter 18.3. + {code: 800040802, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded}, + {code: 800040803, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded}, + {code: 800020812, category: errs.CategoryAuthorization, subtype: errs.SubtypePermissionDenied}, + {code: 800040832, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded}, + {code: 800040817, category: errs.CategoryAPI, subtype: errs.SubtypeQuotaExceeded}, + {code: 800080821, category: errs.CategoryPolicy, subtype: errs.SubtypeAccessDenied}, + {code: 800070831, category: errs.CategoryAPI, subtype: errs.SubtypeUnknown}, + } + for _, test := range tests { + t.Run(fmt.Sprint(test.code), func(t *testing.T) { + meta, ok := LookupCodeMeta(test.code) + if !ok || meta.Category != test.category || meta.Subtype != test.subtype || meta.Retryable != test.retryable { + t.Fatalf("LookupCodeMeta(%d) = %#v, %v", test.code, meta, ok) + } + }) + } +} diff --git a/internal/httpmock/registry.go b/internal/httpmock/registry.go index 156aef299a..a2f2d0c130 100644 --- a/internal/httpmock/registry.go +++ b/internal/httpmock/registry.go @@ -23,6 +23,7 @@ type Stub struct { RawBody []byte // raw bytes (takes precedence over Body when non-nil) ContentType string // override Content-Type header (default: application/json) Headers http.Header // optional full response headers (takes precedence over ContentType) + Error error // optional transport error returned after OnMatch matched bool // BodyFilter (optional): match only when the captured request body satisfies @@ -93,6 +94,9 @@ func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) { if matched.OnMatch != nil { matched.OnMatch(req) } + if matched.Error != nil { + return nil, matched.Error + } resp, err := stubResponse(matched) if err != nil { return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err) diff --git a/internal/httpmock/registry_test.go b/internal/httpmock/registry_test.go index ed8b90996c..4950765915 100644 --- a/internal/httpmock/registry_test.go +++ b/internal/httpmock/registry_test.go @@ -4,6 +4,7 @@ package httpmock import ( + "errors" "io" "net/http" "testing" @@ -112,3 +113,21 @@ func TestRegistry_CustomStatus(t *testing.T) { t.Errorf("want status 500, got %d", resp.StatusCode) } } + +func TestRegistry_TransportError(t *testing.T) { + wantErr := errors.New("connection reset") + reg := &Registry{} + reg.Register(&Stub{ + Method: "POST", + URL: "/transport-error", + Error: wantErr, + }) + + client := NewClient(reg) + req, _ := http.NewRequest("POST", "https://example.com/transport-error", nil) + _, err := client.Do(req) + if !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want transport error %v", err, wantErr) + } + reg.Verify(t) +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index bc3e9441b1..2ef4e22e20 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -161,7 +161,7 @@ func TestShortcutsCatalog(t *testing.T) { want := []string{ "+url-resolve", "+title-resolve", "+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete", - "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", + "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+table-copy", "+table-copy-status", "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", "+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename", "+record-list", "+record-search", "+record-get", "+record-upsert", "+record-batch-create", "+record-batch-update", "+record-share-link-create", "+record-upload-attachment", "+record-download-attachment", "+record-remove-attachment", "+record-delete", diff --git a/shortcuts/base/helpers.go b/shortcuts/base/helpers.go index e95f775609..f726891b4b 100644 --- a/shortcuts/base/helpers.go +++ b/shortcuts/base/helpers.go @@ -5,6 +5,7 @@ package base import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -386,6 +387,10 @@ func baseV3Path(parts ...string) string { } func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + return baseV3RawContext(runtime.Ctx(), runtime, method, path, params, data) +} + +func baseV3RawContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { queryParams := make(larkcore.QueryParams) for k, v := range params { switch val := v.(type) { @@ -409,7 +414,7 @@ func baseV3Raw(runtime *common.RuntimeContext, method, path string, params map[s } h := make(http.Header) h.Set("X-App-Id", runtime.Config.AppID) - resp, err := runtime.DoAPI(req, larkcore.WithHeaders(h)) + resp, err := runtime.DoAPIWithContext(ctx, req, larkcore.WithHeaders(h)) if err != nil { return nil, baseAPIBoundaryError(err, "API call failed") } @@ -504,6 +509,11 @@ func baseV3Call(runtime *common.RuntimeContext, method, path string, params map[ return handleBaseAPIResult(result, err, "API call failed") } +func baseV3CallContext(ctx context.Context, runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (map[string]interface{}, error) { + result, err := baseV3RawContext(ctx, runtime, method, path, params, data) + return handleBaseAPIResult(result, err, "API call failed") +} + func baseV3CallAny(runtime *common.RuntimeContext, method, path string, params map[string]interface{}, data interface{}) (interface{}, error) { result, err := baseV3Raw(runtime, method, path, params, data) return handleBaseAPIResultAny(result, err, "API call failed") diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 61b7a8aa22..3c9a7403c7 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -20,6 +20,8 @@ func Shortcuts() []common.Shortcut { BaseTableCreate, BaseTableUpdate, BaseTableDelete, + BaseTableCopy, + BaseTableCopyStatus, BaseFieldList, BaseFieldGet, BaseFieldCreate, diff --git a/shortcuts/base/table_copy.go b/shortcuts/base/table_copy.go new file mode 100644 index 0000000000..58b77a5e62 --- /dev/null +++ b/shortcuts/base/table_copy.go @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +const ( + tableCopyRangeSchema = "schema" + tableCopyRangeAll = "all" + tableCopyScope = "base:table:create" + tableCopyTimeoutMax = 30 * time.Minute + tableCopyTaskIDMax = 1024 +) + +var BaseTableCopy = common.Shortcut{ + Service: "base", + Command: "+table-copy", + Description: "Copy a table by ID or name; structure only by default", + Risk: "write", + Scopes: []string{tableCopyScope}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + tableRefFlag(true), + {Name: "name", Desc: "target table name", Required: true}, + {Name: "range", Default: tableCopyRangeSchema, Desc: "copy range; defaults to schema, use all only to include records", Enum: []string{tableCopyRangeSchema, tableCopyRangeAll}}, + {Name: "wait", Type: "bool", Desc: "wait for an all-range copy task to finish"}, + }, + Tips: []string{ + `Example: lark-cli base +table-copy --base-token --table-id "Tasks" --name "Tasks copy"`, + "table-id accepts a table ID or name in the current Base.", + "The default copies schema only; use --range all only when records must also be copied.", + "Use --wait with --range all to wait locally; otherwise continue with the returned next_command.", + }, + DryRun: dryRunTableCopy, + PostMount: func(cmd *cobra.Command) { + cmd.Flags().Duration("timeout", 5*time.Minute, "maximum time to wait for an asynchronous copy task (max 30m)") + }, + Validate: validateTableCopy, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopy(ctx, runtime) + }, +} + +var BaseTableCopyStatus = common.Shortcut{ + Service: "base", + Command: "+table-copy-status", + Description: "Get one table copy task status", + Risk: "read", + Scopes: []string{tableCopyScope}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "task-id", Desc: "opaque table copy task ID", Required: true}, + }, + Tips: []string{ + "Use the opaque task_id returned by base +table-copy; this command queries status once.", + "If state is init or process, run the returned next_command later.", + }, + DryRun: dryRunTableCopyStatus, + Validate: func(_ context.Context, runtime *common.RuntimeContext) error { + taskID := runtime.Str("task-id") + if strings.TrimSpace(taskID) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id cannot be blank").WithParam("--task-id") + } + if len(taskID) > tableCopyTaskIDMax { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id must not exceed %d bytes", tableCopyTaskIDMax).WithParam("--task-id") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyStatus(ctx, runtime) + }, +} + +func validateTableCopy(_ context.Context, runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("table-id")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table-id cannot be blank").WithParam("--table-id") + } + if strings.TrimSpace(runtime.Str("name")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name cannot be blank").WithParam("--name") + } + + rangeValue := runtime.Str("range") + wait := runtime.Bool("wait") + timeoutChanged := runtime.Changed("timeout") + if rangeValue == tableCopyRangeSchema { + if wait { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--wait requires --range all").WithParam("--wait") + } + if timeoutChanged { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --range all and --wait").WithParam("--timeout") + } + } + if timeoutChanged && !wait { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout requires --wait").WithParam("--timeout") + } + timeout, err := tableCopyTimeout(runtime) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", err).WithParam("--timeout").WithCause(err) + } + if wait && (timeout <= 0 || timeout > tableCopyTimeoutMax) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--timeout must be greater than 0 and at most 30m").WithParam("--timeout") + } + return nil +} + +func tableCopyTimeout(runtime *common.RuntimeContext) (time.Duration, error) { + return runtime.Cmd.Flags().GetDuration("timeout") +} diff --git a/shortcuts/base/table_copy_ops.go b/shortcuts/base/table_copy_ops.go new file mode 100644 index 0000000000..a73d0b3359 --- /dev/null +++ b/shortcuts/base/table_copy_ops.go @@ -0,0 +1,399 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + tableCopyStateInit = "init" + tableCopyStateProcess = "process" + tableCopyStateSuccess = "success" +) + +type tableCopyTable struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` +} + +type tableCopySubmitResult struct { + Table tableCopyTable + TaskID string + State string +} + +type tableCopyStatus struct { + TableID string + State string +} + +type tableCopyOutput struct { + Table tableCopyTable `json:"table"` + Range string `json:"range,omitempty"` + State string `json:"state"` + Completed bool `json:"completed"` + TaskID string `json:"task_id,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` + NextAction string `json:"next_action,omitempty"` + NextCommand string `json:"next_command,omitempty"` +} + +func dryRunTableCopy(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + baseToken := runtime.Str("base-token") + rangeValue := runtime.Str("range") + dry := common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/copy"). + Desc("[1] Submit table copy"). + Body(map[string]interface{}{ + "name": runtime.Str("name"), + "range": rangeValue, + }). + Set("base_token", baseToken). + Set("table_id", runtime.Str("table-id")) + if runtime.Bool("wait") { + dry.POST("/open-apis/base/v3/bases/:base_token/copy_table_state"). + Desc("[2] Poll with 3s exponential backoff, capped at 30s"). + Body(map[string]interface{}{"task_id": ""}) + timeout, _ := tableCopyTimeout(runtime) + dry.Set("wait", true).Set("timeout", timeout.String()) + } else { + dry.Set("wait", false) + } + return dry +} + +func dryRunTableCopyStatus(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/copy_table_state"). + Body(map[string]interface{}{"task_id": runtime.Str("task-id")}). + Set("base_token", runtime.Str("base-token")) +} + +func executeTableCopy(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, realTableCopyClock{}) +} + +func executeTableCopyWithClock(ctx context.Context, runtime *common.RuntimeContext, clock tableCopyClock) error { + rangeValue := runtime.Str("range") + submit, err := submitTableCopy(runtime, rangeValue) + if err != nil { + return tableCopySubmissionError(err) + } + if rangeValue == tableCopyRangeSchema { + if submit.State != tableCopyStateSuccess { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "schema table copy returned non-success state %q", submit.State) + } + runtime.Out(tableCopyOutput{ + Table: submit.Table, + Range: rangeValue, + State: submit.State, + Completed: true, + }, nil) + tableCopyProgressf(runtime, "Table copy completed: success") + return nil + } + if submit.State == tableCopyStateSuccess { + runtime.Out(tableCopyOutput{ + Table: submit.Table, + Range: rangeValue, + State: submit.State, + Completed: true, + TaskID: submit.TaskID, + }, nil) + tableCopyProgressf(runtime, "Table copy completed: success") + return nil + } + if submit.TaskID == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "all-range table copy response missing task_id") + } + if runtime.Bool("wait") { + tableCopyProgressf(runtime, "Table copy submitted: %s, task_id=%s", submit.State, submit.TaskID) + timeout, timeoutErr := tableCopyTimeout(runtime) + if timeoutErr != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --timeout: %v", timeoutErr).WithParam("--timeout").WithCause(timeoutErr) + } + stopSpinner := runtime.StartSpinner("Waiting for table copy") + status, timedOut, pollErr := pollTableCopy(ctx, timeout, clock, func(ctx context.Context) (tableCopyStatus, error) { + status, err := queryTableCopyStatus(ctx, runtime, runtime.Str("base-token"), submit.TaskID) + if err != nil { + if problem, ok := errs.ProblemOf(err); ok { + tableCopyProgressf(runtime, "Table copy status query error: %s/%s", problem.Category, problem.Subtype) + } else { + tableCopyProgressf(runtime, "Table copy status query error") + } + return tableCopyStatus{}, err + } + tableCopyProgressf(runtime, "Table copy status: %s", status.State) + return status, nil + }) + stopSpinner() + if pollErr != nil { + recoveryState := status.State + if recoveryState == "" { + recoveryState = submit.State + } + recovery := tableCopyOutput{ + Table: submit.Table, + Range: rangeValue, + State: recoveryState, + Completed: false, + TaskID: submit.TaskID, + } + if tableCopyWaitCanContinue(pollErr) { + recovery.NextAction = "poll_status" + recovery.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID) + } + recoveryErr := runtime.OutPartialFailure(recovery, nil) + var partialFailure *output.PartialFailureError + if !errors.As(recoveryErr, &partialFailure) { + return recoveryErr + } + return tableCopyWaitError(pollErr) + } + if timedOut && status.State == "" { + // No status query completed before the deadline. The submit response + // is still the last known task state, so preserve it. + status.State = submit.State + } + out := tableCopyOutput{ + Table: submit.Table, + Range: rangeValue, + State: status.State, + Completed: status.State == tableCopyStateSuccess, + TaskID: submit.TaskID, + TimedOut: timedOut, + } + if !out.Completed { + out.NextAction = "poll_status" + out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID) + tableCopyProgressf(runtime, "Table copy is not complete; use next_command from stdout to continue") + } + runtime.Out(out, nil) + return nil + } + out := tableCopyOutput{ + Table: submit.Table, + Range: rangeValue, + State: submit.State, + Completed: submit.State == tableCopyStateSuccess, + TaskID: submit.TaskID, + } + if !out.Completed { + out.NextAction = "poll_status" + out.NextCommand = tableCopyNextCommand(runtime, runtime.Str("base-token"), submit.TaskID) + tableCopyProgressf(runtime, "Table copy is running asynchronously; use next_command from stdout to continue") + } + runtime.Out(out, nil) + return nil +} + +func tableCopySubmissionError(err error) error { + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork { + return err + } + if problem.Subtype != errs.SubtypeNetworkTimeout && problem.Subtype != errs.SubtypeNetworkTransport { + return err + } + problem.Message = "table copy submission outcome is unknown because the response was not received" + problem.Hint = "Do not retry the copy automatically. Manually confirm whether the target table was created before deciding the next action." + problem.Retryable = false + return err +} + +func tableCopyWaitCanContinue(err error) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + if problem, ok := errs.ProblemOf(err); ok { + switch problem.Category { + case errs.CategoryAuthentication, errs.CategoryAuthorization: + return true + } + } + return tableCopyPollErrorRetryable(err) +} + +func tableCopyWaitError(err error) error { + if !tableCopyWaitCanContinue(err) { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithCause(err) + } + + hint := "The copy task was already submitted; do not submit it again. Read task_id from the submit output and continue with lark-cli base +table-copy-status using the same identity." + if errors.Is(err, context.Canceled) { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "table copy status polling was canceled").WithHint("%s", hint).WithCause(err) + } + if errors.Is(err, context.DeadlineExceeded) { + return errs.NewNetworkError(errs.SubtypeNetworkTimeout, "table copy status polling timed out").WithHint("%s", hint).WithCause(err) + } + if problem, ok := errs.ProblemOf(err); ok { + if problem.Hint == "" { + problem.Hint = hint + } else { + problem.Hint += " " + hint + } + return err + } + return errs.NewInternalError(errs.SubtypeUnknown, "table copy status polling failed: %v", err).WithHint("%s", hint).WithCause(err) +} + +func executeTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext) error { + baseToken := runtime.Str("base-token") + taskID := runtime.Str("task-id") + status, err := queryTableCopyStatus(ctx, runtime, baseToken, taskID) + if err != nil { + return err + } + out := tableCopyOutput{ + Table: tableCopyTable{ID: status.TableID}, + State: status.State, + Completed: status.State == tableCopyStateSuccess, + TaskID: taskID, + } + if !out.Completed { + out.NextAction = "poll_status" + out.NextCommand = tableCopyNextCommand(runtime, baseToken, taskID) + } + runtime.Out(out, nil) + tableCopyProgressf(runtime, "Table copy status: %s", status.State) + return nil +} + +func submitTableCopy(runtime *common.RuntimeContext, rangeValue string) (tableCopySubmitResult, error) { + baseToken := runtime.Str("base-token") + tableRef := runtime.Str("table-id") + body := map[string]interface{}{ + "name": runtime.Str("name"), + "range": rangeValue, + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableRef, "copy"), nil, body) + if err != nil { + return tableCopySubmitResult{}, err + } + return projectTableCopySubmit(data) +} + +func projectTableCopySubmit(data map[string]interface{}) (tableCopySubmitResult, error) { + tableData := common.GetMap(data, "table") + result := tableCopySubmitResult{ + Table: tableCopyTable{ + ID: strings.TrimSpace(common.GetString(tableData, "id")), + Name: common.GetString(tableData, "name"), + }, + TaskID: strings.TrimSpace(common.GetString(data, "task_id")), + State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))), + } + if result.Table.ID == "" { + return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response missing table.id") + } + if len(result.TaskID) > tableCopyTaskIDMax { + return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response task_id exceeds %d bytes", tableCopyTaskIDMax) + } + switch result.State { + case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess: + return result, nil + default: + return tableCopySubmitResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy response has invalid state %q", result.State) + } +} + +func queryTableCopyStatus(ctx context.Context, runtime *common.RuntimeContext, baseToken, taskID string) (tableCopyStatus, error) { + data, err := baseV3CallContext( + ctx, + runtime, + "POST", + baseV3Path("bases", baseToken, "copy_table_state"), + nil, + map[string]interface{}{"task_id": taskID}, + ) + if err != nil { + return tableCopyStatus{}, tableCopyStatusError(err) + } + return projectTableCopyStatus(data) +} + +func tableCopyStatusError(err error) error { + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != 800010109 { + return err + } + var validationErr *errs.ValidationError + if errors.As(err, &validationErr) { + validationErr.WithParam("--task-id") + return err + } + classified := errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", problem.Message). + WithParam("--task-id"). + WithCode(problem.Code). + WithCause(err) + if problem.Hint != "" { + classified.WithHint("%s", problem.Hint) + } + if problem.LogID != "" { + classified.WithLogID(problem.LogID) + } + return classified +} + +func projectTableCopyStatus(data map[string]interface{}) (tableCopyStatus, error) { + status := tableCopyStatus{ + TableID: strings.TrimSpace(common.GetString(data, "table_id")), + State: strings.ToLower(strings.TrimSpace(common.GetString(data, "state"))), + } + if status.TableID == "" { + return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response missing table_id") + } + switch status.State { + case tableCopyStateInit, tableCopyStateProcess, tableCopyStateSuccess: + return status, nil + case "failed": + return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status returned state=failed in a success envelope; the API must return task failures through the top-level error protocol") + default: + return tableCopyStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status response has invalid state %q", status.State) + } +} + +func tableCopyNextCommand(runtime *common.RuntimeContext, baseToken, taskID string) string { + parts := []string{"lark-cli"} + if runtime.Cmd.Flags().Lookup("profile") != nil && runtime.Changed("profile") { + profile, _ := runtime.Cmd.Flags().GetString("profile") + if strings.TrimSpace(profile) != "" { + parts = append(parts, "--profile", tableCopyShellArg(profile)) + } + } + parts = append(parts, + "base", "+table-copy-status", + "--base-token", tableCopyShellArg(baseToken), + "--task-id", tableCopyShellArg(taskID), + "--as", string(runtime.As()), + ) + return strings.Join(parts, " ") +} + +func tableCopyShellArg(value string) string { + if value != "" && strings.IndexFunc(value, func(r rune) bool { + return !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && !strings.ContainsRune("._~-", r) + }) == -1 { + return value + } + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func tableCopyProgressf(runtime *common.RuntimeContext, format string, args ...interface{}) { + if runtime == nil || runtime.IO() == nil || runtime.IO().ErrOut == nil { + return + } + fmt.Fprintf(runtime.IO().ErrOut, format+"\n", args...) +} diff --git a/shortcuts/base/table_copy_poll.go b/shortcuts/base/table_copy_poll.go new file mode 100644 index 0000000000..1e43d2a5a5 --- /dev/null +++ b/shortcuts/base/table_copy_poll.go @@ -0,0 +1,139 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "time" + + "github.com/larksuite/cli/errs" +) + +const ( + tableCopyPollInitial = 3 * time.Second + tableCopyPollMax = 30 * time.Second +) + +type tableCopyTimer interface { + C() <-chan time.Time + Stop() bool +} + +type tableCopyClock interface { + Now() time.Time + NewTimer(time.Duration) tableCopyTimer +} + +type tableCopyStatusFetcher func(context.Context) (tableCopyStatus, error) + +type realTableCopyClock struct{} + +func (realTableCopyClock) Now() time.Time { return time.Now() } + +func (realTableCopyClock) NewTimer(duration time.Duration) tableCopyTimer { + return realTableCopyTimer{Timer: time.NewTimer(duration)} +} + +type realTableCopyTimer struct { + *time.Timer +} + +func (t realTableCopyTimer) C() <-chan time.Time { return t.Timer.C } + +func pollTableCopy( + ctx context.Context, + timeout time.Duration, + clock tableCopyClock, + fetch tableCopyStatusFetcher, +) (tableCopyStatus, bool, error) { + deadline := clock.Now().Add(timeout) + delay := tableCopyPollInitial + var lastStatus tableCopyStatus + var lastErr error + hasStatus := false + + for { + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { + if !hasStatus && lastErr != nil { + return tableCopyStatus{}, false, lastErr + } + return lastStatus, true, nil + } + if delay > remaining { + delay = remaining + } + + timer := clock.NewTimer(delay) + select { + case <-ctx.Done(): + timer.Stop() + return lastStatus, false, ctx.Err() + case <-timer.C(): + } + + if !clock.Now().Before(deadline) { + if !hasStatus && lastErr != nil { + return tableCopyStatus{}, false, lastErr + } + return lastStatus, true, nil + } + requestBudget := deadline.Sub(clock.Now()) + if requestBudget <= 0 { + if !hasStatus && lastErr != nil { + return tableCopyStatus{}, false, lastErr + } + return lastStatus, true, nil + } + fetchCtx, cancelFetch := context.WithTimeout(ctx, requestBudget) + status, err := fetch(fetchCtx) + cancelFetch() + if ctx.Err() != nil { + return lastStatus, false, ctx.Err() + } + if !clock.Now().Before(deadline) { + if !hasStatus && err != nil { + return tableCopyStatus{}, false, err + } + return lastStatus, true, nil + } + if err != nil { + if !tableCopyPollErrorRetryable(err) { + return lastStatus, false, err + } + lastErr = err + } else { + lastStatus = status + hasStatus = true + switch status.State { + case tableCopyStateSuccess: + return status, false, nil + case tableCopyStateInit, tableCopyStateProcess: + default: + return lastStatus, false, errs.NewInternalError(errs.SubtypeInvalidResponse, "table copy status has invalid state %q", status.State) + } + } + + delay *= 2 + if delay > tableCopyPollMax { + delay = tableCopyPollMax + } + } +} + +func tableCopyPollErrorRetryable(err error) bool { + problem, ok := errs.ProblemOf(err) + if !ok { + return false + } + if problem.Category == errs.CategoryNetwork { + switch problem.Subtype { + case errs.SubtypeNetworkTimeout, errs.SubtypeNetworkTransport, errs.SubtypeNetworkServer: + return true + default: + return false + } + } + return problem.Category == errs.CategoryAPI && problem.Retryable +} diff --git a/shortcuts/base/table_copy_test.go b/shortcuts/base/table_copy_test.go new file mode 100644 index 0000000000..7e26df6d1b --- /dev/null +++ b/shortcuts/base/table_copy_test.go @@ -0,0 +1,1411 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestBaseTableCopyShortcutContract(t *testing.T) { + if BaseTableCopy.Command != "+table-copy" { + t.Fatalf("command = %q, want +table-copy", BaseTableCopy.Command) + } + if BaseTableCopy.Risk != "write" { + t.Fatalf("risk = %q, want write", BaseTableCopy.Risk) + } + if !slices.Equal(BaseTableCopy.AuthTypes, authTypes()) { + t.Fatalf("auth types = %#v, want standard Base auth types %#v", BaseTableCopy.AuthTypes, authTypes()) + } + tips := strings.Join(BaseTableCopy.Tips, "\n") + if !strings.Contains(tips, "--range all") || !strings.Contains(tips, "ID or name") { + t.Fatalf("tips = %q, want range safety and table-ref guidance", tips) + } + + flags := make(map[string]struct { + defaultValue string + required bool + enum []string + }) + for _, flag := range BaseTableCopy.Flags { + flags[flag.Name] = struct { + defaultValue string + required bool + enum []string + }{flag.Default, flag.Required, flag.Enum} + } + for _, required := range []string{"base-token", "table-id", "name"} { + if !flags[required].required { + t.Fatalf("flag --%s must be required", required) + } + } + if got := flags["range"]; got.required || got.defaultValue != "schema" || !slices.Equal(got.enum, []string{"schema", "all"}) { + t.Fatalf("range flag = %#v, want optional schema default with schema/all enum", got) + } + + cmd := &cobra.Command{Use: "+table-copy"} + if BaseTableCopy.PostMount == nil { + t.Fatal("table copy must register Cobra duration through PostMount") + } + BaseTableCopy.PostMount(cmd) + timeoutFlag := cmd.Flags().Lookup("timeout") + if timeoutFlag == nil { + t.Fatal("--timeout was not registered") + } + if timeoutFlag.Value.Type() != "duration" || timeoutFlag.DefValue != (5*time.Minute).String() { + t.Fatalf("timeout type/default = %q/%q, want duration/5m0s", timeoutFlag.Value.Type(), timeoutFlag.DefValue) + } + if !strings.Contains(timeoutFlag.Usage, "30m") { + t.Fatalf("timeout usage = %q, want documented 30m maximum", timeoutFlag.Usage) + } +} + +func TestBaseTableCopyStatusShortcutContract(t *testing.T) { + if BaseTableCopyStatus.Command != "+table-copy-status" { + t.Fatalf("command = %q, want +table-copy-status", BaseTableCopyStatus.Command) + } + if BaseTableCopyStatus.Risk != "read" { + t.Fatalf("risk = %q, want read", BaseTableCopyStatus.Risk) + } + if !slices.Equal(BaseTableCopyStatus.AuthTypes, authTypes()) { + t.Fatalf("auth types = %#v, want standard Base auth types %#v", BaseTableCopyStatus.AuthTypes, authTypes()) + } +} + +func TestBaseTableCopyShortcutsUseTableCreateScope(t *testing.T) { + want := []string{"base:table:create"} + if !slices.Equal(BaseTableCopy.Scopes, want) { + t.Fatalf("table copy scopes = %#v, want %#v", BaseTableCopy.Scopes, want) + } + if !slices.Equal(BaseTableCopyStatus.Scopes, want) { + t.Fatalf("table copy status scopes = %#v, want %#v", BaseTableCopyStatus.Scopes, want) + } +} + +func TestBaseTableCopyStatusRejectsInvalidTaskID(t *testing.T) { + tests := []struct { + name string + taskID string + }{ + {name: "blank", taskID: " "}, + {name: "too long", taskID: strings.Repeat("x", tableCopyTaskIDMax+1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes( + t, + BaseTableCopyStatus, + BaseTableCopyStatus.AuthTypes, + []string{"+table-copy-status", "--base-token", "app_x", "--task-id", test.taskID, "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected validation error") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--task-id" { + t.Fatalf("error = %T %v, want validation --task-id", err, err) + } + }) + } +} + +func TestBaseTableCopyRejectsInvalidFlagCombinations(t *testing.T) { + tests := []struct { + name string + extraArgs []string + param string + }{ + {name: "schema wait", extraArgs: []string{"--wait"}, param: "--wait"}, + {name: "schema explicit timeout", extraArgs: []string{"--timeout", "1m"}, param: "--timeout"}, + {name: "all timeout without wait", extraArgs: []string{"--range", "all", "--timeout", "1m"}, param: "--timeout"}, + {name: "negative timeout", extraArgs: []string{"--range", "all", "--wait", "--timeout", "-1s"}, param: "--timeout"}, + {name: "zero timeout", extraArgs: []string{"--range", "all", "--wait", "--timeout", "0s"}, param: "--timeout"}, + {name: "timeout above limit", extraArgs: []string{"--range", "all", "--wait", "--timeout", "31m"}, param: "--timeout"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + args := []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Copy", "--as", "user"} + args = append(args, test.extraArgs...) + err := runShortcutWithAuthTypes(t, BaseTableCopy, BaseTableCopy.AuthTypes, args, factory, stdout) + if err == nil { + t.Fatal("expected validation error") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want validation error", err, err) + } + if validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != test.param { + t.Fatalf("problem = %#v, want invalid_argument param %s", validationErr.Problem, test.param) + } + }) + } +} + +func TestBaseTableCopyAcceptsMaximumTimeout(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Copy", "--range", "all", "--wait", "--timeout", "30m", "--dry-run", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("30m timeout must be accepted: %v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["timeout"] != "30m0s" { + t.Fatalf("timeout = %#v, want 30m0s", data["timeout"]) + } +} + +func TestBaseTableCopyDefaultsToSchemaAndPassesTableNameDirectly(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/Tasks/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "state": "success", + }, + }, + } + reg.Register(copyStub) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "Tasks", "--name", "Copy", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy: %v", err) + } + + body := decodeCapturedJSONBody(t, copyStub) + if body["name"] != "Copy" || body["range"] != tableCopyRangeSchema { + t.Fatalf("copy body = %#v, want name Copy and default range schema", body) + } + data := decodeBaseEnvelope(t, stdout) + if data["range"] != tableCopyRangeSchema || data["state"] != tableCopyStateSuccess || data["completed"] != true { + t.Fatalf("copy output = %#v", data) + } + table := common.GetMap(data, "table") + if common.GetString(table, "id") != "tbl_target" || common.GetString(table, "name") != "Copy" { + t.Fatalf("table output = %#v", table) + } +} + +func TestBaseTableCopySubmissionIsNeverRetried(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + requestCount := 0 + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Error: context.DeadlineExceeded, + Reusable: true, + OnMatch: func(*http.Request) { + requestCount++ + }, + }) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected submission error") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTimeout || problem.Retryable { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if requestCount != 1 { + t.Fatalf("submit request count = %d, want exactly 1", requestCount) + } +} + +func TestBaseTableCopySubmissionAPIErrorReturnsWithoutStatusOrState(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 800070111, + "msg": "table copy rejected", + }, + }) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected submission API error") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryAPI || problem.Code != 800070111 { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if stdout.Len() != 0 { + t.Fatalf("failed submission must not output task state: %s", stdout.String()) + } +} + +func TestBaseTableCopyAllWithoutWaitReturnsTaskAndNextCommand(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + } + reg.Register(copyStub) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["range"] != tableCopyRangeAll || data["state"] != tableCopyStateInit || data["completed"] != false || data["task_id"] != "ct1.token" { + t.Fatalf("copy output = %#v", data) + } + if data["next_action"] != "poll_status" { + t.Fatalf("next_action = %#v", data["next_action"]) + } + wantNext := "lark-cli base +table-copy-status --base-token app_x --task-id ct1.token --as user" + if data["next_command"] != wantNext { + t.Fatalf("next_command = %#v, want %q", data["next_command"], wantNext) + } +} + +func TestBaseTableCopyStatusReturnsProcessState(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + statusStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table_id": "tbl_target", + "state": "process", + }, + }, + } + reg.Register(statusStub) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopyStatus, + BaseTableCopyStatus.AuthTypes, + []string{"+table-copy-status", "--base-token", "app_x", "--task-id", "ct1.token", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy status: %v", err) + } + + body := decodeCapturedJSONBody(t, statusStub) + if body["task_id"] != "ct1.token" { + t.Fatalf("status body = %#v", body) + } + data := decodeBaseEnvelope(t, stdout) + if data["state"] != tableCopyStateProcess || data["completed"] != false || data["task_id"] != "ct1.token" { + t.Fatalf("status output = %#v", data) + } +} + +func TestBaseTableCopyStatusRejectsFailedSuccessEnvelope(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table_id": "tbl_target", + "state": "failed", + }, + }, + }) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopyStatus, + BaseTableCopyStatus.AuthTypes, + []string{"+table-copy-status", "--base-token", "app_x", "--task-id", "ct1.token", "--as", "user"}, + factory, + stdout, + ) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if stdout.Len() != 0 { + t.Fatalf("failed success envelope must not produce status output: %s", stdout.String()) + } +} + +func TestBaseTableCopyDryRunDefaultsToSchemaWithoutNetwork(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "Tasks", "--name", "Copy", "--dry-run", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy dry-run: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + api, _ := data["api"].([]interface{}) + if len(api) != 1 { + t.Fatalf("api calls = %#v, want one submit", data["api"]) + } + call, _ := api[0].(map[string]interface{}) + if call["method"] != "POST" || call["url"] != "/open-apis/base/v3/bases/app_x/tables/Tasks/copy" { + t.Fatalf("submit call = %#v", call) + } + body, _ := call["body"].(map[string]interface{}) + if body["name"] != "Copy" || body["range"] != tableCopyRangeSchema { + t.Fatalf("submit body = %#v", body) + } + if data["wait"] != false { + t.Fatalf("wait metadata = %#v", data["wait"]) + } +} + +func TestBaseTableCopyDryRunWaitShowsSymbolicStatusStep(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_x", "--name", "Copy", "--range", "all", "--wait", "--timeout", "2m", "--dry-run", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy wait dry-run: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + api, _ := data["api"].([]interface{}) + if len(api) != 2 { + t.Fatalf("api calls = %#v, want submit and symbolic status", data["api"]) + } + statusCall, _ := api[1].(map[string]interface{}) + statusBody, _ := statusCall["body"].(map[string]interface{}) + if statusCall["url"] != "/open-apis/base/v3/bases/app_x/copy_table_state" || statusBody["task_id"] != "" { + t.Fatalf("status call = %#v", statusCall) + } + if data["wait"] != true || data["timeout"] != "2m0s" { + encoded, _ := json.Marshal(data) + t.Fatalf("orchestration metadata = %s", encoded) + } +} + +func TestBaseTableCopyStatusDryRun(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcutWithAuthTypes( + t, + BaseTableCopyStatus, + BaseTableCopyStatus.AuthTypes, + []string{"+table-copy-status", "--base-token", "app_x", "--task-id", "ct1.token", "--dry-run", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy status dry-run: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + api, _ := data["api"].([]interface{}) + if len(api) != 1 { + t.Fatalf("api calls = %#v, want one status request", data["api"]) + } + call, _ := api[0].(map[string]interface{}) + body, _ := call["body"].(map[string]interface{}) + if call["method"] != "POST" || + call["url"] != "/open-apis/base/v3/bases/app_x/copy_table_state" || + body["task_id"] != "ct1.token" { + t.Fatalf("status call = %#v", call) + } +} + +func TestBaseTableCopyAllSubmitSuccessCompletesWithoutTaskOrStatusRequest(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "state": tableCopyStateSuccess, + }, + }, + }) + + err := runShortcutWithAuthTypes( + t, + BaseTableCopy, + BaseTableCopy.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--timeout", "1s", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("successful submission must complete immediately: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["state"] != tableCopyStateSuccess || data["completed"] != true || data["range"] != tableCopyRangeAll { + t.Fatalf("submit success output = %#v", data) + } + for _, unexpected := range []string{"task_id", "timed_out", "next_action", "next_command"} { + if _, ok := data[unexpected]; ok { + t.Fatalf("submit success output must omit %s: %#v", unexpected, data) + } + } +} + +func TestBaseTableCopyAllWaitsForSuccess(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stderr := factory.IOStreams.ErrOut.(interface{ String() string }) + statusRequestHasDeadline := false + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + OnMatch: func(req *http.Request) { + _, statusRequestHasDeadline = req.Context().Deadline() + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table_id": "tbl_target", + "state": "success", + }, + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy wait: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["state"] != tableCopyStateSuccess || data["completed"] != true || data["task_id"] != "ct1.token" { + t.Fatalf("wait output = %#v", data) + } + if !strings.Contains(stderr.String(), "Table copy status: success") { + t.Fatalf("stderr = %q, want status progress", stderr.String()) + } + if !strings.Contains(stderr.String(), "Table copy submitted: init, task_id=ct1.token") { + t.Fatalf("stderr = %q, want recoverable submit progress", stderr.String()) + } + if !statusRequestHasDeadline { + t.Fatal("status API request context must inherit the remaining poll deadline") + } +} + +func TestBaseTableCopyAllWaitTimeoutReturnsContinuation(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Reusable: true, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table_id": "tbl_target", + "state": "process", + }, + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--timeout", "10s", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy wait timeout: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["state"] != tableCopyStateProcess || data["completed"] != false || data["timed_out"] != true { + t.Fatalf("timeout output = %#v", data) + } + if data["next_action"] != "poll_status" || data["next_command"] == "" { + t.Fatalf("timeout continuation = %#v", data) + } +} + +func TestBaseTableCopyAllWaitTimeoutBeforeFirstStatusReturnsSubmitState(t *testing.T) { + for _, submitState := range []string{tableCopyStateInit, tableCopyStateProcess} { + t.Run(submitState, 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_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": submitState, + }, + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--timeout", "1s", "--as", "user"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("table copy early timeout: %v", err) + } + + data := decodeBaseEnvelope(t, stdout) + if data["state"] != submitState || data["timed_out"] != true || data["completed"] != false { + t.Fatalf("early timeout output = %#v", data) + } + }) + } +} + +func TestBaseTableCopyAllWaitUsesTopLevelTaskError(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 800070111, + "msg": "table copy task failed", + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected typed task failure") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != 800070111 || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeUnknown { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + var envelope struct { + OK bool `json:"ok"` + Data tableCopyOutput `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode failure stdout: %v\nraw=%s", decodeErr, stdout.String()) + } + if envelope.OK || envelope.Data.NextAction != "" || envelope.Data.NextCommand != "" { + t.Fatalf("terminal task failure must not suggest more polling: %#v", envelope) + } +} + +func TestBaseTableCopyWaitInvalidTaskDoesNotSuggestMorePolling(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stderr := factory.IOStreams.ErrOut.(interface{ String() string }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 800010109, + "msg": "invalid task", + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected status error after successful submit") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != 800010109 { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if strings.Contains(problem.Hint, "ct1.token") { + t.Fatalf("error hint must not embed one task ID: %q", problem.Hint) + } + if strings.Contains(problem.Hint, "+table-copy-status") { + t.Fatalf("invalid task hint must not suggest querying the same task again: %q", problem.Hint) + } + if !strings.Contains(stderr.String(), "Table copy submitted: init, task_id=ct1.token") { + t.Fatalf("stderr = %q, want recoverable submit progress", stderr.String()) + } + + var envelope struct { + OK bool `json:"ok"` + Data tableCopyOutput `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode recovery stdout: %v\nraw=%s", decodeErr, stdout.String()) + } + if envelope.OK || + envelope.Data.State != tableCopyStateInit || + envelope.Data.Completed || + envelope.Data.TaskID != "ct1.token" || + envelope.Data.NextAction != "" || + envelope.Data.NextCommand != "" { + t.Fatalf("recovery envelope = %#v", envelope) + } +} + +func TestBaseTableCopyWaitStatusUnavailableDoesNotSuggestMorePolling(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": "init", + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 800030110, + "msg": "status unavailable", + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "bot"}, + factory, + stdout, + ) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != 800030110 || problem.Subtype != errs.SubtypeNotFound { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if strings.Contains(problem.Hint, "+table-copy-status") { + t.Fatalf("status-unavailable hint must not suggest querying the same task again: %q", problem.Hint) + } + + var envelope struct { + OK bool `json:"ok"` + Data tableCopyOutput `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode failure stdout: %v\nraw=%s", decodeErr, stdout.String()) + } + if envelope.OK || envelope.Data.NextAction != "" || envelope.Data.NextCommand != "" { + t.Fatalf("status-unavailable failure must not suggest more polling: %#v", envelope) + } +} + +func TestBaseTableCopyWaitAuthErrorsPreserveContinuation(t *testing.T) { + tests := []struct { + name string + code int + message string + wantCategory errs.Category + }{ + { + name: "expired token", + code: 99991677, + message: "access token expired", + wantCategory: errs.CategoryAuthentication, + }, + { + name: "missing scope", + code: 99991679, + message: "missing scope", + wantCategory: errs.CategoryAuthorization, + }, + } + + for _, tt := range tests { + t.Run(tt.name, 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_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": tableCopyStateInit, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": tt.code, + "msg": tt.message, + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected status error after successful submit") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != tt.code || problem.Category != tt.wantCategory { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + if !strings.Contains(problem.Hint, "already submitted") || + !strings.Contains(problem.Hint, "+table-copy-status") { + t.Fatalf("error hint = %q, want same-task continuation guidance", problem.Hint) + } + if strings.Contains(problem.Hint, "ct1.token") { + t.Fatalf("error hint must not embed one task ID: %q", problem.Hint) + } + + var envelope struct { + OK bool `json:"ok"` + Data tableCopyOutput `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode recovery stdout: %v\nraw=%s", decodeErr, stdout.String()) + } + wantNext := "lark-cli base +table-copy-status --base-token app_x --task-id ct1.token --as user" + if envelope.OK || + envelope.Data.State != tableCopyStateInit || + envelope.Data.Completed || + envelope.Data.TaskID != "ct1.token" || + envelope.Data.NextAction != "poll_status" || + envelope.Data.NextCommand != wantNext { + t.Fatalf("recovery envelope = %#v, want next_command %q", envelope, wantNext) + } + }) + } +} + +func TestBaseTableCopyWaitErrorPreservesLastSuccessfulStatus(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_source/copy", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target", "name": "Copy"}, + "task_id": "ct1.token", + "state": tableCopyStateInit, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "table_id": "tbl_target", + "state": tableCopyStateProcess, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/copy_table_state", + Body: map[string]interface{}{ + "code": 800070111, + "msg": "table copy task failed", + }, + }) + + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + shortcut := BaseTableCopy + shortcut.Execute = func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeTableCopyWithClock(ctx, runtime, clock) + } + err := runShortcutWithAuthTypes( + t, + shortcut, + shortcut.AuthTypes, + []string{"+table-copy", "--base-token", "app_x", "--table-id", "tbl_source", "--name", "Copy", "--range", "all", "--wait", "--as", "user"}, + factory, + stdout, + ) + if err == nil { + t.Fatal("expected status error after process status") + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Code != 800070111 { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + + var envelope struct { + OK bool `json:"ok"` + Data tableCopyOutput `json:"data"` + } + if decodeErr := json.Unmarshal(stdout.Bytes(), &envelope); decodeErr != nil { + t.Fatalf("decode recovery stdout: %v\nraw=%s", decodeErr, stdout.String()) + } + if envelope.OK || + envelope.Data.State != tableCopyStateProcess || + envelope.Data.Completed || + envelope.Data.TaskID != "ct1.token" || + envelope.Data.NextAction != "" || + envelope.Data.NextCommand != "" { + t.Fatalf("recovery envelope = %#v", envelope) + } +} + +type advancingTableCopyClock struct { + now time.Time + sleeps []time.Duration +} + +func (c *advancingTableCopyClock) Now() time.Time { return c.now } + +func (c *advancingTableCopyClock) NewTimer(duration time.Duration) tableCopyTimer { + c.sleeps = append(c.sleeps, duration) + c.now = c.now.Add(duration) + ch := make(chan time.Time, 1) + ch <- c.now + return &advancingTableCopyTimer{ch: ch} +} + +type advancingTableCopyTimer struct { + ch chan time.Time +} + +func (t *advancingTableCopyTimer) C() <-chan time.Time { return t.ch } +func (t *advancingTableCopyTimer) Stop() bool { return true } + +func TestPollTableCopyUsesBackoffAndStateOnly(t *testing.T) { + statuses := []tableCopyStatus{ + {TableID: "tbl_target", State: tableCopyStateProcess}, + {TableID: "tbl_target", State: tableCopyStateSuccess}, + } + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + fetches := 0 + + status, timedOut, err := pollTableCopy( + context.Background(), + 30*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + status := statuses[fetches] + fetches++ + return status, nil + }, + ) + if err != nil { + t.Fatalf("poll: %v", err) + } + if timedOut || status.State != tableCopyStateSuccess { + t.Fatalf("status=%#v timedOut=%v", status, timedOut) + } + if !slices.Equal(clock.sleeps, []time.Duration{3 * time.Second, 6 * time.Second}) { + t.Fatalf("sleeps = %#v", clock.sleeps) + } +} + +func TestPollTableCopyBoundsEachFetchByRemainingTimeout(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + fetches := 0 + _, timedOut, err := pollTableCopy( + context.Background(), + 30*time.Second, + clock, + func(ctx context.Context) (tableCopyStatus, error) { + fetches++ + deadline, ok := ctx.Deadline() + if !ok { + t.Fatal("fetch context has no deadline") + } + remaining := time.Until(deadline) + if remaining <= 0 || remaining > 27*time.Second { + t.Fatalf("fetch deadline remaining = %s, want within remaining 27s budget", remaining) + } + return tableCopyStatus{TableID: "tbl_target", State: tableCopyStateSuccess}, nil + }, + ) + if err != nil || timedOut || fetches != 1 { + t.Fatalf("timedOut=%v fetches=%d err=%v", timedOut, fetches, err) + } +} + +func TestPollTableCopyDoesNotAcceptResponseAfterDeadline(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + status, timedOut, err := pollTableCopy( + context.Background(), + 30*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + clock.now = clock.now.Add(28 * time.Second) + return tableCopyStatus{TableID: "tbl_target", State: tableCopyStateSuccess}, nil + }, + ) + if err != nil || !timedOut || status.State != "" { + t.Fatalf("status=%#v timedOut=%v err=%v, want timed-out without accepting late response", status, timedOut, err) + } +} + +func TestPollTableCopyCapsSleepAtDeadline(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + fetches := 0 + status, timedOut, err := pollTableCopy( + context.Background(), + 10*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + fetches++ + return tableCopyStatus{TableID: "tbl_target", State: tableCopyStateProcess}, nil + }, + ) + if err != nil { + t.Fatalf("poll: %v", err) + } + if !timedOut || status.State != tableCopyStateProcess || fetches != 2 { + t.Fatalf("status=%#v timedOut=%v fetches=%d", status, timedOut, fetches) + } + if !slices.Equal(clock.sleeps, []time.Duration{3 * time.Second, 6 * time.Second, time.Second}) { + t.Fatalf("sleeps = %#v", clock.sleeps) + } +} + +func TestPollTableCopyRetriesTransientNetworkError(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + fetches := 0 + status, timedOut, err := pollTableCopy( + context.Background(), + 30*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + fetches++ + if fetches == 1 { + return tableCopyStatus{}, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "temporary timeout") + } + return tableCopyStatus{TableID: "tbl_target", State: tableCopyStateSuccess}, nil + }, + ) + if err != nil || timedOut || status.State != tableCopyStateSuccess { + t.Fatalf("status=%#v timedOut=%v err=%v", status, timedOut, err) + } + if fetches != 2 { + t.Fatalf("fetches=%d, want 2", fetches) + } +} + +func TestPollTableCopyReturnsLastStatusWhenLaterRetryableErrorsReachDeadline(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + fetches := 0 + status, timedOut, err := pollTableCopy( + context.Background(), + 10*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + fetches++ + if fetches == 1 { + return tableCopyStatus{TableID: "tbl_target", State: tableCopyStateProcess}, nil + } + return tableCopyStatus{}, errs.NewNetworkError(errs.SubtypeNetworkServer, "temporary server error") + }, + ) + if err != nil || !timedOut || status.State != tableCopyStateProcess { + t.Fatalf("status=%#v timedOut=%v err=%v", status, timedOut, err) + } + if fetches != 2 { + t.Fatalf("fetches=%d, want 2", fetches) + } +} + +func TestPollTableCopyReturnsLastErrorWhenNoStatusSucceeded(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + wantErr := errs.NewNetworkError(errs.SubtypeNetworkServer, "temporary server error") + status, timedOut, err := pollTableCopy( + context.Background(), + 4*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + return tableCopyStatus{}, wantErr + }, + ) + if !errors.Is(err, wantErr) || timedOut || status.State != "" { + t.Fatalf("status=%#v timedOut=%v err=%v, want last error", status, timedOut, err) + } +} + +func TestPollTableCopyDoesNotRetryTLSEvenWhenFlaggedRetryable(t *testing.T) { + clock := &advancingTableCopyClock{now: time.Unix(0, 0)} + wantErr := errs.NewNetworkError(errs.SubtypeNetworkTLS, "certificate failure").WithRetryable() + fetches := 0 + _, timedOut, err := pollTableCopy( + context.Background(), + 30*time.Second, + clock, + func(context.Context) (tableCopyStatus, error) { + fetches++ + return tableCopyStatus{}, wantErr + }, + ) + if !errors.Is(err, wantErr) || timedOut || fetches != 1 { + t.Fatalf("timedOut=%v fetches=%d err=%v", timedOut, fetches, err) + } +} + +func TestTableCopySubmissionErrorMarksOutcomeUnknown(t *testing.T) { + cause := errors.New("read timeout") + original := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "request failed").WithCause(cause).WithRetryable() + err := tableCopySubmissionError(original) + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error = %T %v, want typed error", err, err) + } + if problem.Subtype != errs.SubtypeNetworkTimeout || problem.Retryable { + t.Fatalf("problem = %#v, want non-retryable network timeout", problem) + } + if !strings.Contains(problem.Message, "outcome is unknown") || !strings.Contains(problem.Hint, "Do not retry") { + t.Fatalf("problem = %#v, want unknown-outcome guidance", problem) + } + if !errors.Is(err, cause) { + t.Fatal("submission error must preserve its cause") + } +} + +func TestProjectTableCopySubmitRejectsOversizedTaskID(t *testing.T) { + _, err := projectTableCopySubmit(map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target"}, + "task_id": strings.Repeat("x", tableCopyTaskIDMax+1), + "state": tableCopyStateInit, + }) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } +} + +func TestProjectTableCopySubmitRejectsInvalidResponseShape(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + { + name: "missing table id", + data: map[string]interface{}{"state": tableCopyStateSuccess}, + }, + { + name: "invalid state", + data: map[string]interface{}{ + "table": map[string]interface{}{"id": "tbl_target"}, + "state": "done", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := projectTableCopySubmit(test.data) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + }) + } +} + +func TestProjectTableCopyStatusRejectsFailedState(t *testing.T) { + _, err := projectTableCopyStatus(map[string]interface{}{"table_id": "tbl_target", "state": "failed"}) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } +} + +func TestProjectTableCopyStatusRejectsInvalidResponseShape(t *testing.T) { + tests := []struct { + name string + data map[string]interface{} + }{ + { + name: "missing table id", + data: map[string]interface{}{"state": tableCopyStateSuccess}, + }, + { + name: "invalid state", + data: map[string]interface{}{ + "table_id": "tbl_target", + "state": "done", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := projectTableCopyStatus(test.data) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("error = %T %v, problem=%#v", err, err, problem) + } + }) + } +} + +func TestTableCopyWaitErrorAddsContinuationWithoutReclassification(t *testing.T) { + original := errs.NewNetworkError(errs.SubtypeNetworkServer, "status unavailable").WithCode(503) + err := tableCopyWaitError(original) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkServer || problem.Code != 503 { + t.Fatalf("problem = %#v", problem) + } + if !strings.Contains(problem.Hint, "+table-copy-status") { + t.Fatalf("hint = %q, want continuation command", problem.Hint) + } + for _, sensitive := range []string{"ct1.token", "app_x", "--task-id"} { + if strings.Contains(problem.Hint, sensitive) { + t.Fatalf("hint = %q, must not contain %q", problem.Hint, sensitive) + } + } +} + +func TestTableCopyWaitErrorPreservesNonRetryableHint(t *testing.T) { + original := errs.NewAPIError(errs.SubtypeNotFound, "status unavailable"). + WithCode(800030110). + WithHint("Submit a new copy request.") + err := tableCopyWaitError(original) + if err != original { + t.Fatalf("error = %T %v, want original error", err, err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Hint != "Submit a new copy request." { + t.Fatalf("problem = %#v, want unchanged upstream hint", problem) + } +} + +func TestTableCopyWaitErrorClassifiesCancellation(t *testing.T) { + err := tableCopyWaitError(context.Canceled) + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("problem = %#v", problem) + } + if !errors.Is(err, context.Canceled) || !strings.Contains(problem.Hint, "+table-copy-status") { + t.Fatalf("error=%v problem=%#v", err, problem) + } +} + +func TestTableCopyStatusErrorClassifiesTaskTokenCodes(t *testing.T) { + upstream := errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid task"). + WithCode(800010109). + WithHint("Submit a new copy request.") + err := tableCopyStatusError(upstream) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want validation error", err, err) + } + if validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Code != 800010109 || validationErr.Param != "--task-id" { + t.Fatalf("problem = %#v param=%q", validationErr.Problem, validationErr.Param) + } + if validationErr.Hint != "Submit a new copy request." { + t.Fatalf("hint = %q", validationErr.Hint) + } +} + +func TestTableCopyStatusErrorReclassifiesAndPreservesMetadata(t *testing.T) { + cause := errors.New("upstream task lookup") + upstream := errs.NewAPIError(errs.SubtypeUnknown, "invalid task"). + WithCode(800010109). + WithHint("Submit a new copy request."). + WithLogID("log-id"). + WithCause(cause) + err := tableCopyStatusError(upstream) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error = %T %v, want validation error", err, err) + } + if validationErr.Code != 800010109 || + validationErr.Param != "--task-id" || + validationErr.Hint != "Submit a new copy request." || + validationErr.LogID != "log-id" { + t.Fatalf("validation error = %#v", validationErr) + } + if !errors.Is(err, cause) { + t.Fatal("reclassified error must preserve the original cause chain") + } +} + +func TestTableCopyStatusErrorPreservesStatusUnavailableClassification(t *testing.T) { + upstream := errs.NewAPIError(errs.SubtypeNotFound, "status unavailable"). + WithCode(800030110). + WithHint("Submit a new copy request.") + err := tableCopyStatusError(upstream) + if err != upstream { + t.Fatalf("error = %T %v, want original error", err, err) + } + problem, ok := errs.ProblemOf(err) + if !ok || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeNotFound || problem.Hint != "Submit a new copy request." { + t.Fatalf("problem = %#v", problem) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index dc1f058df7..898a9ccf7b 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -450,6 +450,12 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa // Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating // the identity → token logic across the generic and shortcut API paths. func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { + return ctx.DoAPIWithContext(ctx.ctx, req, opts...) +} + +// DoAPIWithContext executes a raw Lark SDK request using callCtx for request +// cancellation and deadlines while preserving the shortcut's resolved identity. +func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) { ac, err := ctx.getAPIClient() if err != nil { return nil, err @@ -457,7 +463,7 @@ func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestO if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil { opts = append(opts, optFn) } - return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...) + return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...) } // DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token), diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index cf4037a55a..bf443ef76d 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-base -version: 1.2.3 +version: 1.2.4 description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive,认证/授权转 lark-shared。" metadata: requires: @@ -54,6 +54,7 @@ metadata: | 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | | 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | | 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | +| 复制 Base 内单张数据表 | `+table-copy` / `+table-copy-status` | 默认只复制结构;只有用户明确要求复制全表、数据、行或记录时才传 `--range all`;异步任务按返回的 `task_id` 查询或续等 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | @@ -79,6 +80,7 @@ metadata: - `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。 - 新建 Base 时,强烈推荐一次性执行 `lark-cli base +base-create --name "" --table-name "" --fields ''`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema;使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。 - `+base-create` 不传 `--table-name` 和 `--fields` 时,会创建一个默认 schema 的初始数据表。 +- `+table-copy` 的安全默认值是只复制表结构;用户没有明确要求记录时省略 `--range`,明确要求包含记录时才传 `--range all`。`--table-id` 可直接使用当前 Base 中的表 ID 或表名。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 @@ -89,6 +91,7 @@ metadata: ## 身份与权限降级 - 默认显式使用 `--as user` 操作用户资源;只有用户明确要求应用身份时,才直接用 `--as bot`。 +- `+table-copy --wait` 提交成功后会在 stderr 打印完整 `task_id`;若进程被 Ctrl-C 终止,可用该 ID 和原身份执行 `+table-copy-status` 续查,不要重新提交复制。 - user 身份报 scope/授权不足,或错误中包含 `missing_scopes` / `hint`,先转 `lark-shared` 做用户授权恢复,不要直接降级 bot。 - user 身份报资源级无访问且无授权恢复提示时,才可用 `--as bot` 重试一次;bot 仍失败就停止重试并按权限错误处理。 - `91403` 或明确不可访问错误不要循环换身份重试。 diff --git a/tests/cli_e2e/base/base_table_copy_dryrun_test.go b/tests/cli_e2e/base/base_table_copy_dryrun_test.go new file mode 100644 index 0000000000..fba1fe5499 --- /dev/null +++ b/tests/cli_e2e/base/base_table_copy_dryrun_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +func TestBaseTableCopyDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + t.Run("schema default with table name", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy", + "--base-token", "app_x", + "--table-id", "Source Tasks", + "--name", "Copied Tasks", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/Source%20Tasks/copy", clie2e.DryRunGet(out, "api.0.url").String(), out) + require.Equal(t, "Copied Tasks", clie2e.DryRunGet(out, "api.0.body.name").String(), out) + require.Equal(t, "schema", clie2e.DryRunGet(out, "api.0.body.range").String(), out) + require.False(t, clie2e.DryRunGet(out, "api.1").Exists(), out) + }) + + t.Run("all with wait", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy", + "--base-token", "app_x", + "--table-id", "tbl_source", + "--name", "Copied Tasks", + "--range", "all", + "--wait", + "--timeout", "300s", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "all", clie2e.DryRunGet(out, "api.0.body.range").String(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/copy_table_state", clie2e.DryRunGet(out, "api.1.url").String(), out) + require.Equal(t, "", clie2e.DryRunGet(out, "api.1.body.task_id").String(), out) + require.True(t, clie2e.DryRunGet(out, "wait").Bool(), out) + require.Equal(t, "5m0s", clie2e.DryRunGet(out, "timeout").String(), out) + }) + + t.Run("status", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy-status", + "--base-token", "app_x", + "--task-id", "ct1.token", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out) + require.Equal(t, "/open-apis/base/v3/bases/app_x/copy_table_state", clie2e.DryRunGet(out, "api.0.url").String(), out) + require.Equal(t, "ct1.token", clie2e.DryRunGet(out, "api.0.body.task_id").String(), out) + }) +} diff --git a/tests/cli_e2e/base/base_table_copy_workflow_test.go b/tests/cli_e2e/base/base_table_copy_workflow_test.go new file mode 100644 index 0000000000..3ce979546f --- /dev/null +++ b/tests/cli_e2e/base/base_table_copy_workflow_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseTableCopyWorkflow(t *testing.T) { + if os.Getenv("LARK_CLI_E2E_BASE_TABLE_COPY_READY") != "1" { + t.Skip("set LARK_CLI_E2E_BASE_TABLE_COPY_READY=1 after the table-copy OpenAPI is deployed") + } + clie2e.SkipWithoutTenantAccessToken(t) + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Minute) + t.Cleanup(cancel) + + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-table-copy-"+clie2e.GenerateSuffix()) + sourceTableID, _, _ := createTableWithRetry( + t, + parentT, + ctx, + baseToken, + "Copy source "+clie2e.GenerateSuffix(), + `[{"name":"Name","type":"text"}]`, + `{"name":"Main","type":"grid"}`, + ) + + seed, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+record-batch-create", + "--base-token", baseToken, + "--table-id", sourceTableID, + "--json", `{"fields":["Name"],"rows":[["copied record"]]}`, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + seed.AssertExitCode(t, 0) + seed.AssertStdoutStatus(t, true) + + cleanupCopiedTable := func(tableID string) { + t.Helper() + require.NotEmpty(t, tableID) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := cleanupContext() + defer cleanupCancel() + result, cleanupErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{"base", "+table-delete", "--base-token", baseToken, "--table-id", tableID, "--yes"}, + DefaultAs: "bot", + }) + if cleanupErr != nil || result == nil || result.ExitCode != 0 { + reportCleanupFailure(parentT, "delete copied table "+tableID, result, cleanupErr) + } + }) + } + countRecords := func(tableID string) int64 { + t.Helper() + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+record-list", "--base-token", baseToken, "--table-id", tableID, "--limit", "10"}, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + return gjson.Get(result.Stdout, "data.record_id_list.#").Int() + } + + t.Run("schema default", func(t *testing.T) { + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy", + "--base-token", baseToken, + "--table-id", sourceTableID, + "--name", "Schema copy " + clie2e.GenerateSuffix(), + }, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "schema", gjson.Get(result.Stdout, "data.range").String(), result.Stdout) + require.Equal(t, "success", gjson.Get(result.Stdout, "data.state").String(), result.Stdout) + targetID := gjson.Get(result.Stdout, "data.table.id").String() + cleanupCopiedTable(targetID) + require.Equal(t, int64(0), countRecords(targetID)) + }) + + t.Run("all no-wait and status", func(t *testing.T) { + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy", + "--base-token", baseToken, + "--table-id", sourceTableID, + "--name", "Async copy " + clie2e.GenerateSuffix(), + "--range", "all", + }, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + targetID := gjson.Get(result.Stdout, "data.table.id").String() + taskID := gjson.Get(result.Stdout, "data.task_id").String() + cleanupCopiedTable(targetID) + require.NotEmpty(t, taskID, result.Stdout) + + var lastStatus string + waitErr := clie2e.WaitForCondition(ctx, clie2e.WaitOptions{ + Timeout: 2 * time.Minute, + Interval: 3 * time.Second, + TimeoutError: func() error { + return fmt.Errorf("table copy still %s", lastStatus) + }, + }, func() (bool, error) { + status, statusErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+table-copy-status", "--base-token", baseToken, "--task-id", taskID}, + DefaultAs: "bot", + }) + if statusErr != nil { + return false, statusErr + } + if status.ExitCode != 0 { + return false, fmt.Errorf("status failed: stdout=%s stderr=%s", status.Stdout, status.Stderr) + } + lastStatus = gjson.Get(status.Stdout, "data.state").String() + if lastStatus == "failed" { + return false, fmt.Errorf("copy task failed: %s", status.Stdout) + } + return lastStatus == "success", nil + }) + require.NoError(t, waitErr) + require.Equal(t, int64(1), countRecords(targetID)) + }) + + t.Run("all wait", func(t *testing.T) { + result, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+table-copy", + "--base-token", baseToken, + "--table-id", sourceTableID, + "--name", "Wait copy " + clie2e.GenerateSuffix(), + "--range", "all", + "--wait", + "--timeout", "2m", + }, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + require.Equal(t, "success", gjson.Get(result.Stdout, "data.state").String(), result.Stdout) + targetID := gjson.Get(result.Stdout, "data.table.id").String() + cleanupCopiedTable(targetID) + require.Equal(t, int64(1), countRecords(targetID)) + }) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index 8ebe403e54..578dee6012 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -1,9 +1,9 @@ # Base CLI E2E Coverage ## Metrics -- Denominator: 87 leaf commands -- Covered: 28 -- Coverage: 32.2% +- Denominator: 89 leaf commands +- Covered: 30 +- Coverage: 33.7% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. @@ -17,8 +17,10 @@ - TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`. - TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint. - TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body. +- TestBaseTableCopyDryRun: proves `+table-copy` and `+table-copy-status` request shapes, including the schema-safe default, table-name path escaping, explicit all+wait orchestration, Cobra duration parsing, and the opaque task ID body. +- TestBaseTableCopyWorkflow: feature-gated by `LARK_CLI_E2E_BASE_TABLE_COPY_READY=1` until the OpenAPI is deployed; creates a source table and record, proves schema-only copy, all no-wait plus status, all wait, record inclusion, and cleanup. - Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered. -- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite. +- Blocked area: table-copy live integration remains deployment-gated; dashboard, field, most record operations, most form operations, view, and workflow operations still lack deterministic create/read/update workflows in this suite. ## Command Table @@ -82,6 +84,8 @@ | ✓ | base +role-list | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/list as bot | `--base-token` | | | ✓ | base +role-update | shortcut | base_role_workflow_test.go::TestBase_RoleWorkflow/update as bot | `--base-token`; `--role-id`; `--json` | | | ✓ | base +table-create | shortcut | base/helpers_test.go::createTableWithRetry | `--base-token`; `--name`; optional `--fields`; optional `--view` | helper asserts table id | +| ✓ | base +table-copy | shortcut | base_table_copy_dryrun_test.go::TestBaseTableCopyDryRun; base_table_copy_workflow_test.go::TestBaseTableCopyWorkflow | `--table-id` ID/name; default `--range schema`; explicit `--range all --wait --timeout` | dry-run covered; live workflow is deployment-gated and not yet verified | +| ✓ | base +table-copy-status | shortcut | base_table_copy_dryrun_test.go::TestBaseTableCopyDryRun/status; base_table_copy_workflow_test.go::TestBaseTableCopyWorkflow/all no-wait and status | opaque `--task-id` | dry-run covered; live polling is deployment-gated and not yet verified | | ✕ | base +table-delete | shortcut | | none | cleanup only | | ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | | | ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |