Files
dingtalk-workspace-cli/internal/helpers/aitable_extra_test.go
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
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.
2026-06-26 21:49:38 +08:00

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)
}
}