Files
larksuite--cli/shortcuts/sheets/lark_sheet_batch_update_test.go
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

480 lines
16 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"testing"
)
// TestBatchUpdate_TranslatesShortcutToToolName verifies +batch-update
// translates each CLI-shape sub-op ({shortcut, input}) to the MCP-shape
// ({tool_name, input(+operation, +excel_id)}) before threading into
// the underlying batch_update tool. Covers continue_on_error too.
func TestBatchUpdate_TranslatesShortcutToToolName(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+cells-set","input":{"sheet_id":"sh1","range":"A1","cells":[[{"value":42}]]}},
{"shortcut":"+dim-insert","input":{"sheet_id":"sh1","position":"1","count":3}}
]`,
"--continue-on-error",
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations length = %d, want 2", len(ops))
}
if input["continue_on_error"] != true {
t.Errorf("continue_on_error = %v, want true", input["continue_on_error"])
}
// op[0]: +cells-set → set_cell_range, no operation field
op0 := ops[0].(map[string]interface{})
if op0["tool_name"] != "set_cell_range" {
t.Errorf("op[0].tool_name = %v, want set_cell_range", op0["tool_name"])
}
in0, _ := op0["input"].(map[string]interface{})
if in0["excel_id"] == nil {
t.Errorf("op[0].input.excel_id missing (translator should inject)")
}
if _, has := in0["operation"]; has {
t.Errorf("op[0].input.operation present, +cells-set should not inject one: %#v", in0)
}
// op[1]: +dim-insert → modify_sheet_structure + operation:"insert"
op1 := ops[1].(map[string]interface{})
if op1["tool_name"] != "modify_sheet_structure" {
t.Errorf("op[1].tool_name = %v, want modify_sheet_structure", op1["tool_name"])
}
in1, _ := op1["input"].(map[string]interface{})
if in1["operation"] != "insert" {
t.Errorf("op[1].input.operation = %v, want \"insert\"", in1["operation"])
}
}
func TestBatchUpdate_HighRiskWriteRequiresYes(t *testing.T) {
t.Parallel()
stdout, stderr, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+cells-set","input":{}}]`,
})
if err == nil {
t.Fatalf("expected confirmation_required; stdout=%s stderr=%s", stdout, stderr)
}
}
// TestCellsBatchSetStyle_FansOutOps verifies multiple ranges produce one
// set_cell_range op each, sharing the same style flags.
func TestCellsBatchSetStyle_FansOutOps(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchSetStyle, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:B2","sheet1!D1:E2","sheet1!A5:A6"]`,
"--font-weight", "bold",
"--background-color", "#ffff00",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 3 {
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
if op["tool_name"] != "set_cell_range" {
t.Errorf("op[%d].tool_name = %v, want set_cell_range", i, op["tool_name"])
}
params, _ := op["input"].(map[string]interface{})
if params["sheet_name"] != "sheet1" {
t.Errorf("op[%d].sheet_name = %v, want sheet1", i, params["sheet_name"])
}
cells, _ := params["cells"].([]interface{})
row, _ := cells[0].([]interface{})
cell, _ := row[0].(map[string]interface{})
style, _ := cell["cell_styles"].(map[string]interface{})
if style["font_weight"] != "bold" || style["background_color"] != "#ffff00" {
t.Errorf("op[%d] cell_styles wrong: %#v", i, style)
}
}
}
// TestCellsBatchClear_FansOutOps verifies multiple ranges produce one
// clear_cell_range op each, all sharing the same --scope-derived clear_type,
// with the sheet prefix split into sheet_name + bare range.
func TestCellsBatchClear_FansOutOps(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10","sheet2!C1:D5","sheet1!F3"]`,
"--scope", "all",
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 3 {
t.Fatalf("operations length = %d, want 3 (one per range)", len(ops))
}
wantSheet := []string{"sheet1", "sheet2", "sheet1"}
wantRange := []string{"A1:A10", "C1:D5", "F3"}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
if op["tool_name"] != "clear_cell_range" {
t.Errorf("op[%d].tool_name = %v, want clear_cell_range", i, op["tool_name"])
}
params, _ := op["input"].(map[string]interface{})
if params["sheet_name"] != wantSheet[i] {
t.Errorf("op[%d].sheet_name = %v, want %s", i, params["sheet_name"], wantSheet[i])
}
if params["range"] != wantRange[i] {
t.Errorf("op[%d].range = %v, want %s", i, params["range"], wantRange[i])
}
if params["clear_type"] != "all" {
t.Errorf("op[%d].clear_type = %v, want all", i, params["clear_type"])
}
}
}
// TestCellsBatchClear_ScopeDefaultsToContents verifies the default --scope
// (content) maps to the tool's clear_type "contents" — identical to the
// standalone +cells-clear normalization.
func TestCellsBatchClear_ScopeDefaultsToContents(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:B2"]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
params, _ := ops[0].(map[string]interface{})["input"].(map[string]interface{})
if params["clear_type"] != "contents" {
t.Errorf("clear_type = %v, want contents (default scope)", params["clear_type"])
}
}
// TestCellsBatchClear_Guards covers the sheet-prefix requirement and the
// high-risk-write confirmation gate.
func TestCellsBatchClear_Guards(t *testing.T) {
t.Parallel()
// sheetless range → prefix guard (shared with the dropdown fan-outs).
_, _, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["A1:A10"]`,
"--yes",
"--dry-run",
})
requireValidation(t, err, "must include a sheet prefix")
// missing --yes → confirmation_required (high-risk-write).
stdout, stderr, err := runShortcutCapturingErr(t, CellsBatchClear, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A10"]`,
})
if err == nil {
t.Errorf("expected confirmation_required without --yes; stdout=%s stderr=%s", stdout, stderr)
}
}
// TestDropdownUpdate_BatchPayload verifies the multi-range dropdown
// update fans out into a single batch_update with one set_cell_range
// op per range. Also covers --colors / --highlight -> highlight_colors
// / enable_highlight propagation through dropdownBatchInput.
func TestDropdownUpdate_BatchPayload(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
"--options", `["a","b","c"]`,
"--colors", `["#FFE699","#bff7d9","#ffb3b3"]`,
"--multiple", "--highlight",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations length = %d, want 2", len(ops))
}
for i, raw := range ops {
op, _ := raw.(map[string]interface{})
params, _ := op["input"].(map[string]interface{})
cells, _ := params["cells"].([]interface{})
if len(cells) != 4 {
t.Errorf("op[%d] cells rows = %d, want 4 (A2:A5 / C2:C5)", i, len(cells))
}
row0, _ := cells[0].([]interface{})
cell, _ := row0[0].(map[string]interface{})
dv, _ := cell["data_validation"].(map[string]interface{})
if dv == nil || dv["type"] != "list" {
t.Errorf("op[%d] missing data_validation list: %#v", i, cell)
}
items, _ := dv["items"].([]interface{})
if len(items) != 3 {
t.Errorf("op[%d] data_validation.items length = %d, want 3", i, len(items))
}
if dv["support_multiple_values"] != true {
t.Errorf("op[%d] support_multiple_values = %v, want true", i, dv["support_multiple_values"])
}
colors, _ := dv["highlight_colors"].([]interface{})
if len(colors) != 3 {
t.Errorf("op[%d] highlight_colors length = %d, want 3", i, len(colors))
}
if dv["enable_highlight"] != true {
t.Errorf("op[%d] enable_highlight = %v, want true", i, dv["enable_highlight"])
}
}
}
// TestDropdownDelete_BatchClearsValidation verifies delete sets
// data_validation: null on every cell.
func TestDropdownDelete_BatchClearsValidation(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, DropdownDelete, []string{
"--url", testURL,
"--ranges", `["sheet1!A2:A4"]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
if len(ops) != 1 {
t.Fatalf("operations length = %d, want 1", len(ops))
}
op := ops[0].(map[string]interface{})
params, _ := op["input"].(map[string]interface{})
cells, _ := params["cells"].([]interface{})
for i, raw := range cells {
row, _ := raw.([]interface{})
cell, _ := row[0].(map[string]interface{})
if _, present := cell["data_validation"]; !present {
t.Errorf("row %d: data_validation key missing", i)
continue
}
if cell["data_validation"] != nil {
t.Errorf("row %d: data_validation = %v, want null", i, cell["data_validation"])
}
}
}
func TestBatchUpdate_ValidationGuards(t *testing.T) {
t.Parallel()
// dropdown-update with sheetless range
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["A2:A5"]`,
"--options", `["a"]`,
"--dry-run",
})
requireValidation(t, err, "must include a sheet prefix")
// batch-update with empty operations
_, _, err = runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[]`,
"--yes",
"--dry-run",
})
requireValidation(t, err, "non-empty JSON array")
// dropdown-update with non-array --options (object instead) → array guard
// (now via schema validator at parseJSONFlag time)
_, _, err = runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", `["sheet1!A1:A2"]`,
"--options", `{"not":"array"}`,
"--dry-run",
})
requireValidation(t, err, `expected type "array"`)
}
// TestValidateDropdownRanges_RejectsMalformedRange locks the up-front sheet!range
// validation: entries that merely contain "!" but are otherwise malformed (empty
// sheet, empty range, or an unparseable A1 ref) must fail at Validate rather than
// slip through to DryRun/Execute. Covers +dropdown-update / +dropdown-delete,
// which fan out over --ranges.
func TestValidateDropdownRanges_RejectsMalformedRange(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ranges string
want string
}{
{"no sheet prefix at all", `["A1:A5"]`, "must include a sheet prefix"},
{"empty sheet name", `["!A1:A5"]`, "must use sheet!range form"},
{"empty range after prefix", `["Sheet1!"]`, "must use sheet!range form"},
{"unparseable ref", `["Sheet1!bad"]`, "invalid cell ref"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, DropdownUpdate, []string{
"--url", testURL,
"--ranges", tc.ranges,
"--options", `["a"]`,
"--dry-run",
})
requireValidation(t, err, tc.want)
})
}
}
// TestBatchUpdate_TranslatorRejects covers per-op shape errors caught by
// translateBatchOp: unknown shortcut, missing shortcut, banned (read /
// fan-out / legacy v2) shortcuts, hand-filled reserved keys, etc.
func TestBatchUpdate_TranslatorRejects(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opsJSON string
wantMatch string
}{
{
name: "missing shortcut field",
opsJSON: `[{"input":{"range":"A1"}}]`,
wantMatch: "'shortcut' field is required",
},
{
name: "empty shortcut string",
opsJSON: `[{"shortcut":"","input":{}}]`,
wantMatch: "'shortcut' must be a non-empty string",
},
{
name: "unknown shortcut",
opsJSON: `[{"shortcut":"+cells-set-magic","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "read op rejected",
opsJSON: `[{"shortcut":"+cells-get","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "nested batch-update rejected",
opsJSON: `[{"shortcut":"+batch-update","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "fan-out wrapper rejected",
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "fan-out wrapper +cells-batch-clear rejected",
opsJSON: `[{"shortcut":"+cells-batch-clear","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "legacy v2 +dim-move rejected",
opsJSON: `[{"shortcut":"+dim-move","input":{}}]`,
wantMatch: "not allowed in +batch-update",
},
{
name: "user filled operation manually",
opsJSON: `[{"shortcut":"+dim-insert","input":{"operation":"delete","position":"1","count":1}}]`,
wantMatch: "do not pass input.operation",
},
{
name: "user filled excel_id",
opsJSON: `[{"shortcut":"+cells-set","input":{"excel_id":"shtcnX","range":"A1"}}]`,
wantMatch: "do not pass input.excel_id",
},
{
name: "user filled url",
opsJSON: `[{"shortcut":"+cells-set","input":{"url":"https://x.feishu.cn/sheets/sh","range":"A1"}}]`,
wantMatch: "do not pass input.url",
},
{
name: "extra top-level key",
opsJSON: `[{"shortcut":"+cells-set","input":{"range":"A1"},"tool_name":"oops"}]`,
wantMatch: "unknown top-level key",
},
{
name: "sub-op not an object",
opsJSON: `["not-an-object"]`,
wantMatch: "must be a JSON object",
},
{
name: "input not an object",
opsJSON: `[{"shortcut":"+cells-set","input":"not-an-object"}]`,
wantMatch: "'input' must be a JSON object",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
requireValidation(t, err, tc.wantMatch)
})
}
}
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze
// path of the single shortcut is intentionally not supported in batch).
func TestBatchUpdate_DimFreezeInjectsFreeze(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+dim-freeze","input":{"sheet_id":"sh1","dimension":"row","count":2}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops, _ := input["operations"].([]interface{})
op := ops[0].(map[string]interface{})
if op["tool_name"] != "modify_sheet_structure" {
t.Errorf("tool_name = %v, want modify_sheet_structure", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
if in["operation"] != "freeze" {
t.Errorf("operation = %v, want \"freeze\"", in["operation"])
}
}
// TestBatchUpdate_ResizeNoOperationField covers the resize_range dispatch:
// mapping has no operationField, so input.operation must NOT be injected.
func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
op := input["operations"].([]interface{})[0].(map[string]interface{})
if op["tool_name"] != "resize_range" {
t.Errorf("tool_name = %v, want resize_range", op["tool_name"])
}
in, _ := op["input"].(map[string]interface{})
if _, has := in["operation"]; has {
t.Errorf("operation should NOT be injected for resize_range; got %#v", in)
}
}
// TestSplitSheetPrefixedRange exercises the helper directly.
func TestSplitSheetPrefixedRange(t *testing.T) {
t.Parallel()
sheet, sub, err := splitSheetPrefixedRange("sheet1!A2:A100")
if err != nil || sheet != "sheet1" || sub != "A2:A100" {
t.Errorf("split = (%q,%q,%v), want (sheet1, A2:A100, nil)", sheet, sub, err)
}
if _, _, err := splitSheetPrefixedRange("A2:A100"); err == nil {
t.Error("expected error on missing prefix")
}
if _, _, err := splitSheetPrefixedRange("!A2"); err == nil {
t.Error("expected error on empty sheet name")
}
// Compile-time use of json import
_ = json.Marshal
}