feat(sheets): use office_sheet_file parent_type for imported office spreadsheets (#1606)

Image uploads to a spreadsheet hard-coded parent_type=sheet_image at every
entry point. Imported "office" spreadsheets carry a token prefixed with
"fake_office_", for which the drive backend requires
parent_type=office_sheet_file. Funnel the parent_type selection through a
single sheets-domain helper so the rule lives in one place and every
image-upload path (float-image, +cells-set-image, backward +media-upload,
and every dry-run preview) stays consistent.

- Add sheetMediaParentType(token) in the sheets domain: returns
  office_sheet_file for fake_office_-prefixed tokens, otherwise sheet_image.
- Add an uploadSheetImage(...) collector that builds the
  DriveMediaUploadAllConfig (including parent_type) once, replacing the
  per-call-site hand-rolled configs.
- Route both main-domain image entries through the collector — float-image
  local upload and +cells-set-image — covering Execute and the dry-run
  preview body/desc.
- Cover the backward +media-upload entry: single-part, multipart (>20MB),
  and both dry-run bodies. backward is a separate package and an
  intentional verbatim mirror of shortcuts/sheets/, so it keeps its own
  copy of the helper rather than importing the main domain.
- Leave the shared common.UploadDriveMediaAllTyped upload layer untouched
  — the fake_office_ rule is sheets-specific and must not leak into
  mail/slides/doc/drive/base.

Tests:
- Pure-function TestSheetMediaParentType (5 cases incl. prefix-only and
  mid-string non-match).
- Main-domain dry-run TestCellsSetImage_DryRunOfficeParentType and
  TestUploadSheetImage_ParentType / _FileOpenError that exercise the
  Execute path on the wire, asserting parent_type via the captured
  multipart body and typed validation metadata (errs.ProblemOf
  category/subtype, fs.ErrNotExist cause preserved) on file open errors.
  decodeSheetMediaMultipartBody fails fast on NextPart / ReadFrom errors
  rather than silently producing a partial body.
- backward TestSheetMediaUploadExecuteOfficeParentType (real multipart
  wire) and TestSheetMediaUploadDryRunSmallFileOfficeParentType
  (small-file dry-run preview for fake_office_).
- cli_e2e tests/cli_e2e/sheets/sheets_image_upload_dryrun_test.go: --dry-run
  end-to-end across +media-upload and +cells-set-image, native and
  fake_office_ tokens, asserting api.0 is POST upload_all with
  parent_type=sheet_image / office_sheet_file and parent_node = token.
This commit is contained in:
xiongyuanwen-byted
2026-06-27 16:16:56 +08:00
committed by GitHub
parent 8a268aa2d2
commit 4c31323de1
8 changed files with 470 additions and 23 deletions

View File

@@ -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": "<file_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_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,
})
}

View File

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

View File

@@ -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.

View File

@@ -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_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) {

View File

@@ -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_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
}

View File

@@ -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{

View File

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

View File

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