diff --git a/shortcuts/sheets/backward/lark_sheets_float_images.go b/shortcuts/sheets/backward/lark_sheets_float_images.go index b1c62331..a117bbc2 100644 --- a/shortcuts/sheets/backward/lark_sheets_float_images.go +++ b/shortcuts/sheets/backward/lark_sheets_float_images.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "path/filepath" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" @@ -14,7 +15,25 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const sheetImageParentType = "sheet_image" +// Drive media parent_type values for uploading an image into a spreadsheet. +// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a +// synthetic token prefixed with "fake_office_" and the backend requires +// "office_sheet_file" instead. +const ( + sheetImageParentType = "sheet_image" + officeSheetFileParentType = "office_sheet_file" + fakeOfficeTokenPrefix = "fake_office_" +) + +// sheetMediaParentType returns the drive media parent_type to use when +// uploading an image whose parent_node is spreadsheetToken, mapping the +// "fake_office_" imported-spreadsheet token prefix to "office_sheet_file". +func sheetMediaParentType(spreadsheetToken string) string { + if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { + return officeSheetFileParentType + } + return sheetImageParentType +} var SheetMediaUpload = common.Shortcut{ Service: "sheets", @@ -49,7 +68,7 @@ var SheetMediaUpload = common.Shortcut{ POST("/open-apis/drive/v1/medias/upload_prepare"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": sheetImageParentType, + "parent_type": sheetMediaParentType(parentNode), "parent_node": parentNode, "size": "", }). @@ -71,7 +90,7 @@ var SheetMediaUpload = common.Shortcut{ POST("/open-apis/drive/v1/medias/upload_all"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": sheetImageParentType, + "parent_type": sheetMediaParentType(parentNode), "parent_node": parentNode, "size": "", "file": "@" + filePath, @@ -141,13 +160,14 @@ func resolveSheetMediaUploadParent(runtime *common.RuntimeContext) (string, erro } func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName string, fileSize int64, parentNode string) (string, error) { + parentType := sheetMediaParentType(parentNode) if fileSize <= common.MaxDriveMediaUploadSinglePartSize { pn := parentNode return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ FilePath: filePath, FileName: fileName, FileSize: fileSize, - ParentType: sheetImageParentType, + ParentType: parentType, ParentNode: &pn, }) } @@ -155,7 +175,7 @@ func uploadSheetMediaFile(runtime *common.RuntimeContext, filePath, fileName str FilePath: filePath, FileName: fileName, FileSize: fileSize, - ParentType: sheetImageParentType, + ParentType: parentType, ParentNode: parentNode, }) } diff --git a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go index 03b10416..40b31d31 100644 --- a/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go +++ b/shortcuts/sheets/backward/lark_sheets_sheet_media_upload_test.go @@ -91,6 +91,39 @@ func TestSheetMediaUploadDryRunSmallFile(t *testing.T) { } } +// TestSheetMediaUploadDryRunSmallFileOfficeParentType pins the small-file +// upload_all dry-run preview to the token-derived parent_type so the preview +// agents/users will copy matches what Execute actually sends. Without this the +// multipart dry-run branch could drift back to a hard-coded "sheet_image". +func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig()) + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", "fake_office_abc123", + "--file", "img.png", + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/open-apis/drive/v1/medias/upload_all") { + t.Fatalf("dry-run should use upload_all for small file, got: %s", out) + } + if !strings.Contains(out, `"office_sheet_file"`) { + t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out) + } + if strings.Contains(out, `"sheet_image"`) { + t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out) + } +} + func TestSheetMediaUploadDryRunURLExtractsToken(t *testing.T) { dir := t.TempDir() withSheetsTestWorkingDir(t, dir) @@ -205,6 +238,47 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) { } } +// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported +// "office" spreadsheet (token prefixed with "fake_office_") uploads with +// parent_type=office_sheet_file instead of the native sheet_image. +func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) { + dir := t.TempDir() + withSheetsTestWorkingDir(t, dir) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + const officeToken = "fake_office_abc123" + err := mountAndRunSheets(t, SheetMediaUpload, []string{ + "+media-upload", + "--spreadsheet-token", officeToken, + "--file", "img.png", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeSheetsMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != officeSheetFileParentType { + t.Fatalf("parent_type = %q, want %q", got, officeSheetFileParentType) + } + if got := body.Fields["parent_node"]; got != officeToken { + t.Fatalf("parent_node = %q, want %q", got, officeToken) + } +} + func TestSheetMediaUploadFileNotFound(t *testing.T) { dir := t.TempDir() withSheetsTestWorkingDir(t, dir) diff --git a/shortcuts/sheets/helpers.go b/shortcuts/sheets/helpers.go index 4853504f..23272e26 100644 --- a/shortcuts/sheets/helpers.go +++ b/shortcuts/sheets/helpers.go @@ -50,6 +50,42 @@ func sheetsInputStatError(flag string, err error) error { return wrapped } +// Drive media parent_type values for uploading an image into a spreadsheet. +// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a +// synthetic token prefixed with "fake_office_" and the backend requires +// "office_sheet_file" instead. +const ( + sheetImageParentType = "sheet_image" + officeSheetFileParentType = "office_sheet_file" + fakeOfficeTokenPrefix = "fake_office_" +) + +// sheetMediaParentType returns the drive media parent_type to use when +// uploading an image whose parent_node is spreadsheetToken. It is the single +// place that maps a spreadsheet token to its parent_type so every image-upload +// entry point (and its dry-run preview) stays consistent. +func sheetMediaParentType(spreadsheetToken string) string { + if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) { + return officeSheetFileParentType + } + return sheetImageParentType +} + +// uploadSheetImage uploads a local image file as a spreadsheet media asset and +// returns its file_token. It funnels every sheets image upload through one +// place so the parent_type selection (see sheetMediaParentType) is never +// duplicated or forgotten at a call site. Callers are expected to have already +// resolved spreadsheetToken (the upload's parent_node) and stat'd the file. +func uploadSheetImage(runtime *common.RuntimeContext, spreadsheetToken, filePath, fileName string, fileSize int64) (string, error) { + return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ + FilePath: filePath, + FileName: fileName, + FileSize: fileSize, + ParentType: sheetMediaParentType(spreadsheetToken), + ParentNode: &spreadsheetToken, + }) +} + // spreadsheetRef classification: a --url / --spreadsheet-token input names a // spreadsheet either directly (a /sheets/ URL or raw token) or indirectly via a // wiki node that must be resolved to its backing spreadsheet at Execute time. diff --git a/shortcuts/sheets/lark_sheet_object_crud.go b/shortcuts/sheets/lark_sheet_object_crud.go index 580e9daa..83f3b550 100644 --- a/shortcuts/sheets/lark_sheet_object_crud.go +++ b/shortcuts/sheets/lark_sheet_object_crud.go @@ -861,10 +861,10 @@ func newFloatImageWriteShortcut(command, description, op string, withIDFlag, isH manageBody, _ := buildToolBody("manage_float_image_object", input) return common.NewDryRunAPI(). POST("/open-apis/drive/v1/medias/upload_all"). - Desc("upload local image to drive (parent_type=sheet_image)"). + Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")"). Body(map[string]interface{}{ "file_name": floatImageName(runtime), - "parent_type": "sheet_image", + "parent_type": sheetMediaParentType(token), "parent_node": token, "size": "", "file": "@" + img, @@ -918,13 +918,7 @@ func uploadFloatImageIfLocal(runtime *common.RuntimeContext, spreadsheetToken st if err != nil { return "", sheetsInputStatError("image", err) } - return common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ - FilePath: img, - FileName: floatImageName(runtime), - FileSize: info.Size(), - ParentType: "sheet_image", - ParentNode: &spreadsheetToken, - }) + return uploadSheetImage(runtime, spreadsheetToken, img, floatImageName(runtime), info.Size()) } func floatImageWriteInput(runtime flagView, token, sheetID, sheetName, op string, withIDFlag bool, uploadedImageToken string) (map[string]interface{}, error) { diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 9a3a8d59..da7a0770 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -791,10 +791,10 @@ var CellsSetImage = common.Shortcut{ }) return common.NewDryRunAPI(). POST("/open-apis/drive/v1/medias/upload_all"). - Desc("upload local image to drive (parent_type=sheet_image)"). + Desc("upload local image to drive (parent_type=" + sheetMediaParentType(token) + ")"). Body(map[string]interface{}{ "file_name": fileName, - "parent_type": "sheet_image", + "parent_type": sheetMediaParentType(token), "parent_node": token, "size": "", "file": "@" + imgPath, @@ -832,13 +832,7 @@ var CellsSetImage = common.Shortcut{ WithParam("--image"). WithCause(err) } - fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{ - FilePath: imgPath, - FileName: fileName, - FileSize: info.Size(), - ParentType: "sheet_image", - ParentNode: &token, - }) + fileToken, err := uploadSheetImage(runtime, token, imgPath, fileName, info.Size()) if err != nil { return err } diff --git a/shortcuts/sheets/lark_sheet_write_cells_test.go b/shortcuts/sheets/lark_sheet_write_cells_test.go index b450bb62..c36f59fd 100644 --- a/shortcuts/sheets/lark_sheet_write_cells_test.go +++ b/shortcuts/sheets/lark_sheet_write_cells_test.go @@ -496,6 +496,31 @@ func TestCellsSetImage_DryRun(t *testing.T) { } } +// TestCellsSetImage_DryRunOfficeParentType confirms that an imported "office" +// spreadsheet (token prefixed with "fake_office_") uploads with +// parent_type=office_sheet_file instead of the native sheet_image, and that the +// preview's parent_node carries the same token. +func TestCellsSetImage_DryRunOfficeParentType(t *testing.T) { + t.Parallel() + const officeToken = "fake_office_abc123" + calls := parseDryRunAPI(t, CellsSetImage, []string{ + "--spreadsheet-token", officeToken, "--sheet-id", testSheetID, + "--range", "A1", + "--image", "./README.md", // any existing-shaped path; dry-run skips stat + }) + if len(calls) != 2 { + t.Fatalf("api calls = %d, want 2 (upload + set_cell_range)", len(calls)) + } + upload := calls[0].(map[string]interface{}) + ubody, _ := upload["body"].(map[string]interface{}) + if ubody["parent_type"] != officeSheetFileParentType { + t.Errorf("parent_type = %v, want %s", ubody["parent_type"], officeSheetFileParentType) + } + if ubody["parent_node"] != officeToken { + t.Errorf("parent_node = %v, want %s", ubody["parent_node"], officeToken) + } +} + func TestCellsSetImage_RangeMustBeSingleCell(t *testing.T) { t.Parallel() _, _, err := runShortcutCapturingErr(t, CellsSetImage, []string{ diff --git a/shortcuts/sheets/sheet_media_parent_type_test.go b/shortcuts/sheets/sheet_media_parent_type_test.go new file mode 100644 index 00000000..ebf03dec --- /dev/null +++ b/shortcuts/sheets/sheet_media_parent_type_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "mime" + "mime/multipart" + "os" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// TestSheetMediaParentType pins the token→parent_type mapping that every +// sheets image-upload entry point funnels through. Native spreadsheet tokens +// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" +// synthetic token and must upload with "office_sheet_file". +func TestSheetMediaParentType(t *testing.T) { + t.Parallel() + cases := []struct { + name string + token string + want string + }{ + {"native spreadsheet token", "shtcnABC123", sheetImageParentType}, + {"empty token", "", sheetImageParentType}, + {"office imported token", "fake_office_abc123", officeSheetFileParentType}, + {"office token, only the prefix", fakeOfficeTokenPrefix, officeSheetFileParentType}, + {"prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := sheetMediaParentType(tc.token); got != tc.want { + t.Fatalf("sheetMediaParentType(%q) = %q, want %q", tc.token, got, tc.want) + } + }) + } +} + +// TestUploadSheetImage_ParentType exercises the uploadSheetImage collector end +// to end (the Execute path the dry-run tests don't reach), asserting the +// parent_type that actually goes out on the wire is derived from the token: a +// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet +// (fake_office_-prefixed token) as office_sheet_file. +func TestUploadSheetImage_ParentType(t *testing.T) { + cases := []struct { + name string + token string + wantParentType string + }{ + {"native spreadsheet", "shtcnTOK123", sheetImageParentType}, + {"office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + runtime, reg := newSheetMediaTestRuntime(t) + // UploadDriveMediaAllTyped opens the file via the runtime's FileIO, + // which sandboxes paths to the current working directory; chdir to a + // temp dir and pass a relative name so the open is allowed. + cmdutil.TestChdir(t, t.TempDir()) + if err := os.WriteFile("img.png", []byte("png-bytes"), 0o600); err != nil { + t.Fatal(err) + } + + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/medias/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"file_token": "boxTOK123"}, + }, + } + reg.Register(stub) + + fileToken, err := uploadSheetImage(runtime, tc.token, "img.png", "img.png", 9) + if err != nil { + t.Fatalf("uploadSheetImage() error: %v", err) + } + if fileToken != "boxTOK123" { + t.Fatalf("file_token = %q, want boxTOK123", fileToken) + } + + body := decodeSheetMediaMultipartBody(t, stub) + if got := body.Fields["parent_type"]; got != tc.wantParentType { + t.Fatalf("parent_type = %q, want %q", got, tc.wantParentType) + } + if got := body.Fields["parent_node"]; got != tc.token { + t.Fatalf("parent_node = %q, want %q", got, tc.token) + } + if got := body.Fields["file_name"]; got != "img.png" { + t.Fatalf("file_name = %q, want img.png", got) + } + }) + } +} + +// TestUploadSheetImage_FileOpenError confirms a missing image surfaces as a +// typed validation error (category=validation, subtype=invalid_argument) with +// the original os-level cause preserved for errors.Is, and proves the upload +// endpoint is never hit. No httpmock stub is registered, so if uploadSheetImage +// ever tried to POST upload_all the RoundTrip would return a +// "no stub for POST ..." network failure — that would surface as a +// non-validation category and fail the metadata assertion below. The +// category=validation + fs.ErrNotExist cause therefore strictly implies the +// short-circuit happened before the wire. +func TestUploadSheetImage_FileOpenError(t *testing.T) { + runtime, _ := newSheetMediaTestRuntime(t) + cmdutil.TestChdir(t, t.TempDir()) + + _, err := uploadSheetImage(runtime, "shtcnTOK123", "missing.png", "missing.png", 1) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } + + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("err = %v; want typed problem carrier", err) + } + if p.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q (non-validation implies the upload endpoint was reached)", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("err = %v; want wrapped fs.ErrNotExist cause to be preserved", err) + } +} + +func newSheetMediaTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{ + AppID: "test-sheets-media-" + t.Name(), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + f, _, _, reg := cmdutil.TestFactory(t, cfg) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "sheets"}, cfg, f, core.AsBot) + return runtime, reg +} + +type sheetMediaCapturedMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeSheetMediaMultipartBody(t *testing.T, stub *httpmock.Stub) sheetMediaCapturedMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := sheetMediaCapturedMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("read multipart part: %v", err) + } + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(part); err != nil { + t.Fatalf("read multipart body for %q: %v", part.FormName(), err) + } + if part.FileName() != "" { + body.Files[part.FormName()] = buf.Bytes() + continue + } + body.Fields[part.FormName()] = buf.String() + } + return body +} diff --git a/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go b/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go new file mode 100644 index 00000000..6bd3deb9 --- /dev/null +++ b/tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets +// image-upload shortcuts emit in --dry-run output for native vs. imported +// "office" spreadsheets. For native tokens parent_type must be "sheet_image"; +// for tokens prefixed with "fake_office_" (the synthetic token an imported +// office spreadsheet carries) the backend requires "office_sheet_file". The +// three covered entries — sheets +media-upload (backward), sheets +// +cells-set-image, and sheets +create-float-image — are every image-upload +// surface that the office/native split fans out to. +func TestSheets_ImageUploadDryRunParentType(t *testing.T) { + setSheetsDryRunEnv(t) + + workDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(workDir, "img.png"), []byte("png-bytes"), 0o600)) + + type tc struct { + name string + args []string + token string + wantParentType string + } + tests := []tc{ + { + name: "media-upload native", + args: []string{ + "sheets", "+media-upload", + "--spreadsheet-token", "shtDryRunNative", + "--file", "img.png", + "--dry-run", + }, + token: "shtDryRunNative", + wantParentType: "sheet_image", + }, + { + name: "media-upload office", + args: []string{ + "sheets", "+media-upload", + "--spreadsheet-token", "fake_office_dryrun", + "--file", "img.png", + "--dry-run", + }, + token: "fake_office_dryrun", + wantParentType: "office_sheet_file", + }, + { + name: "cells-set-image native", + args: []string{ + "sheets", "+cells-set-image", + "--spreadsheet-token", "shtDryRunNative", + "--sheet-id", "sheet1", + "--range", "A1", + "--image", "img.png", + "--dry-run", + }, + token: "shtDryRunNative", + wantParentType: "sheet_image", + }, + { + name: "cells-set-image office", + args: []string{ + "sheets", "+cells-set-image", + "--spreadsheet-token", "fake_office_dryrun", + "--sheet-id", "sheet1", + "--range", "A1", + "--image", "img.png", + "--dry-run", + }, + token: "fake_office_dryrun", + wantParentType: "office_sheet_file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + WorkDir: workDir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "api.0 must be the drive upload; stdout:\n%s", out) + require.Equal(t, "/open-apis/drive/v1/medias/upload_all", + gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + require.Equal(t, tt.wantParentType, gjson.Get(out, "api.0.body.parent_type").String(), + "parent_type for token %q must be %q; stdout:\n%s", tt.token, tt.wantParentType, out) + require.Equal(t, tt.token, gjson.Get(out, "api.0.body.parent_node").String(), + "parent_node must equal the spreadsheet token; stdout:\n%s", out) + }) + } +}