Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
421 lines
12 KiB
Go
421 lines
12 KiB
Go
package helpers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
func executeAitableExtraCommand(t *testing.T, cmd *cobra.Command, args ...string) {
|
|
t.Helper()
|
|
|
|
var out, errOut bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&errOut)
|
|
cmd.SetArgs(args)
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
|
}
|
|
}
|
|
|
|
type aitableSequencedRunner struct {
|
|
calls []executor.Invocation
|
|
responses []map[string]any
|
|
}
|
|
|
|
func (r *aitableSequencedRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
|
r.calls = append(r.calls, invocation)
|
|
var response map[string]any
|
|
if idx := len(r.calls) - 1; idx >= 0 && idx < len(r.responses) {
|
|
response = r.responses[idx]
|
|
}
|
|
return executor.Result{Invocation: invocation, Response: response}, nil
|
|
}
|
|
|
|
func TestAitableFieldSearchOptionsRoutesToAitable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableFieldSearchOptionsCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--field-id", "FIELD_001",
|
|
"--keyword", "已",
|
|
"--limit", "10",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
|
}
|
|
if got := runner.last.Tool; got != "search_field_options" {
|
|
t.Fatalf("Tool = %q, want search_field_options", got)
|
|
}
|
|
if got := runner.last.Params["keyword"]; got != "已" {
|
|
t.Fatalf("keyword = %#v, want 已", got)
|
|
}
|
|
if got := runner.last.Params["limit"]; got != 10 {
|
|
t.Fatalf("limit = %#v, want 10", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableViewGetFieldWidthsProjectsCustomWidthMap(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableSequencedRunner{responses: []map[string]any{{
|
|
"content": map[string]any{
|
|
"status": "success",
|
|
"data": map[string]any{"views": []any{map[string]any{
|
|
"viewId": "VIEW_001",
|
|
"viewType": "Grid",
|
|
"custom": map[string]any{
|
|
"widthMap": map[string]any{"FIELD_001": 240},
|
|
},
|
|
}}},
|
|
},
|
|
}}}
|
|
cmd := newAitableViewGetFieldWidthsCommand(runner)
|
|
var out, errOut bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&errOut)
|
|
cmd.SetArgs([]string{
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--view-id", "VIEW_001",
|
|
})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
|
t.Fatalf("output JSON parse error = %v\nstdout:\n%s", err, out.String())
|
|
}
|
|
data, ok := payload["data"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data = %#v, want object", payload["data"])
|
|
}
|
|
if got := data["FIELD_001"]; got != float64(240) {
|
|
t.Fatalf("FIELD_001 width = %#v, want 240", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableViewUpdateCardDispatchesGalleryConfig(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableSequencedRunner{responses: []map[string]any{
|
|
{"content": map[string]any{
|
|
"status": "success",
|
|
"data": map[string]any{"views": []any{map[string]any{
|
|
"viewId": "VIEW_001",
|
|
"viewType": "Gallery",
|
|
}}},
|
|
}},
|
|
{"content": map[string]any{"status": "success"}},
|
|
}}
|
|
cmd := newAitableViewUpdateCardCommand(runner)
|
|
var out, errOut bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&errOut)
|
|
cmd.SetArgs([]string{
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--view-id", "VIEW_001",
|
|
"--cover-mode", "custom",
|
|
"--cover-field-id", "FIELD_001",
|
|
})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
|
}
|
|
if len(runner.calls) != 2 {
|
|
t.Fatalf("calls = %d, want 2", len(runner.calls))
|
|
}
|
|
update := runner.calls[1]
|
|
if update.Tool != "update_view" {
|
|
t.Fatalf("second tool = %q, want update_view", update.Tool)
|
|
}
|
|
config, ok := update.Params["config"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("config = %#v, want object", update.Params["config"])
|
|
}
|
|
card, ok := config["galleryCard"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("galleryCard = %#v, want object", config["galleryCard"])
|
|
}
|
|
if got := card["coverMode"]; got != "custom" {
|
|
t.Fatalf("coverMode = %#v, want custom", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableViewUpdateConfigRoutedKeyHintsSubcommand(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableViewUpdateCommand(runner)
|
|
var out, errOut bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetErr(&errOut)
|
|
cmd.SetArgs([]string{
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--view-id", "VIEW_001",
|
|
"--config", `{"frozenColCount":2}`,
|
|
})
|
|
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
|
|
}
|
|
if !strings.Contains(errOut.String(), "frozen-cols") {
|
|
t.Fatalf("stderr missing frozen-cols hint:\n%s", errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestAitableRecordHistoryListRoutesToHelper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableRecordHistoryListCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--record-id", "REC_001",
|
|
"--offset", "10",
|
|
"--limit", "30",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "query_record_history" {
|
|
t.Fatalf("Tool = %q, want query_record_history", got)
|
|
}
|
|
if got := runner.last.Params["recordId"]; got != "REC_001" {
|
|
t.Fatalf("recordId = %#v, want REC_001", got)
|
|
}
|
|
if got := runner.last.Params["offset"]; got != 10 {
|
|
t.Fatalf("offset = %#v, want 10", got)
|
|
}
|
|
if got := runner.last.Params["limit"]; got != 30 {
|
|
t.Fatalf("limit = %#v, want 30", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableRecordUpsertAcceptsFieldsAlias(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableRecordUpsertCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--fields", `[{"recordId":"REC_001","cells":{"fld":"updated"}},{"cells":{"fld":"new"}}]`,
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "record_upsert" {
|
|
t.Fatalf("Tool = %q, want record_upsert", got)
|
|
}
|
|
records, ok := runner.last.Params["records"].([]any)
|
|
if !ok {
|
|
t.Fatalf("records type = %T, want []any", runner.last.Params["records"])
|
|
}
|
|
if len(records) != 2 {
|
|
t.Fatalf("records len = %d, want 2", len(records))
|
|
}
|
|
}
|
|
|
|
func TestAitableViewExtraCommandsRouteToExpectedTools(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("lock unlock", func(t *testing.T) {
|
|
t.Parallel()
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableViewLockCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--view-id", "VIEW_001",
|
|
"--off",
|
|
)
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "lock_or_unlock_view" {
|
|
t.Fatalf("Tool = %q, want lock_or_unlock_view", got)
|
|
}
|
|
if got := runner.last.Params["action"]; got != "unlock" {
|
|
t.Fatalf("action = %#v, want unlock", got)
|
|
}
|
|
})
|
|
|
|
t.Run("fill color rule", func(t *testing.T) {
|
|
t.Parallel()
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableViewUpdateFillColorRuleCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--view-id", "VIEW_001",
|
|
"--json", `[]`,
|
|
)
|
|
if got := runner.last.CanonicalProduct; got != "aitable" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable", got)
|
|
}
|
|
if got := runner.last.Tool; got != "set_view_fill_color_rule" {
|
|
t.Fatalf("Tool = %q, want set_view_fill_color_rule", got)
|
|
}
|
|
if formats, ok := runner.last.Params["conditionalFormats"].([]any); !ok || len(formats) != 0 {
|
|
t.Fatalf("conditionalFormats = %#v, want empty []any", runner.last.Params["conditionalFormats"])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAitableWorkflowListRoutesToHelper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableWorkflowListCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--limit", "50",
|
|
"--offset", "100",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "list_workflows" {
|
|
t.Fatalf("Tool = %q, want list_workflows", got)
|
|
}
|
|
if got := runner.last.Params["limit"]; got != 50 {
|
|
t.Fatalf("limit = %#v, want 50", got)
|
|
}
|
|
if got := runner.last.Params["offset"]; got != 100 {
|
|
t.Fatalf("offset = %#v, want 100", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableRecordQueryEmptyRoutesToHelper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableRecordQueryEmptyCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--limit", "50",
|
|
"--cursor", "CUR_001",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "query_empty_records" {
|
|
t.Fatalf("Tool = %q, want query_empty_records", got)
|
|
}
|
|
if got := runner.last.Params["limit"]; got != 50 {
|
|
t.Fatalf("limit = %#v, want 50", got)
|
|
}
|
|
if got := runner.last.Params["cursor"]; got != "CUR_001" {
|
|
t.Fatalf("cursor = %#v, want CUR_001", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableRecordQueryEmptyRejectsOutOfRangeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableRecordQueryEmptyCommand(runner)
|
|
cmd.SetArgs([]string{
|
|
"--base-id", "BASE_001",
|
|
"--table-id", "TABLE_001",
|
|
"--limit", "200",
|
|
})
|
|
cmd.SetOut(&bytes.Buffer{})
|
|
cmd.SetErr(&bytes.Buffer{})
|
|
if err := cmd.Execute(); err == nil {
|
|
t.Fatal("expected error for --limit 200, got nil")
|
|
}
|
|
if runner.last.Tool != "" {
|
|
t.Fatalf("runner should not be called on invalid limit, got tool %q", runner.last.Tool)
|
|
}
|
|
}
|
|
|
|
func TestAitableDashboardArrangeRoutesToHelper(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableDashboardArrangeCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--dashboard-id", "DASH_001",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "align_dashboard" {
|
|
t.Fatalf("Tool = %q, want align_dashboard", got)
|
|
}
|
|
if got := runner.last.Params["dashboardId"]; got != "DASH_001" {
|
|
t.Fatalf("dashboardId = %#v, want DASH_001", got)
|
|
}
|
|
}
|
|
|
|
func TestAitableAdvpermRoleCreateParsesSubRoles(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableAdvpermRoleCreateCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--name", "市场可读",
|
|
"--sub-roles", `[{"targetId":"TABLE_001","targetType":"sheet","authLevel":"read"}]`,
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "create_role" {
|
|
t.Fatalf("Tool = %q, want create_role", got)
|
|
}
|
|
subRoles, ok := runner.last.Params["subRoles"].([]any)
|
|
if !ok || len(subRoles) != 1 {
|
|
t.Fatalf("subRoles = %#v, want single-item []any", runner.last.Params["subRoles"])
|
|
}
|
|
}
|
|
|
|
func TestAitableSectionMoveNodeAllowsRootParent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
runner := &aitableCommandRunner{}
|
|
cmd := newAitableSectionMoveNodeCommand(runner)
|
|
executeAitableExtraCommand(t, cmd,
|
|
"--base-id", "BASE_001",
|
|
"--node-id", "NODE_001",
|
|
"--new-parent-section-id", "",
|
|
"--target-index", "0",
|
|
)
|
|
|
|
if got := runner.last.CanonicalProduct; got != "aitable-helper" {
|
|
t.Fatalf("CanonicalProduct = %q, want aitable-helper", got)
|
|
}
|
|
if got := runner.last.Tool; got != "move_nsheet_node" {
|
|
t.Fatalf("Tool = %q, want move_nsheet_node", got)
|
|
}
|
|
if got := runner.last.Params["newParentSectionId"]; got != "" {
|
|
t.Fatalf("newParentSectionId = %#v, want empty string", got)
|
|
}
|
|
if got := runner.last.Params["targetIndex"]; got != 0 {
|
|
t.Fatalf("targetIndex = %#v, want 0", got)
|
|
}
|
|
}
|