mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
Emit structured validation, API, network, file, and internal error envelopes for Sheets shortcuts so users and agents can recover from failed spreadsheet workflows using stable type, subtype, param, and code fields. Add Sheets domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
591 lines
22 KiB
Go
591 lines
22 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
)
|
|
|
|
// TestExecute_WorkbookInfo_Happy stubs the invoke_read endpoint and
|
|
// verifies the shortcut decodes the JSON-string output, surfaces it as
|
|
// envelope data, and finishes without error.
|
|
func TestExecute_WorkbookInfo_Happy(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"Sheet1","row_count":1000,"column_count":26,"index":0}]}`)
|
|
out, err := runShortcutWithStubs(t, WorkbookInfo, []string{"--url", testURL}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
data := decodeEnvelopeData(t, out)
|
|
sheets, _ := data["sheets"].([]interface{})
|
|
if len(sheets) != 1 {
|
|
t.Fatalf("sheets len = %d, want 1", len(sheets))
|
|
}
|
|
sheet, _ := sheets[0].(map[string]interface{})
|
|
if sheet["sheet_id"] != "sh1" || sheet["title"] != "Sheet1" {
|
|
t.Errorf("unexpected sheet: %#v", sheet)
|
|
}
|
|
}
|
|
|
|
// TestExecute_WorkbookInfo_ToolError surfaces a non-zero code in the
|
|
// envelope shape and asserts CLI returns an error envelope.
|
|
func TestExecute_WorkbookInfo_ToolError(t *testing.T) {
|
|
t.Parallel()
|
|
stub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheet_ai/v2/spreadsheets/" + testToken + "/tools/invoke_read",
|
|
Body: map[string]interface{}{
|
|
"code": 1310201,
|
|
"msg": "spreadsheet not found",
|
|
"data": map[string]interface{}{},
|
|
},
|
|
}
|
|
stdout, stderr, err := func() (string, string, error) {
|
|
parent, stdout, stderr, reg := newTestRig(t, WorkbookInfo)
|
|
reg.Register(stub)
|
|
parent.SetArgs([]string{"+workbook-info", "--url", testURL})
|
|
err := parent.Execute()
|
|
return stdout.String(), stderr.String(), err
|
|
}()
|
|
if err == nil {
|
|
t.Fatalf("expected non-zero code to surface as error; stdout=%s stderr=%s", stdout, stderr)
|
|
}
|
|
combined := stdout + stderr + err.Error()
|
|
if !strings.Contains(combined, "1310201") && !strings.Contains(combined, "not found") {
|
|
t.Errorf("expected error code in envelope; got=%s|%s|%v", stdout, stderr, err)
|
|
}
|
|
}
|
|
|
|
// TestExecute_SheetMove_LookupsIndex covers the two-step path: SheetMove
|
|
// when only --sheet-name is given (and --source-index omitted) first
|
|
// reads the workbook structure to derive sheet_id + source_index, then
|
|
// posts the modify_workbook_structure call.
|
|
func TestExecute_SheetMove_LookupsIndex(t *testing.T) {
|
|
t.Parallel()
|
|
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","sheet_name":"汇总","index":3}]}`)
|
|
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
|
|
out, err := runShortcutWithStubs(t, SheetMove,
|
|
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
|
lookup, move,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
// Inspect the captured move body: source_index should be 3 (looked up),
|
|
// not <resolve>, and sheet_id should be the resolved id.
|
|
if move.CapturedBody == nil {
|
|
t.Fatal("move stub didn't capture a body")
|
|
}
|
|
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
|
input := decodeToolInput(t, body, "modify_workbook_structure")
|
|
if input["sheet_id"] != "sh1" {
|
|
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name)", input["sheet_id"])
|
|
}
|
|
if input["source_index"].(float64) != 3 {
|
|
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
|
|
}
|
|
if input["target_index"].(float64) != 0 {
|
|
t.Errorf("target_index = %v, want 0", input["target_index"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_SheetMove_LookupsIndexByTitle covers the same lookup path as
|
|
// above but with get_workbook_structure exposing the display name as "title"
|
|
// (the field the real tool returns) instead of "sheet_name". lookupSheetIndex
|
|
// must resolve --sheet-name against either key.
|
|
func TestExecute_SheetMove_LookupsIndexByTitle(t *testing.T) {
|
|
t.Parallel()
|
|
lookup := toolOutputStub(testToken, "read", `{"sheets":[{"sheet_id":"sh1","title":"汇总","index":3}]}`)
|
|
move := toolOutputStub(testToken, "write", `{"sheet_id":"sh1"}`)
|
|
out, err := runShortcutWithStubs(t, SheetMove,
|
|
[]string{"--url", testURL, "--sheet-name", "汇总", "--index", "0"},
|
|
lookup, move,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
if move.CapturedBody == nil {
|
|
t.Fatal("move stub didn't capture a body")
|
|
}
|
|
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
|
input := decodeToolInput(t, body, "modify_workbook_structure")
|
|
if input["sheet_id"] != "sh1" {
|
|
t.Errorf("sheet_id = %v, want sh1 (resolved from --sheet-name via title)", input["sheet_id"])
|
|
}
|
|
if input["source_index"].(float64) != 3 {
|
|
t.Errorf("source_index = %v, want 3 (from lookup)", input["source_index"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_CellsGet covers a multi-range read end-to-end.
|
|
func TestExecute_CellsGet(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "read", `{"ranges":[{"range":"A1:B2","cells":[[{"value":1}]]}]}`)
|
|
out, err := runShortcutWithStubs(t, CellsGet,
|
|
[]string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2"}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
if data := decodeEnvelopeData(t, out); data["ranges"] == nil {
|
|
t.Fatalf("expected ranges in output; got=%#v", data)
|
|
}
|
|
}
|
|
|
|
// TestExecute_CellsSet covers the write path including allow-overwrite
|
|
// override.
|
|
func TestExecute_CellsSet(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"updated_cells":2}`)
|
|
out, err := runShortcutWithStubs(t, CellsSet, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--range", "A1:B1",
|
|
"--cells", `[[{"value":"x"},{"value":"y"}]]`,
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "set_cell_range")
|
|
if input["range"] != "A1:B1" {
|
|
t.Errorf("wire range = %v", input["range"])
|
|
}
|
|
if data := decodeEnvelopeData(t, out); data["updated_cells"].(float64) != 2 {
|
|
t.Errorf("updated_cells = %v", data["updated_cells"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_DropdownSet covers the fan-out → set_cell_range write.
|
|
func TestExecute_DropdownSet(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{}`)
|
|
_, err := runShortcutWithStubs(t, DropdownSet, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--range", "A2:A4",
|
|
"--options", `["x","y"]`,
|
|
"--multiple",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "set_cell_range")
|
|
cells, _ := input["cells"].([]interface{})
|
|
if len(cells) != 3 {
|
|
t.Errorf("wire cells rows = %d, want 3", len(cells))
|
|
}
|
|
}
|
|
|
|
// TestExecute_DropdownUpdate_Batch covers the batch_update fan-out for
|
|
// dropdown-update. Verifies the captured request has 2 ops.
|
|
func TestExecute_DropdownUpdate_Batch(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true},{"ok":true}]}`)
|
|
_, err := runShortcutWithStubs(t, DropdownUpdate, []string{
|
|
"--url", testURL,
|
|
"--ranges", `["sheet1!A2:A5","sheet1!C2:C5"]`,
|
|
"--options", `["a","b"]`,
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "batch_update")
|
|
ops, _ := input["operations"].([]interface{})
|
|
if len(ops) != 2 {
|
|
t.Errorf("operations len = %d, want 2", len(ops))
|
|
}
|
|
}
|
|
|
|
// TestExecute_CellsSearch covers the search read path with options.
|
|
func TestExecute_CellsSearch(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "read", `{"matches":[{"cell":"B2"}],"has_more":false}`)
|
|
out, err := runShortcutWithStubs(t, CellsSearch, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--find", "foo", "--match-case",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
data := decodeEnvelopeData(t, out)
|
|
if data["matches"] == nil {
|
|
t.Errorf("matches missing: %#v", data)
|
|
}
|
|
}
|
|
|
|
// TestExecute_RangeMove covers the transform_range write path.
|
|
func TestExecute_RangeMove(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"moved":true}`)
|
|
out, err := runShortcutWithStubs(t, RangeMove, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--source-range", "A1:C5",
|
|
"--target-range", "D1",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "transform_range")
|
|
if input["operation"] != "move" {
|
|
t.Errorf("operation = %v, want move", input["operation"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_FilterCreate covers the filter special case (range mandatory,
|
|
// optional --data conditions merge).
|
|
func TestExecute_FilterCreate(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"filter_id":"sh1"}`)
|
|
out, err := runShortcutWithStubs(t, FilterCreate, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--range", "A1:F100",
|
|
"--properties", `{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["x"]}]}]}`,
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "manage_filter_object")
|
|
props, _ := input["properties"].(map[string]interface{})
|
|
if props["range"] != "A1:F100" {
|
|
t.Errorf("properties.range = %v", props["range"])
|
|
}
|
|
if props["rules"] == nil {
|
|
t.Errorf("rules missing: %#v", props)
|
|
}
|
|
}
|
|
|
|
// TestExecute_BatchUpdate_Translated covers the CLI-shape → MCP-shape
|
|
// translation: user passes {shortcut, input}, batchOpDispatch maps it to
|
|
// {tool_name, input(+operation, +excel_id)} before the tool call. Also
|
|
// verifies --continue-on-error.
|
|
func TestExecute_BatchUpdate_Translated(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
|
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
|
"--url", testURL,
|
|
"--operations", `[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}]`,
|
|
"--continue-on-error",
|
|
"--yes",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "batch_update")
|
|
if input["continue_on_error"] != true {
|
|
t.Errorf("continue_on_error not propagated: %#v", input)
|
|
}
|
|
ops, _ := input["operations"].([]interface{})
|
|
if len(ops) != 1 {
|
|
t.Fatalf("operations length = %d, want 1", len(ops))
|
|
}
|
|
op := ops[0].(map[string]interface{})
|
|
if op["tool_name"] != "set_cell_range" {
|
|
t.Errorf("op.tool_name = %v, want set_cell_range (translated from +cells-set)", op["tool_name"])
|
|
}
|
|
subInput, _ := op["input"].(map[string]interface{})
|
|
if subInput["excel_id"] != testToken {
|
|
t.Errorf("op.input.excel_id = %v, want %s (translator should inject)", subInput["excel_id"], testToken)
|
|
}
|
|
if _, has := subInput["operation"]; has {
|
|
t.Errorf("op.input.operation present but +cells-set should not inject one: %#v", subInput)
|
|
}
|
|
}
|
|
|
|
// TestExecute_BatchUpdate_ContinueOnErrorPrecedence locks the flag-vs-envelope
|
|
// precedence: an explicit --continue-on-error=false must keep the strict
|
|
// transaction even when the --operations envelope carries continue_on_error:true,
|
|
// while an envelope value still applies when the flag is absent. Guards against
|
|
// the regression where the flag was read by value (runtime.Bool) rather than by
|
|
// Changed().
|
|
func TestExecute_BatchUpdate_ContinueOnErrorPrecedence(t *testing.T) {
|
|
t.Parallel()
|
|
envelope := `{"operations":[{"shortcut":"+cells-set","input":{"sheet-id":"sh1","range":"A1","cells":[[{"value":1}]]}}],"continue_on_error":true}`
|
|
|
|
t.Run("explicit false overrides envelope", func(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
|
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
|
"--url", testURL,
|
|
"--operations", envelope,
|
|
"--continue-on-error=false",
|
|
"--yes",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
|
|
if input["continue_on_error"] == true {
|
|
t.Errorf("explicit --continue-on-error=false must win over envelope; got continue_on_error=%#v", input["continue_on_error"])
|
|
}
|
|
})
|
|
|
|
t.Run("envelope applies when flag absent", func(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"results":[{"ok":true}]}`)
|
|
_, err := runShortcutWithStubs(t, BatchUpdate, []string{
|
|
"--url", testURL,
|
|
"--operations", envelope,
|
|
"--yes",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
input := decodeToolInput(t, decodeRawEnvelopeBody(t, stub.CapturedBody), "batch_update")
|
|
if input["continue_on_error"] != true {
|
|
t.Errorf("envelope continue_on_error:true should apply when --continue-on-error absent; got %#v", input["continue_on_error"])
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestExecute_WorkbookCreate covers the create POST + first-sheet lookup +
|
|
// set_cell_range follow-up. Stubs all three endpoints.
|
|
func TestExecute_WorkbookCreate(t *testing.T) {
|
|
t.Parallel()
|
|
create := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheets/v3/spreadsheets",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{
|
|
"spreadsheet": map[string]interface{}{
|
|
"spreadsheet_token": "shtcnBRAND",
|
|
"title": "Sales",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
// Initial fill first reads the workbook structure to resolve the default
|
|
// sheet's id (the create response doesn't echo it), then writes.
|
|
structure := toolOutputStub("shtcnBRAND", "read", `{"sheets":[{"sheet_id":"shtFirst","sheet_name":"Sheet1","index":0}]}`)
|
|
fill := toolOutputStub("shtcnBRAND", "write", `{"updated_cells":4}`)
|
|
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{
|
|
"--title", "Sales",
|
|
"--headers", `["Name","Score"]`,
|
|
"--values", `[["alice",95]]`,
|
|
}, create, structure, fill)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
data := decodeEnvelopeData(t, out)
|
|
ss, _ := data["spreadsheet"].(map[string]interface{})
|
|
if ss["spreadsheet_token"] != "shtcnBRAND" {
|
|
t.Errorf("spreadsheet_token = %v", ss["spreadsheet_token"])
|
|
}
|
|
if data["initial_fill"] == nil {
|
|
t.Errorf("initial_fill missing in envelope")
|
|
}
|
|
// The fill must target the resolved first sheet, not an empty selector.
|
|
fillInput := decodeToolInput(t, decodeRawEnvelopeBody(t, fill.CapturedBody), "set_cell_range")
|
|
if fillInput["sheet_id"] != "shtFirst" {
|
|
t.Errorf("fill sheet_id = %v, want shtFirst (resolved from workbook structure)", fillInput["sheet_id"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_WorkbookCreate_EmptyArraysSkipFill locks the fix for the nil-map
|
|
// panic / illegal-range bug: --values '[]' or --headers '[]' must short-circuit
|
|
// the initial fill (no structure/fill calls fire) and finish with the
|
|
// spreadsheet created but no initial_fill — never panic on a nil fill map.
|
|
func TestExecute_WorkbookCreate_EmptyArraysSkipFill(t *testing.T) {
|
|
t.Parallel()
|
|
for _, tc := range []struct{ name, flag, val string }{
|
|
{"empty values", "--values", "[]"},
|
|
{"empty headers", "--headers", "[]"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
create := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheets/v3/spreadsheets",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{
|
|
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
|
|
},
|
|
},
|
|
}
|
|
// Only the create stub is provided: an empty array must skip the fill
|
|
// entirely, so no structure/fill call fires (and no nil-map panic).
|
|
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", tc.flag, tc.val}, create)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
data := decodeEnvelopeData(t, out)
|
|
if data["initial_fill"] != nil {
|
|
t.Errorf("initial_fill should be absent for %s %s; got %#v", tc.flag, tc.val, data["initial_fill"])
|
|
}
|
|
if ss, _ := data["spreadsheet"].(map[string]interface{}); ss["spreadsheet_token"] != "shtNEW" {
|
|
t.Errorf("spreadsheet_token = %v, want shtNEW", ss["spreadsheet_token"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExecute_WorkbookCreate_FillFailureKeepsToken locks the partial-success
|
|
// contract: when the spreadsheet is created but the follow-up fill can't resolve
|
|
// its first sheet, the error must be structured and retain spreadsheet_token so
|
|
// the caller can recover instead of orphaning the new workbook.
|
|
func TestExecute_WorkbookCreate_FillFailureKeepsToken(t *testing.T) {
|
|
t.Parallel()
|
|
create := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheets/v3/spreadsheets",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "success",
|
|
"data": map[string]interface{}{
|
|
"spreadsheet": map[string]interface{}{"spreadsheet_token": "shtNEW", "title": "X"},
|
|
},
|
|
},
|
|
}
|
|
// Structure comes back with no sheets, so lookupFirstSheetID fails AFTER the
|
|
// spreadsheet already exists — exercising the partial-success path.
|
|
structure := toolOutputStub("shtNEW", "read", `{"sheets":[]}`)
|
|
out, err := runShortcutWithStubs(t, WorkbookCreate, []string{"--title", "X", "--values", `[["a"]]`}, create, structure)
|
|
if err == nil {
|
|
t.Fatalf("expected a partial-success error; got nil\nout=%s", out)
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("error type = %T, want typed problem", err)
|
|
}
|
|
if p.Subtype != errs.SubtypeFailedPrecondition {
|
|
t.Errorf("subtype = %q, want failed_precondition (the spreadsheet exists; caller must change state, not retry)", p.Subtype)
|
|
}
|
|
if !strings.Contains(p.Message, "shtNEW") {
|
|
t.Errorf("message = %q, want spreadsheet token for recovery", p.Message)
|
|
}
|
|
if !strings.Contains(p.Hint, "spreadsheet_token") {
|
|
t.Errorf("hint = %q, want recovery guidance naming spreadsheet_token", p.Hint)
|
|
}
|
|
// The underlying fill failure is preserved as the cause so its subtype and
|
|
// log_id stay diagnosable rather than being flattened into the message.
|
|
inner := errors.Unwrap(err)
|
|
if inner == nil {
|
|
t.Fatalf("expected the underlying fill failure preserved as the cause")
|
|
}
|
|
if ip, ok := errs.ProblemOf(inner); !ok || ip.Subtype != errs.SubtypeInvalidResponse {
|
|
t.Errorf("cause = %v, want the underlying invalid_response failure preserved for diagnosis", inner)
|
|
}
|
|
}
|
|
|
|
// TestExecute_DimMove covers the native v3 move_dimension call. CLI's
|
|
// --source-range "1:3" (1-based inclusive) is parsed into v3's
|
|
// source.{start_index=0,end_index=2} (0-based inclusive); --target "11" is
|
|
// parsed into destination_index=10.
|
|
func TestExecute_DimMove(t *testing.T) {
|
|
t.Parallel()
|
|
move := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/sheets/v3/spreadsheets/" + testToken + "/sheets/" + testSheetID + "/move_dimension",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"msg": "success",
|
|
"data": map[string]interface{}{"moved": true},
|
|
},
|
|
}
|
|
_, err := runShortcutWithStubs(t, DimMove, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--source-range", "1:3", "--target", "11",
|
|
}, move)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, move.CapturedBody)
|
|
src, _ := body["source"].(map[string]interface{})
|
|
if src["start_index"].(float64) != 0 || src["end_index"].(float64) != 2 {
|
|
t.Errorf("indices = (%v,%v), want (0,2) — 0-based inclusive", src["start_index"], src["end_index"])
|
|
}
|
|
if body["destination_index"].(float64) != 10 {
|
|
t.Errorf("destination_index = %v, want 10", body["destination_index"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_ChartCreate covers the object-CRUD factory's create path.
|
|
func TestExecute_ChartCreate(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"chart_id":"chartNEW"}`)
|
|
out, err := runShortcutWithStubs(t, ChartCreate, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--properties", `{"type":"line","position":{"row":0,"col":"A"},"size":{"width":400,"height":300}}`,
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
data := decodeEnvelopeData(t, out)
|
|
if data["chart_id"] != "chartNEW" {
|
|
t.Errorf("chart_id = %v", data["chart_id"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_SheetCreate hits the workbook write path with all four
|
|
// optional flags so the input builder + callTool wiring is exercised.
|
|
func TestExecute_SheetCreate(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"sheet_id":"sh99","sheet_name":"Q4","index":2}`)
|
|
out, err := runShortcutWithStubs(t, SheetCreate, []string{
|
|
"--url", testURL,
|
|
"--title", "Q4",
|
|
"--index", "2",
|
|
"--row-count", "300",
|
|
"--col-count", "12",
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v\nout=%s", err, out)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "modify_workbook_structure")
|
|
if input["operation"] != "create" || input["sheet_name"] != "Q4" {
|
|
t.Errorf("input shape wrong: %#v", input)
|
|
}
|
|
if input["rows"].(float64) != 300 || input["columns"].(float64) != 12 {
|
|
t.Errorf("dimensions = (%v, %v), want (300, 12)", input["rows"], input["columns"])
|
|
}
|
|
}
|
|
|
|
// TestExecute_RangeSort exercises the sort_conditions JSON parsing
|
|
// alongside the boolean has_header.
|
|
func TestExecute_RangeSort(t *testing.T) {
|
|
t.Parallel()
|
|
stub := toolOutputStub(testToken, "write", `{"sorted":true}`)
|
|
_, err := runShortcutWithStubs(t, RangeSort, []string{
|
|
"--url", testURL, "--sheet-id", testSheetID,
|
|
"--range", "A1:D50",
|
|
"--has-header",
|
|
"--sort-keys", `[{"column":"B","ascending":true}]`,
|
|
}, stub)
|
|
if err != nil {
|
|
t.Fatalf("execute failed: %v", err)
|
|
}
|
|
body := decodeRawEnvelopeBody(t, stub.CapturedBody)
|
|
input := decodeToolInput(t, body, "transform_range")
|
|
if input["operation"] != "sort" || input["has_header"] != true {
|
|
t.Errorf("input wrong: %#v", input)
|
|
}
|
|
conds, _ := input["sort_conditions"].([]interface{})
|
|
if len(conds) != 1 {
|
|
t.Errorf("sort_conditions len = %d", len(conds))
|
|
}
|
|
}
|
|
|
|
// decodeRawEnvelopeBody parses the raw JSON request body captured by an
|
|
// httpmock stub. Used by execute tests to inspect what the CLI sent on
|
|
// the wire (vs. dry-run tests that render the body up-front).
|
|
func decodeRawEnvelopeBody(t *testing.T, raw []byte) map[string]interface{} {
|
|
t.Helper()
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
t.Fatalf("captured body parse error: %v\nraw=%s", err, string(raw))
|
|
}
|
|
return body
|
|
}
|