diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index bc790653..78c34e67 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -275,7 +275,14 @@ var DrivePush = common.Shortcut{ skipped++ continue } - token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, folderToken) + parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache) + if parentErr != nil { + items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()}) + failed++ + uploadFailed = true + continue + } + token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken) if upErr != nil { // Token contract on overwrite failure: an in-place // overwrite preserves the file's token, so the @@ -580,6 +587,10 @@ func drivePushEnsureFolder(ctx context.Context, runtime *common.RuntimeContext, return token, nil } +func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeContext, rootFolderToken, relPath string, folderCache map[string]string) (string, error) { + return drivePushEnsureFolder(ctx, runtime, rootFolderToken, drivePushParentRel(relPath), folderCache) +} + // drivePushUploadFile uploads (or overwrites) a single local file. When // existingToken is non-empty, the request adds the file_token form field to // trigger overwrite-with-version semantics on the backend; the response is diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index ec71e4bf..3d5654ca 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -1296,6 +1296,130 @@ func TestDrivePushReusesExistingRemoteFolder(t *testing.T) { } } +// 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 diff --git a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go index 16d3ea8f..9946a99a 100644 --- a/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go +++ b/tests/cli_e2e/drive/drive_duplicate_sync_workflow_test.go @@ -207,4 +207,69 @@ func TestDrive_DuplicateRemoteWorkflow(t *testing.T) { t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout) } }) + + t.Run("push overwrites nested remote file under its real parent", func(t *testing.T) { + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-nested-push-"+suffix, "") + subFolderToken := createDriveFolder(t, parentT, ctx, "sub", folderToken) + + workDir := t.TempDir() + if err := os.MkdirAll(filepath.Join(workDir, "local", "sub"), 0o755); err != nil { + t.Fatalf("mkdir local/sub: %v", err) + } + if err := os.WriteFile(filepath.Join(workDir, "local", "sub", "keep.txt"), []byte("local-nested-overwrite"), 0o644); err != nil { + t.Fatalf("write local/sub/keep.txt: %v", err) + } + + existingToken := uploadNamedFile(t, workDir, subFolderToken, "_nested_keep.txt", "keep.txt", "remote-before") + + pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+push", + "--local-dir", "local", + "--folder-token", folderToken, + "--if-exists", "overwrite", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + pushResult.AssertExitCode(t, 0) + pushResult.AssertStdoutStatus(t, true) + + if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 { + t.Fatalf("nested +push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout) + } + if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").action`).String(); got != "overwritten" { + t.Fatalf("nested +push action=%q, want overwritten\nstdout:\n%s", got, pushResult.Stdout) + } + if got := gjson.Get(pushResult.Stdout, `data.items.#(rel_path="sub/keep.txt").file_token`).String(); got != existingToken { + t.Fatalf("nested +push file_token=%q, want existing token %q\nstdout:\n%s", got, existingToken, pushResult.Stdout) + } + + statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+status", + "--local-dir", "local", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + skipDriveStatusExactIfMissingDownloadScope(t, statusResult) + statusResult.AssertExitCode(t, 0) + statusResult.AssertStdoutStatus(t, true) + if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 { + t.Fatalf("nested +status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "sub/keep.txt" { + t.Fatalf("nested +status unchanged rel_path=%q, want sub/keep.txt\nstdout:\n%s", got, statusResult.Stdout) + } + if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 || + gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 || + gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 { + t.Fatalf("nested overwrite should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout) + } + }) }