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
2239 lines
73 KiB
Go
2239 lines
73 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package drive
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// countingOpenProvider wraps a fileio.Provider and counts FileIO.Open
|
|
// calls. Used by the multipart test to pin the single-shared-fd
|
|
// optimization: a regression to reopen-per-block would push the
|
|
// counter above 1.
|
|
type countingOpenProvider struct {
|
|
inner fileio.Provider
|
|
opens *atomic.Int32
|
|
}
|
|
|
|
func (p *countingOpenProvider) Name() string { return "counting-open" }
|
|
|
|
func (p *countingOpenProvider) ResolveFileIO(ctx context.Context) fileio.FileIO {
|
|
return &countingOpenFileIO{inner: p.inner.ResolveFileIO(ctx), opens: p.opens}
|
|
}
|
|
|
|
type countingOpenFileIO struct {
|
|
inner fileio.FileIO
|
|
opens *atomic.Int32
|
|
}
|
|
|
|
func (c *countingOpenFileIO) Open(name string) (fileio.File, error) {
|
|
c.opens.Add(1)
|
|
return c.inner.Open(name)
|
|
}
|
|
func (c *countingOpenFileIO) Stat(name string) (fileio.FileInfo, error) {
|
|
return c.inner.Stat(name)
|
|
}
|
|
func (c *countingOpenFileIO) ResolvePath(p string) (string, error) { return c.inner.ResolvePath(p) }
|
|
func (c *countingOpenFileIO) Save(p string, opts fileio.SaveOptions, body io.Reader) (fileio.SaveResult, error) {
|
|
return c.inner.Save(p, opts, body)
|
|
}
|
|
|
|
// TestDrivePushUploadsAndCreatesParents verifies the happy path: a local
|
|
// tree with a top-level file and a subdirectory is mirrored onto a Drive
|
|
// folder, with create_folder called once for the missing sub-folder.
|
|
func TestDrivePushUploadsAndCreatesParents(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("AAA"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile a: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "sub", "b.txt"), []byte("BBB"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile b: %v", err)
|
|
}
|
|
|
|
// Empty remote: no files, no folders.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
|
|
// Upload a.txt into the root folder.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_a"},
|
|
},
|
|
})
|
|
|
|
// create_folder for "sub", returns a fresh token.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/create_folder",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"token": "fld_sub"},
|
|
},
|
|
})
|
|
|
|
// Upload b.txt into the freshly created sub-folder.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_b"},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 2`) {
|
|
t.Errorf("expected uploaded=2, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"failed": 0`) {
|
|
t.Errorf("expected failed=0, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"deleted_remote": 0`) {
|
|
t.Errorf("expected deleted_remote=0 (flag not set), got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushOverwritesWhenIfExistsOverwrite verifies that a local file
|
|
// whose rel_path already maps to a type=file on Drive is sent through
|
|
// upload_all with the existing file_token in the form body, and that the
|
|
// returned version is propagated to items[].version.
|
|
func TestDrivePushOverwritesWhenIfExistsOverwrite(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local-new"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"file_token": "tok_keep_new",
|
|
"version": "v42",
|
|
},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
// Default --if-exists is now "skip"; opt into overwrite explicitly
|
|
// to exercise the file_token-on-upload_all path this test pins.
|
|
"--if-exists", "overwrite",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"action": "overwritten"`) {
|
|
t.Errorf("expected action=overwritten, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"version": "v42"`) {
|
|
t.Errorf("expected version propagated, got: %s", out)
|
|
}
|
|
|
|
// The overwrite request must carry the existing file_token in the
|
|
// form body so the backend knows to bump the existing file's version
|
|
// rather than create a sibling. Decode the captured multipart body
|
|
// using the existing decoder helper.
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got := body.Fields["file_token"]; got != "tok_keep_old" {
|
|
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
|
|
}
|
|
if got := body.Fields["file_name"]; got != "keep.txt" {
|
|
t.Fatalf("upload_all form file_name = %q, want keep.txt", got)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushDefaultsToSkipForExistingRemote pins the new default. Until
|
|
// the upload_all overwrite/version protocol field is fully rolled out, the
|
|
// default --if-exists is "skip" so a first-time push against a non-empty
|
|
// folder never trips on the missing-version branch by surprise. A test
|
|
// that registers no upload_all stub would fail with "unmatched stub" if
|
|
// the default were still "overwrite".
|
|
func TestDrivePushDefaultsToSkipForExistingRemote(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Intentionally NO upload_all stub: with the new default, the file
|
|
// is silently skipped — any upload_all call would trip the registry's
|
|
// strict no-stub-or-die contract.
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"skipped": 1`) {
|
|
t.Errorf("expected default behavior to skip existing remote files, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Errorf("expected uploaded=0 under default --if-exists=skip, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"failed": 0`) {
|
|
t.Errorf("expected failed=0, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushSkipsWhenIfExistsSkip verifies --if-exists=skip leaves Drive
|
|
// content untouched and counts the file under summary.skipped without
|
|
// hitting upload_all.
|
|
func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "skip",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"skipped": 1`) {
|
|
t.Errorf("expected skipped=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Errorf("expected uploaded=0 with --if-exists=skip, got: %s", out)
|
|
}
|
|
|
|
// reg.Verify is implicit via the registry's no-stub-or-die contract:
|
|
// if --if-exists=skip had triggered an upload_all anyway, the request
|
|
// would 404 against the registry and the run would have errored above.
|
|
}
|
|
|
|
// TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate verifies the smart
|
|
// fast path for local → Drive mirrors: when the remote copy is already at
|
|
// least as new as the local file, +push skips the upload.
|
|
func TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
localPath := filepath.Join("local", "keep.txt")
|
|
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
|
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Intentionally NO upload_all stub: smart mode should skip the transfer.
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "smart",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"skipped": 1`) {
|
|
t.Errorf("expected skipped=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Errorf("expected uploaded=0, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer verifies the smart
|
|
// path still uploads when the local file is newer than the remote one.
|
|
func TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
localPath := filepath.Join("local", "keep.txt")
|
|
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
|
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_keep_new", "version": "v43"},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "smart",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"action": "overwritten"`) {
|
|
t.Errorf("expected overwritten action, got: %s", out)
|
|
}
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got := body.Fields["file_token"]; got != "tok_keep_old" {
|
|
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
|
|
}
|
|
}
|
|
|
|
func TestDrivePushShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
localFile := drivePushLocalFile{
|
|
Size: 5,
|
|
ModTime: time.Unix(100, 500*int64(time.Millisecond)),
|
|
}
|
|
|
|
for _, tt := range []struct {
|
|
name string
|
|
remoteFile driveRemoteEntry
|
|
}{
|
|
{
|
|
name: "invalid remote timestamp",
|
|
remoteFile: driveRemoteEntry{ModifiedTime: "not-a-time"},
|
|
},
|
|
} {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := drivePushShouldSkipSmart(localFile, tt.remoteFile); got {
|
|
t.Fatalf("drivePushShouldSkipSmart() = true, want false for %s", tt.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrivePushSkipsWhenSmartIgnoresRemoteSize(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
localPath := filepath.Join("local", "keep.txt")
|
|
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
|
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "200"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "smart",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"skipped": 1`) {
|
|
t.Errorf("expected skipped=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Errorf("expected uploaded=0, got: %s", out)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard:
|
|
// --delete-remote without --yes must be refused before any list / upload
|
|
// happens, so a stray flag never silently deletes anything.
|
|
func TestDrivePushDeleteRemoteRequiresYes(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--delete-remote",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for --delete-remote without --yes, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--delete-remote") || !strings.Contains(err.Error(), "--yes") {
|
|
t.Fatalf("error must mention --delete-remote and --yes, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushDeleteRemoteSkipsOnlineDocs is the load-bearing safety
|
|
// regression: with --delete-remote --yes, the command must only DELETE
|
|
// type=file Drive entries that have no local counterpart. Online docs
|
|
// (docx/sheet/bitable/...), shortcuts and folders that share a rel_path
|
|
// with a missing local file must be left alone.
|
|
func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
// One local file that already exists remotely and matches by rel_path.
|
|
if err := os.WriteFile(filepath.Join("local", "kept.txt"), []byte("kept"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// Remote contains:
|
|
// - kept.txt: a type=file with a local counterpart -> overwritten
|
|
// - orphan.txt: a type=file without local counterpart -> deleted_remote
|
|
// - notes.docx: an online doc -> must be left alone (not deleted)
|
|
// - link: a shortcut -> must be left alone
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_kept", "name": "kept.txt", "type": "file"},
|
|
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
|
map[string]interface{}{"token": "tok_doc", "name": "notes.docx", "type": "docx"},
|
|
map[string]interface{}{"token": "tok_link", "name": "link", "type": "shortcut"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
// Overwrite kept.txt.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"file_token": "tok_kept",
|
|
"version": "v2",
|
|
},
|
|
},
|
|
})
|
|
|
|
deleteStub := &httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/tok_orphan",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
}
|
|
reg.Register(deleteStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
// This test exercises the overwrite path against kept.txt; opt
|
|
// into overwrite explicitly now that the default is "skip".
|
|
"--if-exists", "overwrite",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1 (overwrite of kept.txt), got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"deleted_remote": 1`) {
|
|
t.Errorf("expected deleted_remote=1 (just orphan.txt), got: %s", out)
|
|
}
|
|
if strings.Contains(out, "notes.docx") {
|
|
t.Errorf("notes.docx must not appear in items (online docs are protected): %s", out)
|
|
}
|
|
// Bare-substring check (matches the notes.docx assertion above):
|
|
// drivePushItem serializes the field as "file_token", so a check
|
|
// against `"token": "tok_link"` would never fire — silent test
|
|
// failure to catch a regression. A bare substring is strictly
|
|
// stronger: it catches leaks into file_token, log lines, anywhere.
|
|
if strings.Contains(out, "tok_link") {
|
|
t.Errorf("shortcut tok_link must not appear in items: %s", out)
|
|
}
|
|
|
|
// Registry.RoundTrip sets CapturedHeaders unconditionally on a match,
|
|
// so a non-nil value proves the DELETE for tok_orphan actually went
|
|
// through. If the test reached this point with no DELETE issued the
|
|
// registry would already have errored on the first uncovered request.
|
|
if deleteStub.CapturedHeaders == nil {
|
|
t.Fatal("DELETE for tok_orphan was never issued; --delete-remote did not run")
|
|
}
|
|
}
|
|
|
|
func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"},
|
|
map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/tok_a",
|
|
Body: map[string]interface{}{
|
|
"code": 1061004,
|
|
"msg": "forbidden",
|
|
},
|
|
})
|
|
// No DELETE stub for tok_b: terminal delete failure must stop before it.
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
|
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err)
|
|
}
|
|
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != true {
|
|
t.Fatalf("summary.aborted = %v, want true", got)
|
|
}
|
|
if got := summary["deleted_remote"]; got != float64(0) {
|
|
t.Fatalf("summary.deleted_remote = %v, want 0", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" {
|
|
t.Fatalf("unexpected failed item: %#v", item)
|
|
}
|
|
if item["code"] != float64(1061004) || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
for _, item := range items {
|
|
if item["file_token"] == "tok_b" {
|
|
t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/tok_orphan",
|
|
Body: map[string]interface{}{
|
|
"code": 1061007,
|
|
"msg": "file has been delete.",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(0) {
|
|
t.Fatalf("summary.failed = %v, want 0", got)
|
|
}
|
|
if got := summary["deleted_remote"]; got != float64(0) {
|
|
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
|
|
t.Fatalf("unexpected already-deleted item: %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
registerDuplicateRemoteFiles(reg)
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"file_token": "dup-new-token",
|
|
"version": "v99",
|
|
},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
deleteStub := &httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
}
|
|
reg.Register(deleteStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "overwrite",
|
|
"--on-duplicate-remote", "newest",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
|
|
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
|
|
}
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Fatalf("expected uploaded=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"deleted_remote": 1`) {
|
|
t.Fatalf("expected deleted_remote=1, got: %s", out)
|
|
}
|
|
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
|
if deleteStub.CapturedHeaders == nil {
|
|
t.Fatal("DELETE for the unchosen duplicate sibling was never issued")
|
|
}
|
|
|
|
reg.Verify(t)
|
|
}
|
|
|
|
func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
registerDuplicateRemoteFiles(reg)
|
|
deleteFirst := &httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
}
|
|
deleteSecond := &httpmock.Stub{
|
|
Method: "DELETE",
|
|
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
}
|
|
reg.Register(deleteFirst)
|
|
reg.Register(deleteSecond)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "skip",
|
|
"--on-duplicate-remote", "newest",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Fatalf("expected uploaded=0, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"deleted_remote": 2`) {
|
|
t.Fatalf("expected deleted_remote=2, got: %s", out)
|
|
}
|
|
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
|
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
|
|
if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil {
|
|
t.Fatal("expected both duplicate remote DELETE requests to be issued")
|
|
}
|
|
|
|
reg.Verify(t)
|
|
}
|
|
|
|
// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces
|
|
// the proper flag name in the error message.
|
|
func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "/etc",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected validation error for absolute --local-dir, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "--local-dir") {
|
|
t.Fatalf("error must reference --local-dir, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushRejectsBadIfExistsEnum verifies the framework's enum guard.
|
|
func TestDrivePushRejectsBadIfExistsEnum(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "fail-and-die",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected enum validation error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "if-exists") {
|
|
t.Fatalf("error must reference --if-exists, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushOverwriteWithoutVersionFails surfaces the protocol contract:
|
|
// when the upload_all response for an overwrite call lacks both `version`
|
|
// and `data_version`, the shortcut must report a structured api_error
|
|
// rather than silently report success. Important during the transitional
|
|
// period where the deployed backend may not yet expose the field.
|
|
func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
// upload_all returns a NEW file_token but no version. Using a distinct
|
|
// value (tok_keep_new vs the entry's tok_keep) is what makes the test
|
|
// able to distinguish "items[].file_token comes from the upload_all
|
|
// response" from "fall back to entry.FileToken" — if the assertion
|
|
// stubbed the same token as the entry, a regression to the fallback
|
|
// branch would silently still pass.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_keep_new"},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
// Default is "skip" now; this test specifically exercises the
|
|
// overwrite-failure branch, so opt into overwrite explicitly.
|
|
"--if-exists", "overwrite",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
// Item-level failures report a partial failure: an ok:false items[]
|
|
// envelope on stdout + a non-zero exit via the partial-failure signal.
|
|
// Older behavior was to silently return nil; the assertion below pins
|
|
// the new contract.
|
|
if err == nil {
|
|
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
if pfErr.Code != output.ExitAPI {
|
|
t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code)
|
|
}
|
|
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Errorf("summary.failed = %v, want 1", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") {
|
|
t.Errorf("items[0].error = %q, want missing-version message", got)
|
|
}
|
|
// Pin the token-stability contract: the failed item must surface the
|
|
// token returned by upload_all (tok_keep_new), NOT the fallback
|
|
// entry.FileToken (tok_keep). Without this, a regression that always
|
|
// uses entry.FileToken on failure would slip through.
|
|
if got := items[0]["file_token"]; got != "tok_keep_new" {
|
|
t.Errorf("items[0].file_token = %v, want tok_keep_new", got)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushOverwritePartialSuccessSurfacesReturnedToken pins the
|
|
// partial-success contract for upload_all: when the backend returns a
|
|
// non-zero `code` AND a non-empty `data.file_token`, the bytes have
|
|
// already landed under that returned token. The shortcut must surface
|
|
// THAT token in items[].file_token (not silently fall back to
|
|
// entry.FileToken via the empty-string sentinel), so callers always
|
|
// have a usable handle to whatever state Drive ended up in.
|
|
func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
// upload_all returns a partial-success: non-zero code + a brand-new
|
|
// file_token. The shortcut must not drop that token.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 1234, "msg": "partial-success simulated",
|
|
"data": map[string]interface{}{"file_token": "tok_keep_partial"},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "overwrite",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected non-zero exit on item-level failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
|
t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err)
|
|
}
|
|
|
|
// Partial failure reports an ok:false result envelope on stdout (not a
|
|
// misleading ok:true) while still carrying BOTH the succeeded and failed
|
|
// items — consistent with the pre-change payload. The failed side is
|
|
// asserted via "failed": 1 and the succeeded side via tok_keep_partial.
|
|
envelope := decodeDrivePushStdout(t, stdout.Bytes())
|
|
if envelope.OK {
|
|
t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String())
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Errorf("summary.failed = %v, want 1", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
// The freshly returned token must be the one in items[].file_token,
|
|
// not the stale entry.FileToken (tok_keep_old).
|
|
if got := items[0]["file_token"]; got != "tok_keep_partial" {
|
|
t.Errorf("items[0].file_token = %v, want tok_keep_partial", got)
|
|
}
|
|
if got := items[0]["file_token"]; got == "tok_keep_old" {
|
|
t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0])
|
|
}
|
|
}
|
|
|
|
func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile a: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile b: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 1061002,
|
|
"msg": "params error.",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != true {
|
|
t.Fatalf("summary.aborted = %v, want true", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" {
|
|
t.Fatalf("unexpected failed item: %#v", item)
|
|
}
|
|
if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
for _, item := range items {
|
|
if item["rel_path"] == "b.txt" {
|
|
t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile a: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile b: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 1061044,
|
|
"msg": "parent node not exist.",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != true {
|
|
t.Fatalf("summary.aborted = %v, want true", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
|
|
t.Fatalf("unexpected failed item: %#v", item)
|
|
}
|
|
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
|
|
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
|
|
}
|
|
for _, item := range items {
|
|
if item["rel_path"] == "b.txt" {
|
|
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll a: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll b: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/create_folder",
|
|
Body: map[string]interface{}{
|
|
"code": 99991672,
|
|
"msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != true {
|
|
t.Fatalf("summary.aborted = %v, want true", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" {
|
|
t.Fatalf("unexpected failed item: %#v", item)
|
|
}
|
|
if item["code"] != float64(99991672) || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
for _, item := range items {
|
|
if item["rel_path"] == "b" {
|
|
t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll a: %v", err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll b: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/create_folder",
|
|
Body: map[string]interface{}{
|
|
"code": 1062507,
|
|
"msg": "parent node out of sibling num.",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != true {
|
|
t.Fatalf("summary.aborted = %v, want true", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
|
|
t.Fatalf("unexpected failed item: %#v", item)
|
|
}
|
|
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
|
|
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
|
|
}
|
|
for _, item := range items {
|
|
if item["rel_path"] == "b" {
|
|
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
localPath := filepath.Join("local", "changing.txt")
|
|
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
OnMatch: func(req *http.Request) {
|
|
if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil {
|
|
t.Fatalf("mutate local file: %v", err)
|
|
}
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if got := summary["aborted"]; got != false {
|
|
t.Fatalf("summary.aborted = %v, want false", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") {
|
|
t.Fatalf("items[0].error = %q, want local-change message", got)
|
|
}
|
|
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
|
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
|
}
|
|
|
|
problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{
|
|
RelPath: "missing.txt",
|
|
OpenPath: filepath.Join("local", "missing.txt"),
|
|
FileName: "missing.txt",
|
|
Size: 1,
|
|
ModTime: time.Now(),
|
|
})
|
|
problem, ok := errs.ProblemOf(problemErr)
|
|
if !ok {
|
|
t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr)
|
|
}
|
|
if problem.Subtype != errs.SubtypeFailedPrecondition {
|
|
t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition)
|
|
}
|
|
if problem.Category != errs.CategoryValidation {
|
|
t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation)
|
|
}
|
|
if errors.Unwrap(problemErr) == nil {
|
|
t.Fatalf("snapshot error cause was not preserved")
|
|
}
|
|
}
|
|
|
|
func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
localPath := filepath.Join("local", "changing.txt")
|
|
if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
originalModTime := time.Unix(100, 0)
|
|
changedModTime := time.Unix(200, 0)
|
|
if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil {
|
|
t.Fatalf("Chtimes original: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
OnMatch: func(req *http.Request) {
|
|
if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil {
|
|
t.Fatalf("mutate local file: %v", err)
|
|
}
|
|
if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil {
|
|
t.Fatalf("Chtimes changed: %v", err)
|
|
}
|
|
},
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) {
|
|
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
|
|
}
|
|
|
|
summary, items := splitDrivePushStdout(t, stdout.Bytes())
|
|
if got := summary["failed"]; got != float64(1) {
|
|
t.Fatalf("summary.failed = %v, want 1", got)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
|
|
}
|
|
item := items[0]
|
|
if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" {
|
|
t.Fatalf("unexpected failure metadata: %#v", item)
|
|
}
|
|
if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") {
|
|
t.Fatalf("items[0].error = %q, want modtime mismatch", got)
|
|
}
|
|
if strings.Contains(stdout.String(), "httpmock: no stub") {
|
|
t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestDrivePushSkipsDeleteAfterUploadFailure pins the half-sync safety
|
|
// guard: when any upload / overwrite / folder step fails, the
|
|
// --delete-remote phase must be skipped entirely. Otherwise a partial
|
|
// upload would proceed to delete remote orphans, leaving the tenant in
|
|
// a worse state than where it started (files missing locally and now
|
|
// also gone from Drive).
|
|
//
|
|
// Test setup mirrors the missing-version overwrite failure: one local
|
|
// file with a same-name remote that overwrites with no version field,
|
|
// plus one orphan remote file that --delete-remote --yes would
|
|
// otherwise delete. With the fix in place the orphan must NOT be
|
|
// reached.
|
|
func TestDrivePushSkipsDeleteAfterUploadFailure(t *testing.T) {
|
|
f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file"},
|
|
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
// Overwrite returns no version → fails.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_keep"},
|
|
},
|
|
})
|
|
// Crucially: do NOT register a DELETE stub for tok_orphan. If the
|
|
// delete phase were still triggered after the upload failure, the
|
|
// registry's no-stub-or-die contract would surface an unmatched
|
|
// request and the test would catch the regression.
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "overwrite",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected non-zero exit on overwrite failure, got nil\nstdout: %s", stdout.String())
|
|
}
|
|
var pfErr *output.PartialFailureError
|
|
if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI {
|
|
t.Fatalf("expected ExitAPI *output.PartialFailureError, got %v", err)
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"failed": 1`) {
|
|
t.Errorf("expected failed=1 (overwrite missing-version), got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"deleted_remote": 0`) {
|
|
t.Errorf("expected deleted_remote=0 (delete phase must skip after upload failure), got: %s", out)
|
|
}
|
|
if strings.Contains(out, "tok_orphan") {
|
|
t.Errorf("orphan file token must not appear in items (delete phase skipped), got: %s", out)
|
|
}
|
|
// Operator-facing hint that cleanup was skipped lives on stderr.
|
|
if !strings.Contains(stderrBuf.String(), "Skipping --delete-remote") {
|
|
t.Errorf("expected stderr hint about skipped delete phase, got: %s", stderrBuf.String())
|
|
}
|
|
}
|
|
|
|
// TestDrivePushExitsZeroOnCleanRun pins the inverse: a successful run
|
|
// with no failures must NOT bump the exit code. Without this the
|
|
// partial-failure path could regress to "always non-zero" silently.
|
|
func TestDrivePushExitsZeroOnCleanRun(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("AAA"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_a"},
|
|
},
|
|
})
|
|
|
|
if err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout); err != nil {
|
|
t.Fatalf("expected nil error on clean run, got: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"failed": 0`) {
|
|
t.Errorf("expected failed=0, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
type drivePushStdoutEnvelope struct {
|
|
OK bool `json:"ok"`
|
|
Data struct {
|
|
Summary map[string]interface{} `json:"summary"`
|
|
Items []map[string]interface{} `json:"items"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope {
|
|
t.Helper()
|
|
var envelope drivePushStdoutEnvelope
|
|
if err := json.Unmarshal(stdout, &envelope); err != nil {
|
|
t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout))
|
|
}
|
|
return envelope
|
|
}
|
|
|
|
func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) {
|
|
t.Helper()
|
|
envelope := decodeDrivePushStdout(t, stdout)
|
|
if envelope.Data.Summary == nil {
|
|
t.Fatalf("stdout missing data.summary; raw=%s", string(stdout))
|
|
}
|
|
return envelope.Data.Summary, envelope.Data.Items
|
|
}
|
|
|
|
// TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the
|
|
// behavior when a local regular file shares its rel_path with a Lark
|
|
// native cloud document on Drive (sheet/docx/bitable/...).
|
|
//
|
|
// The contract:
|
|
// - The native doc is NOT a candidate for overwrite — the remoteFiles
|
|
// view collapsed off listRemoteFolder only keeps type=file entries,
|
|
// so the local "report" is treated as new and the upload_all body
|
|
// must NOT carry the sheet's token in the file_token form field
|
|
// (that would mean overwriting the sheet's bytes, not creating a
|
|
// sibling).
|
|
// - The native doc must NOT be deleted by --delete-remote --yes either.
|
|
// The orphan check iterates remoteFiles only; a sheet at the same
|
|
// rel_path as a missing local file would otherwise look orphaned.
|
|
//
|
|
// Both invariants together: native cloud docs are never reached by either
|
|
// the upload or the delete path, regardless of whether a same-named
|
|
// local file exists.
|
|
func TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "report"), []byte("local-csv-bytes"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
// Remote contains:
|
|
// - "report" as a Lark native sheet (type=sheet, tok_sheet) — must be
|
|
// left strictly alone
|
|
// - "minutes" as a native docx (type=docx, tok_docx) — no
|
|
// local counterpart, --delete-remote must skip it
|
|
// No type=file entries at all — every local rel_path is a "new upload"
|
|
// from the remoteFiles view's perspective (listRemoteFolder returns the
|
|
// sheet/docx in the unified entry map but they're filtered out when
|
|
// collapsing to the type=file view).
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_sheet", "name": "report", "type": "sheet"},
|
|
map[string]interface{}{"token": "tok_docx", "name": "minutes", "type": "docx"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_report_file"},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--delete-remote",
|
|
"--yes",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1 (sibling create, not overwrite), got: %s", out)
|
|
}
|
|
// Native docs are never iterated for delete; --delete-remote must be 0.
|
|
if !strings.Contains(out, `"deleted_remote": 0`) {
|
|
t.Errorf("native docs must not be deleted, got: %s", out)
|
|
}
|
|
// Items must show the upload as a fresh "uploaded", not "overwritten",
|
|
// and must point at the new token, never at the sheet's token.
|
|
if !strings.Contains(out, `"action": "uploaded"`) {
|
|
t.Errorf("expected action=uploaded for sibling create, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"file_token": "tok_report_file"`) {
|
|
t.Errorf("expected new file_token in items, got: %s", out)
|
|
}
|
|
if strings.Contains(out, "tok_sheet") {
|
|
t.Errorf("sheet token must not appear anywhere in output, got: %s", out)
|
|
}
|
|
if strings.Contains(out, "tok_docx") {
|
|
t.Errorf("docx token must not appear anywhere in output, got: %s", out)
|
|
}
|
|
|
|
// The upload_all multipart body must NOT carry file_token. Carrying
|
|
// the sheet's token would mean asking the backend to overwrite the
|
|
// sheet's bytes with our local file's bytes — the exact bug this
|
|
// test guards against. file_name is "report" (no extension, matching
|
|
// the basename) and parent_node is the root folder.
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got, present := body.Fields["file_token"]; present {
|
|
t.Fatalf("upload_all body must NOT include file_token (would overwrite the sheet); got %q", got)
|
|
}
|
|
if got := body.Fields["file_name"]; got != "report" {
|
|
t.Fatalf("upload_all file_name = %q, want \"report\"", got)
|
|
}
|
|
if got := body.Fields["parent_node"]; got != "folder_root" {
|
|
t.Fatalf("upload_all parent_node = %q, want folder_root", got)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushReusesExistingRemoteFolder verifies that when a remote
|
|
// folder already exists at the target rel_path, the upload uses its
|
|
// pre-existing token instead of calling create_folder. Together with the
|
|
// "creates parents" test this pins the folderCache contract end-to-end.
|
|
func TestDrivePushReusesExistingRemoteFolder(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "sub", "b.txt"), []byte("BBB"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile b: %v", err)
|
|
}
|
|
|
|
// Root contains the folder "sub" already; recurse returns no files.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=fld_existing_sub",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_b"},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
if !strings.Contains(stdout.String(), `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1, got: %s", stdout.String())
|
|
}
|
|
|
|
// Upload must have been routed under the pre-existing sub-folder
|
|
// rather than re-creating it; the form's parent_node should be the
|
|
// remote folder token discovered in the listing.
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got := body.Fields["parent_node"]; got != "fld_existing_sub" {
|
|
t.Fatalf("upload parent_node = %q, want fld_existing_sub (folderCache miss?)", got)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushOverwriteNestedFileUsesParentFolderToken verifies that
|
|
// overwriting an existing nested remote file keeps parent_node aligned with
|
|
// the file's actual parent folder instead of the root folder token.
|
|
func TestDrivePushOverwriteNestedFileUsesParentFolderToken(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "fld_existing_sub", "name": "sub", "type": "folder"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=fld_existing_sub",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep_nested", "name": "keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
|
|
uploadStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_all",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"file_token": "tok_keep_nested",
|
|
"version": "v2",
|
|
},
|
|
},
|
|
}
|
|
reg.Register(uploadStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "overwrite",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
body := decodeDriveMultipartBody(t, uploadStub)
|
|
if got := body.Fields["file_token"]; got != "tok_keep_nested" {
|
|
t.Fatalf("upload_all file_token = %q, want tok_keep_nested", got)
|
|
}
|
|
if got := body.Fields["parent_node"]; got != "fld_existing_sub" {
|
|
t.Fatalf("upload_all parent_node = %q, want fld_existing_sub", got)
|
|
}
|
|
}
|
|
|
|
func TestDrivePushOverwriteNestedFileReportsParentEnsureFailure(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("local", "sub", "keep.txt"), []byte("local"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"files": []interface{}{
|
|
map[string]interface{}{"token": "tok_keep_nested", "name": "sub/keep.txt", "type": "file"},
|
|
},
|
|
"has_more": false,
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/create_folder",
|
|
Body: map[string]interface{}{
|
|
"code": 9999,
|
|
"msg": "create parent failed",
|
|
},
|
|
})
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--if-exists", "overwrite",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err == nil {
|
|
t.Fatalf("expected parent ensure failure\nstdout: %s", stdout.String())
|
|
}
|
|
if !strings.Contains(stdout.String(), `"action": "failed"`) || !strings.Contains(stdout.String(), "create parent failed") {
|
|
t.Fatalf("expected failed item with create_folder error, got: %s", stdout.String())
|
|
}
|
|
}
|
|
|
|
// TestDrivePushMirrorsEmptyDirectories confirms the gap codex review
|
|
// flagged: a local directory with no files inside must still surface on
|
|
// Drive as a created sub-folder, not be silently dropped because the
|
|
// upload loop only iterates regular files.
|
|
func TestDrivePushMirrorsEmptyDirectories(t *testing.T) {
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll(filepath.Join("local", "empty"), 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
|
|
// Empty remote.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
|
|
// create_folder for "empty" — must be issued even though no file
|
|
// will land underneath it.
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/create_folder",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"token": "fld_empty"},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
|
|
err := mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"action": "folder_created"`) {
|
|
t.Errorf("expected folder_created action for empty dir, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"rel_path": "empty"`) {
|
|
t.Errorf("expected rel_path=empty in items, got: %s", out)
|
|
}
|
|
// Empty dirs do NOT bump summary.uploaded — that field is for files.
|
|
if !strings.Contains(out, `"uploaded": 0`) {
|
|
t.Errorf("uploaded should remain 0 for an empty-dir-only push, got: %s", out)
|
|
}
|
|
if createStub.CapturedHeaders == nil {
|
|
t.Fatal("create_folder for empty/ was never issued")
|
|
}
|
|
}
|
|
|
|
// TestDrivePushUploadsLargeFileViaMultipart locks in the 3-step
|
|
// upload_prepare/upload_part/upload_finish flow used for files larger
|
|
// than common.MaxDriveMediaUploadSinglePartSize. The single shared file
|
|
// handle (opened once outside the part loop, reused via SectionReader)
|
|
// is implicitly exercised: the test asserts upload_part is invoked
|
|
// twice in a row before upload_finish, and would deadlock or surface a
|
|
// fileio open error if the helper still re-opened per chunk and the
|
|
// httpmock registry didn't have enough Open-permission stubs.
|
|
//
|
|
// The local file is created with Truncate to size+1 so it crosses the
|
|
// single-part threshold by one byte, exercising the "remaining < block
|
|
// size" tail path on the second chunk.
|
|
//
|
|
// Use a distinct AppID to avoid SDK token cache collisions with other
|
|
// tests (mirrors the existing TestDriveUploadLargeFileUsesMultipart
|
|
// pattern).
|
|
func TestDrivePushUploadsLargeFileViaMultipart(t *testing.T) {
|
|
pushTestConfig := &core.CliConfig{
|
|
AppID: "drive-push-multipart-test", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
}
|
|
f, stdout, _, reg := cmdutil.TestFactory(t, pushTestConfig)
|
|
|
|
// Wrap the default FileIO provider to count Open calls. The
|
|
// shared-fd refactor opens the local file exactly once and feeds
|
|
// each block through io.NewSectionReader at distinct offsets; a
|
|
// regression back to "open per block" would bump this counter to
|
|
// block_num (2 in this test).
|
|
opens := &atomic.Int32{}
|
|
f.FileIOProvider = &countingOpenProvider{inner: f.FileIOProvider, opens: opens}
|
|
|
|
tmpDir := t.TempDir()
|
|
withDriveWorkingDir(t, tmpDir)
|
|
if err := os.MkdirAll("local", 0o755); err != nil {
|
|
t.Fatalf("MkdirAll: %v", err)
|
|
}
|
|
largePath := filepath.Join("local", "large.bin")
|
|
fh, err := os.Create(largePath)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
|
|
t.Fatalf("Truncate: %v", err)
|
|
}
|
|
if err := fh.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
// Empty remote — large.bin is a fresh upload, not an overwrite.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "folder_token=folder_root",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
|
|
},
|
|
})
|
|
|
|
prepareStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_prepare",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"upload_id": "upid-large",
|
|
"block_size": float64(common.MaxDriveMediaUploadSinglePartSize),
|
|
"block_num": float64(2),
|
|
},
|
|
},
|
|
}
|
|
reg.Register(prepareStub)
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_part",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_part",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/drive/v1/files/upload_finish",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{"file_token": "tok_large"},
|
|
},
|
|
})
|
|
|
|
err = mountAndRunDrive(t, DrivePush, []string{
|
|
"+push",
|
|
"--local-dir", "local",
|
|
"--folder-token", "folder_root",
|
|
"--as", "bot",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
|
}
|
|
|
|
out := stdout.String()
|
|
if !strings.Contains(out, `"uploaded": 1`) {
|
|
t.Errorf("expected uploaded=1, got: %s", out)
|
|
}
|
|
if !strings.Contains(out, `"file_token": "tok_large"`) {
|
|
t.Errorf("expected file_token=tok_large in items, got: %s", out)
|
|
}
|
|
|
|
// upload_prepare's body must NOT include file_token (this was a
|
|
// fresh upload, not an overwrite). Decoding the body proves both
|
|
// that the request was issued and that the conditional file_token
|
|
// branch in drivePushUploadMultipart was skipped correctly.
|
|
body := decodeCapturedJSONBody(t, prepareStub)
|
|
if _, present := body["file_token"]; present {
|
|
t.Fatalf("upload_prepare body must omit file_token for fresh uploads, got: %#v", body)
|
|
}
|
|
if got := body["parent_type"]; got != driveUploadParentTypeExplorer {
|
|
t.Fatalf("parent_type = %#v, want %q", got, driveUploadParentTypeExplorer)
|
|
}
|
|
if got := body["parent_node"]; got != "folder_root" {
|
|
t.Fatalf("parent_node = %#v, want folder_root", got)
|
|
}
|
|
|
|
// One Open across the entire multipart upload — the load-bearing
|
|
// pin for the shared-fd refactor. block_num=2 above means a
|
|
// reopen-per-block regression would land at 2.
|
|
if got := opens.Load(); got != 1 {
|
|
t.Fatalf("FileIO.Open invocations = %d, want exactly 1 (single shared fd across all blocks)", got)
|
|
}
|
|
}
|
|
|
|
// TestDrivePushHelpersRelPath pins the path utilities since the upload
|
|
// loop relies on slash-only rel_paths even on Windows.
|
|
func TestDrivePushHelpersRelPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
if got := drivePushParentRel("a.txt"); got != "" {
|
|
t.Fatalf("parent of root file should be empty, got %q", got)
|
|
}
|
|
if got := drivePushParentRel("a/b/c.txt"); got != "a/b" {
|
|
t.Fatalf("parent of a/b/c.txt = %q, want a/b", got)
|
|
}
|
|
if parent, name := drivePushSplitRel("a/b/c"); parent != "a/b" || name != "c" {
|
|
t.Fatalf("split a/b/c = (%q,%q), want (a/b,c)", parent, name)
|
|
}
|
|
if parent, name := drivePushSplitRel("solo"); parent != "" || name != "solo" {
|
|
t.Fatalf("split solo = (%q,%q), want (\"\",solo)", parent, name)
|
|
}
|
|
if got := joinRelDrive("", "x"); got != "x" {
|
|
t.Fatalf("joinRelDrive(\"\", x) = %q, want x", got)
|
|
}
|
|
if got := joinRelDrive("a", "x"); got != "a/x" {
|
|
t.Fatalf("joinRelDrive(a, x) = %q, want a/x", got)
|
|
}
|
|
}
|