Compare commits

...

3 Commits

Author SHA1 Message Date
zhoujunteng
b573a3fe01 docs: document upload report helpers 2026-07-29 16:59:05 +08:00
zhoujunteng
7846b89754 test(drive): skip import workflow without tenant token 2026-07-29 15:39:58 +08:00
zhoujunteng
89138261fd feat: report upload file events 2026-07-29 15:22:33 +08:00
16 changed files with 1564 additions and 82 deletions

View File

@@ -288,3 +288,18 @@ func TestDoAPIJSONTyped_NonZeroCode(t *testing.T) {
t.Errorf("LogID = %q, want lz", p.LogID)
}
}
func TestRuntimeContextMarkFileEventReported(t *testing.T) {
rt := &RuntimeContext{}
if !rt.MarkFileEventReported() {
t.Fatal("first mark should report")
}
if rt.MarkFileEventReported() {
t.Fatal("second mark should be skipped")
}
var nilRT *RuntimeContext
if nilRT.MarkFileEventReported() {
t.Fatal("nil receiver should not report")
}
}

View File

@@ -23,6 +23,13 @@ const (
driveMediaUploadFinishAction = "upload media finish failed"
)
const (
driveMediaUploadAllPath = "/open-apis/drive/v1/medias/upload_all"
driveMediaUploadPreparePath = "/open-apis/drive/v1/medias/upload_prepare"
driveMediaUploadPartPath = "/open-apis/drive/v1/medias/upload_part"
driveMediaUploadFinishPath = "/open-apis/drive/v1/medias/upload_finish"
)
type DriveMediaMultipartUploadSession struct {
UploadID string
BlockSize int64
@@ -83,20 +90,33 @@ func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllCo
}
fd.AddFile("file", fileReader)
meta := LarkCLIFileEventMeta{
APIPath: driveMediaUploadAllPath,
UploadMode: "singlepart",
ResourceType: "media",
ParentType: cfg.ParentType,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
ApiPath: driveMediaUploadAllPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
return "", ReportUploadFileEventOnError(runtime, prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction), meta)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
return "", ReportUploadFileEventOnError(runtime, prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction), meta)
}
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
fileToken, err := extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
if err != nil {
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
meta.FileToken = fileToken
ReportUploadFileEvent(runtime, meta)
return fileToken, nil
}
// UploadDriveMediaMultipartTyped uploads a file in server-planned chunks:
@@ -118,22 +138,37 @@ func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMulti
prepareBody["extra"] = cfg.Extra
}
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
meta := LarkCLIFileEventMeta{
APIPath: driveMediaUploadPreparePath,
UploadMode: "multipart",
ResourceType: "media",
ParentType: cfg.ParentType,
}
data, err := runtime.CallAPITyped("POST", driveMediaUploadPreparePath, nil, prepareBody)
if err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
if err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
meta.APIPath = driveMediaUploadPartPath
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
return "", err
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
meta.APIPath = driveMediaUploadFinishPath
fileToken, err := finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
if err != nil {
return "", ReportUploadFileEventOnError(runtime, err, meta)
}
meta.FileToken = fileToken
ReportUploadFileEvent(runtime, meta)
return fileToken, nil
}
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
@@ -235,7 +270,7 @@ func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_part",
ApiPath: driveMediaUploadPartPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
@@ -249,7 +284,7 @@ func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string
}
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
data, err := runtime.CallAPITyped("POST", driveMediaUploadFinishPath, nil, map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
})

View File

@@ -304,3 +304,274 @@ func TestUploadDriveMediaMultipartTypedFinishRequiresFileToken(t *testing.T) {
t.Fatalf("message = %q", p.Message)
}
}
// registerDriveMediaReportStub registers a successful report_file_event stub.
func registerDriveMediaReportStub(t *testing.T, reg *httpmock.Registry) *httpmock.Stub {
t.Helper()
return registerDriveMediaReportStubWithMsg(t, reg, "")
}
// registerDriveMediaReportStubWithMsg registers a report_file_event stub that
// returns code 0 and, when msg is non-empty, carries it as the top-level msg
// (the capacity-expansion URL for tenant-capacity-exceeded uploads).
func registerDriveMediaReportStubWithMsg(t *testing.T, reg *httpmock.Registry, msg string) *httpmock.Stub {
t.Helper()
body := map[string]interface{}{"code": 0, "data": map[string]interface{}{}}
if msg != "" {
body["msg"] = msg
}
stub := &httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
// assertSingleReport verifies one upload report with the expected status and
// returns its decoded tags for additional assertions.
func assertSingleReport(t *testing.T, reportStub *httpmock.Stub, wantStatus string) map[string]interface{} {
t.Helper()
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
body := decodeCapturedDriveMediaJSONBody(t, reportStub)
assertReportEnvelope(t, body)
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags := assertTagsObject(t, body)
if got := tags["status"]; got != wantStatus {
t.Fatalf("tags.status = %v, want %s", got, wantStatus)
}
return tags
}
func TestUploadDriveMediaAllTypedReportsFileEventOnSuccess(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_ok"},
},
})
payload := []byte{0x89, 0x50}
fileToken, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err != nil {
t.Fatalf("UploadDriveMediaAllTyped() error: %v", err)
}
if fileToken != "file_ok" {
t.Fatalf("fileToken = %q, want file_ok", fileToken)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusSuccess)
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("tags.api_path = %v", got)
}
if got := tags["upload_mode"]; got != "singlepart" {
t.Fatalf("tags.upload_mode = %v, want singlepart", got)
}
if got := tags["resource_type"]; got != "media" {
t.Fatalf("tags.resource_type = %v, want media", got)
}
if got := tags["mount_point"]; got != "docx_image" {
t.Fatalf("tags.mount_point = %v, want docx_image", got)
}
if got := tags["file_token"]; got != "file_ok" {
t.Fatalf("tags.file_token = %v, want file_ok", got)
}
}
func TestUploadDriveMediaAllTypedReportsFileEventOnError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 999, "msg": "upload rejected"},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 999 {
t.Fatalf("expected typed api error code 999, got %T (%v)", err, err)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusError)
if got := tags["code"]; got != "999" {
t.Fatalf("tags.code = %v, want 999", got)
}
}
func TestUploadDriveMediaAllTypedReportFailureKeepsUploadError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: map[string]interface{}{"code": 500, "msg": "report rejected"},
Reusable: true,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
payload := []byte{0x01}
_, err := UploadDriveMediaAllTyped(runtime, DriveMediaUploadAllConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: int64(len(payload)),
ParentType: "docx_image",
ParentNode: strPtr("blk_parent"),
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061101 {
t.Fatalf("code = %d, want original 1061101", p.Code)
}
// The report failed (code 500), so no capacity-expansion URL is available.
// Keep the quota hint produced by API error classification unchanged.
const wantHint = "reduce the request volume or free quota, then retry after the relevant quota resets"
if p.Hint != wantHint {
t.Fatalf("hint = %q, want original classified hint %q", p.Hint, wantHint)
}
}
func TestUploadDriveMediaMultipartTypedReportsFileEventOnPrepareError(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStubWithMsg(t, reg, testCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
filePath := writeDriveMediaUploadSizedFile(t, "large.bin", MaxDriveMediaUploadSinglePartSize+1)
_, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: "large.bin",
FileSize: MaxDriveMediaUploadSinglePartSize + 1,
ParentType: "ccm_import_open",
ParentNode: "",
})
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 1061101 {
t.Fatalf("expected typed api error code 1061101, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusError)
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("tags.api_path = %v, want upload_prepare", got)
}
if got := tags["code"]; got != "1061101" {
t.Fatalf("tags.code = %v, want 1061101", got)
}
}
func TestUploadDriveMediaMultipartTypedReportsFileEventOnSuccess(t *testing.T) {
runtime, reg := newDriveMediaUploadTestRuntime(t)
withDriveMediaUploadWorkingDir(t, t.TempDir())
reportStub := registerDriveMediaReportStub(t, reg)
size := MaxDriveMediaUploadSinglePartSize + 1
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_ok",
"block_size": float64(4 * 1024 * 1024),
"block_num": float64(6),
},
},
})
for i := 0; i < 6; i++ {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
}
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_multi_ok"},
},
})
payload := bytes.Repeat([]byte{0xCD}, int(size))
fileToken, err := UploadDriveMediaMultipartTyped(runtime, DriveMediaMultipartUploadConfig{
Reader: bytes.NewReader(payload),
FileName: "clipboard.png",
FileSize: size,
ParentType: "docx_image",
ParentNode: "",
})
if err != nil {
t.Fatalf("UploadDriveMediaMultipartTyped() error: %v", err)
}
if fileToken != "file_multi_ok" {
t.Fatalf("fileToken = %q, want file_multi_ok", fileToken)
}
tags := assertSingleReport(t, reportStub, uploadFileEventStatusSuccess)
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("tags.api_path = %v, want upload_finish", got)
}
if got := tags["file_token"]; got != "file_multi_ok" {
t.Fatalf("tags.file_token = %v, want file_multi_ok", got)
}
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
)
const (
larkCLIReportFileEventPath = "/open-apis/drive/v1/lark_cli_file_event/report"
uploadFileEventReportTimeout = 3 * time.Second
uploadFileEventStatusSuccess = "success"
uploadFileEventStatusError = "error"
)
// LarkCLIFileEventMeta describes the upload context attached to a best-effort
// report_file_event call. Identity (user_id / tenant_id) is intentionally
// omitted: the server derives it from the authenticated request context.
type LarkCLIFileEventMeta struct {
APIPath string
Command string
UploadMode string
ResourceType string
Status string
Code string
// ParentType is the upload request's parent_type (explorer / wiki /
// docx_file / sheet_image / slide_file / email / bitable_file /
// ccm_import_open ...). It is reported verbatim as the tags mount_point.
ParentType string
// FileToken is the uploaded file's token, set only on success paths and
// reported as the tags file_token. Empty on failure paths.
FileToken string
}
// IsTenantCapacityExceeded reports whether err is a typed API error carrying a
// tenant-capacity-exceeded code recognized by the CLI upload reporting flow.
// The code set mirrors the storage service source of truth.
func IsTenantCapacityExceeded(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return false
}
switch p.Code {
case 1061101:
return true
default:
return false
}
}
// ReportUploadFileEvent best-effort reports a successful upload file event once
// per RuntimeContext. The report call's failure is swallowed; it never affects
// the caller's success path.
func ReportUploadFileEvent(runtime *RuntimeContext, meta LarkCLIFileEventMeta) {
if runtime == nil {
return
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusSuccess
}
if !runtime.MarkFileEventReported() {
return
}
_ = postUploadFileEvent(runtime, meta)
}
// ReportUploadFileEventOnError best-effort reports a failed upload once per
// RuntimeContext, then returns the original uploadErr. The report call's own
// failure never replaces uploadErr. When uploadErr is a tenant-capacity-exceeded
// error, the capacity-expansion URL carried by the report response's msg is
// appended to its .hint (only when the report returns a non-empty msg), without
// altering type / subtype / code / message.
func ReportUploadFileEventOnError(runtime *RuntimeContext, uploadErr error, meta LarkCLIFileEventMeta) error {
if uploadErr == nil {
return nil
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusError
}
if strings.TrimSpace(meta.Code) == "" {
if p, ok := errs.ProblemOf(uploadErr); ok && p != nil && p.Code != 0 {
meta.Code = strconv.Itoa(p.Code)
}
}
var reportMsg string
if runtime != nil && runtime.MarkFileEventReported() {
reportMsg = postUploadFileEvent(runtime, meta)
}
return appendTenantCapacityHint(uploadErr, reportMsg)
}
// postUploadFileEvent sends the best-effort report and returns the report
// response's capacity-expansion URL. The server currently carries this URL in
// data.msg; some responses also include a generic top-level msg like "success",
// which must not be mistaken for a URL. Any transport / parse failure or a
// non-zero response code yields an empty string, and the report never affects
// the caller's flow.
func postUploadFileEvent(runtime *RuntimeContext, meta LarkCLIFileEventMeta) string {
return postUploadFileEventWithTimeout(runtime, meta, uploadFileEventReportTimeout)
}
// postUploadFileEventWithTimeout sends the report within the supplied timeout
// and returns a validated capacity-expansion URL from a successful response.
func postUploadFileEventWithTimeout(runtime *RuntimeContext, meta LarkCLIFileEventMeta, timeout time.Duration) string {
reportCtx, cancel := context.WithTimeout(runtime.Ctx(), timeout)
defer cancel()
resp, err := runtime.DoAPIWithContext(reportCtx, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: larkCLIReportFileEventPath,
Body: buildUploadReportRequest(runtime, meta),
})
if err != nil || resp == nil {
return ""
}
parsed, err := client.ParseJSONResponse(resp)
if err != nil {
return ""
}
envelope, ok := parsed.(map[string]interface{})
if !ok {
return ""
}
if GetFloat(envelope, "code") != 0 {
return ""
}
return extractCapacityExpansionURL(envelope)
}
// extractCapacityExpansionURL returns the first valid capacity-expansion URL
// carried by the report response, preferring data.msg over the top-level msg.
func extractCapacityExpansionURL(envelope map[string]interface{}) string {
for _, candidate := range []string{
GetString(envelope, "data", "msg"),
GetString(envelope, "msg"),
} {
if u := sanitizeCapacityExpansionURL(candidate); u != "" {
return u
}
}
return ""
}
// sanitizeCapacityExpansionURL accepts absolute HTTP(S) URLs and rejects empty,
// relative, or malformed report response values.
func sanitizeCapacityExpansionURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if (u.Scheme != "http" && u.Scheme != "https") ||
strings.TrimSpace(u.Host) == "" ||
strings.TrimSpace(u.Hostname()) == "" ||
strings.HasSuffix(u.Host, ":") ||
strings.HasPrefix(u.Path, "//") {
return ""
}
return u.String()
}
// AppendUploadFileEventDryRun describes the success-path report request that
// follows an upload. Error-path reporting uses the same envelope with status
// and code populated from the typed upload error at runtime.
func AppendUploadFileEventDryRun(dry *DryRunAPI, runtime *RuntimeContext, meta LarkCLIFileEventMeta) {
if dry == nil {
return
}
if strings.TrimSpace(meta.Status) == "" {
meta.Status = uploadFileEventStatusSuccess
}
dry.POST(larkCLIReportFileEventPath).
Desc("Best-effort report of the completed upload").
Body(buildUploadReportRequest(runtime, meta))
}
// buildUploadReportRequest assembles the minimal report body: fixed event
// fields plus tags. Identity fields are never included.
func buildUploadReportRequest(runtime *RuntimeContext, meta LarkCLIFileEventMeta) map[string]interface{} {
command := strings.TrimSpace(meta.Command)
if command == "" {
command = commandPathOrName(runtime)
}
tags := map[string]string{
"code": strings.TrimSpace(meta.Code),
"api_path": strings.TrimSpace(meta.APIPath),
"command": command,
"upload_mode": strings.TrimSpace(meta.UploadMode),
"resource_type": strings.TrimSpace(meta.ResourceType),
"status": strings.TrimSpace(meta.Status),
"mount_point": strings.TrimSpace(meta.ParentType),
"file_token": strings.TrimSpace(meta.FileToken),
}
return map[string]interface{}{
"file_scene": "lark-cli",
"scene": "upload",
"operation": "upload",
"tags": tags,
}
}
// appendTenantCapacityHint adds the capacity-expansion URL (carried by the
// report response's msg) to a tenant-capacity-exceeded error's hint, preserving
// any existing hint and never touching type / subtype / code / message. It is a
// no-op for non-quota errors and when the report returned no URL.
func appendTenantCapacityHint(err error, reportMsg string) error {
if !IsTenantCapacityExceeded(err) {
return err
}
url := strings.TrimSpace(reportMsg)
if url == "" {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || p == nil {
return err
}
hint := "tenant storage capacity is exceeded. Open this URL to expand capacity: " + url
switch {
case strings.TrimSpace(p.Hint) == "":
p.Hint = hint
case strings.Contains(p.Hint, url):
// already present; do not duplicate
default:
p.Hint = p.Hint + "\n" + hint
}
return err
}
// commandPathOrName returns the best-effort command identifier for upload
// reporting, preferring the full command path and falling back to the shortcut
// name. Empty is allowed for low-level helpers used outside a mounted shortcut.
func commandPathOrName(runtime *RuntimeContext) string {
if runtime == nil {
return ""
}
if runtime.Cmd != nil {
path := strings.TrimSpace(runtime.Cmd.CommandPath())
path = strings.TrimPrefix(path, "lark-cli ")
path = strings.TrimPrefix(path, "lark ")
if path != "" {
return path
}
}
return runtime.Command()
}

View File

@@ -0,0 +1,369 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"net/http"
"strings"
"testing"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
"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"
)
// newUploadFileEventRuntime creates an isolated runtime and HTTP stub registry
// for upload file-event reporting tests.
func newUploadFileEventRuntime(t *testing.T) (*RuntimeContext, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, reg := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+upload"}, cfg, f, core.AsUser)
return rt, reg
}
// testCapacityExpansionURL is a placeholder capacity-expansion URL used in
// tests. It intentionally uses example.com so no internal endpoint is embedded
// in the repository.
const testCapacityExpansionURL = "https://example.com/space/upload/pay/prepare"
// registerReportStub registers a report_file_event response with no message.
func registerReportStub(t *testing.T, reg *httpmock.Registry, code int) *httpmock.Stub {
t.Helper()
return registerReportStubWithMsg(t, reg, code, "")
}
// registerReportStubWithMsg registers a report_file_event stub returning the
// given top-level code and msg.
func registerReportStubWithMsg(t *testing.T, reg *httpmock.Registry, code int, msg string) *httpmock.Stub {
t.Helper()
return registerReportStubWithBody(t, reg, map[string]interface{}{
"code": code,
"data": map[string]interface{}{},
"msg": msg,
})
}
// registerReportStubWithBody registers the supplied report_file_event response.
func registerReportStubWithBody(t *testing.T, reg *httpmock.Registry, body map[string]interface{}) *httpmock.Stub {
t.Helper()
stub := &httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
func TestIsTenantCapacityExceeded(t *testing.T) {
if !IsTenantCapacityExceeded(errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)) {
t.Fatal("code 1061101 should be recognized as tenant capacity exceeded")
}
// Legacy quota codes are intentionally no longer recognized: only the
// tenant-capacity-exceeded code 1061101 gates the expansion hint.
for _, code := range []int{11001, 90008072, 90003081, 10690008072, 10690003081} {
err := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(code)
if IsTenantCapacityExceeded(err) {
t.Fatalf("code %d must not be recognized as tenant capacity exceeded", code)
}
}
if IsTenantCapacityExceeded(errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(12345)) {
t.Fatal("unexpected recognition for unrelated quota code")
}
if IsTenantCapacityExceeded(errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input")) {
t.Fatal("non api error must not be recognized")
}
}
func TestReportUploadFileEvent_Success_ReportsOnceWithMinimalBody(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reportStub := registerReportStub(t, reg, 0)
meta := LarkCLIFileEventMeta{
APIPath: "/open-apis/drive/v1/medias/upload_all",
Command: "drive +upload",
UploadMode: "singlepart",
ResourceType: "media",
ParentType: "docx_file",
FileToken: "boxcnabc123",
}
ReportUploadFileEvent(runtime, meta)
ReportUploadFileEvent(runtime, meta)
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
body := decodeCapturedDriveMediaJSONBody(t, reportStub)
assertReportEnvelope(t, body)
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags := assertTagsObject(t, body)
if got := tags["status"]; got != uploadFileEventStatusSuccess {
t.Fatalf("tags.status = %v, want success", got)
}
if got := tags["api_path"]; got != meta.APIPath {
t.Fatalf("tags.api_path = %v, want %s", got, meta.APIPath)
}
if got := tags["command"]; got != meta.Command {
t.Fatalf("tags.command = %v, want %s", got, meta.Command)
}
if got := tags["upload_mode"]; got != meta.UploadMode {
t.Fatalf("tags.upload_mode = %v, want %s", got, meta.UploadMode)
}
if got := tags["resource_type"]; got != meta.ResourceType {
t.Fatalf("tags.resource_type = %v, want %s", got, meta.ResourceType)
}
if got := tags["mount_point"]; got != meta.ParentType {
t.Fatalf("tags.mount_point = %v, want %s", got, meta.ParentType)
}
if got := tags["file_token"]; got != meta.FileToken {
t.Fatalf("tags.file_token = %v, want %s", got, meta.FileToken)
}
}
func TestBuildUploadReportRequest_CommandOmitsBinaryName(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
drive := &cobra.Command{Use: "drive"}
upload := &cobra.Command{Use: "+upload"}
root.AddCommand(drive)
drive.AddCommand(upload)
body := buildUploadReportRequest(&RuntimeContext{Cmd: upload}, LarkCLIFileEventMeta{})
tags := assertTagsObject(t, body)
if got := tags["command"]; got != "drive +upload" {
t.Fatalf("tags.command = %v, want drive +upload", got)
}
}
func TestReportUploadFileEventOnError_ReportsAndPreservesError(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reportStub := registerReportStub(t, reg, 0)
uploadErr := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(42)
meta := LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_all"}
returned := ReportUploadFileEventOnError(runtime, uploadErr, meta)
if returned != uploadErr {
t.Fatalf("returned error changed: got %v want original %v", returned, uploadErr)
}
returned = ReportUploadFileEventOnError(runtime, uploadErr, meta)
if returned != uploadErr {
t.Fatalf("second call changed error: got %v want original %v", returned, uploadErr)
}
if len(reportStub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(reportStub.CapturedBodies))
}
tags := assertTagsObject(t, decodeCapturedDriveMediaJSONBody(t, reportStub))
if got := tags["status"]; got != uploadFileEventStatusError {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["code"]; got != "42" {
t.Fatalf("tags.code = %v, want 42", got)
}
}
func TestReportUploadFileEventOnError_ReportFailureDoesNotReplaceUploadError(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: larkCLIReportFileEventPath,
Body: map[string]interface{}{"code": 999, "msg": "report rejected"},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(10690008072)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
if returned != uploadErr {
t.Fatalf("returned error changed: got %v want original %v", returned, uploadErr)
}
}
func TestReportUploadFileEventOnError_AppendsCapacityExpansionHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithBody(t, reg, map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"msg": testCapacityExpansionURL,
},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if !strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("hint = %q, want it to contain %q", p.Hint, testCapacityExpansionURL)
}
if p.Code != 1061101 {
t.Fatalf("code changed: got %d, want 1061101", p.Code)
}
if p.Subtype != errs.SubtypeQuotaExceeded {
t.Fatalf("subtype changed: got %q, want %q", p.Subtype, errs.SubtypeQuotaExceeded)
}
}
func TestReportUploadFileEventOnError_TopLevelSuccessMsgDoesNotBecomeHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithMsg(t, reg, 0, "success")
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("top-level success msg must not become hint, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_InvalidURLInDataMsgIsIgnored(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithBody(t, reg, map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"msg": "https://https://example.com/space/upload/pay/prepare",
},
})
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("invalid data.msg URL must be ignored, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_EmptyReportMsgYieldsNoHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
// report returns code 0 but no msg: no capacity-expansion URL to surface.
registerReportStub(t, reg, 0)
uploadErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "quota exceeded").WithCode(1061101)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{APIPath: "/open-apis/drive/v1/files/upload_prepare"})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.TrimSpace(p.Hint) != "" {
t.Fatalf("empty report msg must yield no hint, got %q", p.Hint)
}
if p.Code != 1061101 {
t.Fatalf("code changed: got %d, want 1061101", p.Code)
}
}
func TestReportUploadFileEventOnError_NonQuotaErrorKeepsHint(t *testing.T) {
runtime, reg := newUploadFileEventRuntime(t)
registerReportStubWithMsg(t, reg, 0, testCapacityExpansionURL)
uploadErr := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithCode(42)
returned := ReportUploadFileEventOnError(runtime, uploadErr, LarkCLIFileEventMeta{})
p, ok := errs.ProblemOf(returned)
if !ok || p == nil {
t.Fatalf("expected typed problem, got %T (%v)", returned, returned)
}
if strings.Contains(p.Hint, testCapacityExpansionURL) {
t.Fatalf("non-quota error must not get expansion hint, got %q", p.Hint)
}
}
func TestReportUploadFileEventOnError_NilErrorIsNoop(t *testing.T) {
runtime, _ := newUploadFileEventRuntime(t)
// No report stub is registered: a nil upload error must not attempt a
// report at all (an unexpected POST would fail with "no stub").
if err := ReportUploadFileEventOnError(runtime, nil, LarkCLIFileEventMeta{}); err != nil {
t.Fatalf("nil upload error should return nil, got %v", err)
}
// The reporting mark must remain unconsumed, proving no report fired.
if !runtime.MarkFileEventReported() {
t.Fatal("nil error path must not consume the file-event report mark")
}
}
type contextBlockingRoundTripper struct{}
// RoundTrip blocks until the request context expires, allowing timeout behavior
// to be tested without performing a network request.
func (contextBlockingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
<-req.Context().Done()
return nil, req.Context().Err()
}
func TestPostUploadFileEventWithTimeout_BoundsBestEffortRequest(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, _, _, _ := cmdutil.TestFactory(t, cfg)
f.LarkClient = func() (*lark.Client, error) {
return lark.NewClient("cli_x", "test-secret", lark.WithHttpClient(&http.Client{
Transport: contextBlockingRoundTripper{},
})), nil
}
runtime := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+upload"}, cfg, f, core.AsUser)
started := time.Now()
if got := postUploadFileEventWithTimeout(runtime, LarkCLIFileEventMeta{}, 10*time.Millisecond); got != "" {
t.Fatalf("postUploadFileEventWithTimeout() = %q, want empty result on timeout", got)
}
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("best-effort report took %s, want it bounded by the request context", elapsed)
}
}
// assertReportEnvelope verifies the fixed fields in an upload report body.
func assertReportEnvelope(t *testing.T, body map[string]interface{}) {
t.Helper()
if got := body["file_scene"]; got != "lark-cli" {
t.Fatalf("file_scene = %v, want lark-cli", got)
}
if got := body["scene"]; got != "upload" {
t.Fatalf("scene = %v, want upload", got)
}
if got := body["operation"]; got != "upload" {
t.Fatalf("operation = %v, want upload", got)
}
}
// assertTagsObject returns the report tags as a generic JSON-style object.
func assertTagsObject(t *testing.T, body map[string]interface{}) map[string]interface{} {
t.Helper()
switch tags := body["tags"].(type) {
case map[string]interface{}:
return tags
case map[string]string:
result := make(map[string]interface{}, len(tags))
for key, value := range tags {
result[key] = value
}
return result
default:
t.Fatalf("tags = %#v, want object", body["tags"])
return nil
}
}

View File

@@ -40,16 +40,19 @@ type RuntimeContext struct {
Config *core.CliConfig
Cmd *cobra.Command
Format string
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
JqExpr string // --jq expression; empty = no filter
outputErrOnce sync.Once // guards first-error capture in Out()/OutFormat()
outputErr error // deferred error from jq filtering; written at most once
botOnly bool // set by framework for bot-only shortcuts
resolvedAs core.Identity // effective identity resolved by framework
// fileEventReportOnce guards best-effort upload file-event reporting so it is
// emitted at most once per command run (see MarkFileEventReported).
fileEventReportOnce sync.Once
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
stdinConsumed bool // set when an Input flag has consumed stdin (`-`); guards against a second flag also using `-` within the same call
}
// ── Identity ──
@@ -75,6 +78,20 @@ func (ctx *RuntimeContext) IsBot() bool {
return ctx.As().IsBot()
}
// MarkFileEventReported returns true only on the first successful mark within
// this RuntimeContext. Upload file-event reporting is best-effort and should
// happen at most once per command execution.
func (ctx *RuntimeContext) MarkFileEventReported() bool {
if ctx == nil {
return false
}
report := false
ctx.fileEventReportOnce.Do(func() {
report = true
})
return report
}
// Command returns the shortcut command name as cobra knows it (e.g.
// "+pivot-create"). Used by per-service helpers (e.g. sheets schema
// validation) that key off the shortcut identity.
@@ -450,14 +467,24 @@ func (ctx *RuntimeContext) callRaw(method, url string, params map[string]interfa
// Auth resolution is delegated to APIClient.DoSDKRequest to avoid duplicating
// the identity → token logic across the generic and shortcut API paths.
func (ctx *RuntimeContext) DoAPI(req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
return ctx.DoAPIWithContext(ctx.ctx, req, opts...)
}
// DoAPIWithContext executes a raw Lark SDK request with an explicit context.
// Callers that perform best-effort or otherwise bounded side requests can use
// this without changing the RuntimeContext's command-wide context.
func (ctx *RuntimeContext) DoAPIWithContext(callCtx context.Context, req *larkcore.ApiReq, opts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
if callCtx == nil {
callCtx = ctx.ctx
}
ac, err := ctx.getAPIClient()
if err != nil {
return nil, err
}
if optFn := cmdutil.ShortcutHeaderOpts(ctx.ctx); optFn != nil {
if optFn := cmdutil.ShortcutHeaderOpts(callCtx); optFn != nil {
opts = append(opts, optFn)
}
return ac.DoSDKRequest(ctx.ctx, req, ctx.As(), opts...)
return ac.DoSDKRequest(callCtx, req, ctx.As(), opts...)
}
// DoAPIAsBot executes a raw Lark SDK request using bot identity (tenant access token),

View File

@@ -105,6 +105,7 @@ func PlanImportDryRun(runtime *common.RuntimeContext, p ImportParams) *common.Dr
appendDriveImportFolderTokenWikiCheckDryRun(dry, spec)
appendDriveImportUploadDryRun(dry, spec, fileSize)
appendDriveImportUploadReportDryRun(dry, runtime, fileSize)
dry.POST("/open-apis/drive/v1/import_tasks").
Desc("[2] Create import task").
@@ -259,6 +260,24 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
})
}
// appendDriveImportUploadReportDryRun adds the best-effort upload report to an
// import dry-run plan, matching the single-part or multipart upload path.
func appendDriveImportUploadReportDryRun(dry *common.DryRunAPI, runtime *common.RuntimeContext, fileSize int64) {
apiPath := "/open-apis/drive/v1/medias/upload_all"
uploadMode := "singlepart"
if fileSize > common.MaxDriveMediaUploadSinglePartSize {
apiPath = "/open-apis/drive/v1/medias/upload_finish"
uploadMode = "multipart"
}
common.AppendUploadFileEventDryRun(dry, runtime, common.LarkCLIFileEventMeta{
APIPath: apiPath,
UploadMode: uploadMode,
ResourceType: "media",
ParentType: "ccm_import_open",
FileToken: "<file_token from upload response>",
})
}
// normalizeDriveImportKindForURL maps the server's import "type" field to a
// canonical kind BuildResourceURL recognizes. status.DocType comes straight
// from the API and isn't normalized; if it ever returns aliases like "sheets"

View File

@@ -109,14 +109,15 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
var got struct {
API []struct {
Desc string `json:"desc"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
if len(got.API) != 5 {
t.Fatalf("expected 5 API calls, got %d", len(got.API))
}
wantDesc := "After the import result returns the final cloud document target in bot mode, the CLI will also try to grant the current CLI user full_access on it."
if got.API[len(got.API)-1].Desc != wantDesc {
@@ -132,7 +133,11 @@ func TestDriveImportDryRunUsesExtensionlessDefaultName(t *testing.T) {
t.Fatalf("upload file_name = %q, want %q", uploadName, "base-import.xlsx")
}
importName, _ := got.API[2].Body["file_name"].(string)
if got.API[2].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[2].URL)
}
importName, _ := got.API[3].Body["file_name"].(string)
if importName != "base-import" {
t.Fatalf("import task file_name = %q, want %q", importName, "base-import")
}
@@ -186,8 +191,8 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 5 {
t.Fatalf("expected 5 API calls, got %d", len(got.API))
if len(got.API) != 6 {
t.Fatalf("expected 6 API calls, got %d", len(got.API))
}
if got.API[0].URL != "/open-apis/drive/v1/medias/upload_prepare" {
t.Fatalf("dry-run first URL = %q, want upload_prepare", got.API[0].URL)
@@ -198,6 +203,9 @@ func TestDriveImportDryRunShowsMultipartUploadForLargeFile(t *testing.T) {
if got.API[2].URL != "/open-apis/drive/v1/medias/upload_finish" {
t.Fatalf("dry-run third URL = %q, want upload_finish", got.API[2].URL)
}
if got.API[3].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[3].URL)
}
}
func TestDriveImportDryRunReturnsErrorForUnsafePath(t *testing.T) {
@@ -475,12 +483,16 @@ func TestDriveImportDryRunWithTargetToken(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
if len(got.API) != 4 {
t.Fatalf("expected 4 API calls, got %d", len(got.API))
}
// The import task body (API[1]) should contain target_token in point
importTaskBody := got.API[1].Body
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
// The import task body (API[2]) should contain target_token in point.
importTaskBody := got.API[2].Body
point, ok := importTaskBody["point"].(map[string]interface{})
if !ok {
t.Fatalf("point = %#v, want map", importTaskBody["point"])

View File

@@ -1109,8 +1109,8 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["parent_type"] != driveUploadParentTypeWiki {
t.Fatalf("parent_type = %#v, want %q", got.API[0].Body["parent_type"], driveUploadParentTypeWiki)
@@ -1118,11 +1118,14 @@ func TestDriveUploadDryRunUsesWikiTarget(t *testing.T) {
if got.API[0].Body["parent_node"] != "wikcn_dryrun_upload_target" {
t.Fatalf("parent_node = %#v, want %q", got.API[0].Body["parent_node"], "wikcn_dryrun_upload_target")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
if got.API[2].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[2].URL)
}
if got.API[2].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[2].Body["with_url"])
}
wantPostUploadNote := "After file upload succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new file."
if got.PostUploadNote != wantPostUploadNote {
@@ -1210,17 +1213,20 @@ func TestDriveUploadDryRunIncludesFileToken(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
}
if got.API[1].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[1].URL)
if got.API[1].URL != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("report URL = %q, want lark_cli_file_event/report", got.API[1].URL)
}
if got.API[1].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[1].Body["with_url"])
if got.API[2].URL != "/open-apis/drive/v1/metas/batch_query" {
t.Fatalf("metadata URL = %q, want metas/batch_query", got.API[2].URL)
}
if got.API[2].Body["with_url"] != true {
t.Fatalf("metadata with_url = %#v, want true", got.API[2].Body["with_url"])
}
}
@@ -1264,8 +1270,8 @@ func TestDriveUploadDryRunBotOverwriteSkipsPermissionGrantHint(t *testing.T) {
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
if len(got.API) != 3 {
t.Fatalf("expected 3 API calls, got %d", len(got.API))
}
if got.API[0].Body["file_token"] != "boxcn_dryrun_overwrite" {
t.Fatalf("file_token = %#v, want %q", got.API[0].Body["file_token"], "boxcn_dryrun_overwrite")
@@ -1596,3 +1602,254 @@ func decodeDriveMultipartBody(t *testing.T, stub *httpmock.Stub) capturedDriveMu
}
return body
}
const driveReportFileEventPath = "/open-apis/drive/v1/lark_cli_file_event/report"
// testDriveCapacityExpansionURL is a placeholder capacity-expansion URL used in
// tests. It intentionally uses example.com so no internal endpoint is embedded
// in the repository.
const testDriveCapacityExpansionURL = "https://example.com/space/upload/pay/prepare"
// registerDriveReportStub registers a successful report_file_event stub.
func registerDriveReportStub(t *testing.T, reg *httpmock.Registry) *httpmock.Stub {
t.Helper()
return registerDriveReportStubWithMsg(t, reg, "")
}
// registerDriveReportStubWithMsg registers a report_file_event stub returning
// code 0 and, when msg is non-empty, carrying it as data.msg.
func registerDriveReportStubWithMsg(t *testing.T, reg *httpmock.Registry, msg string) *httpmock.Stub {
t.Helper()
body := map[string]interface{}{"code": 0, "data": map[string]interface{}{}}
if msg != "" {
body["msg"] = "success"
body["data"] = map[string]interface{}{"msg": msg}
}
stub := &httpmock.Stub{
Method: "POST",
URL: driveReportFileEventPath,
Body: body,
Reusable: true,
}
reg.Register(stub)
return stub
}
// decodeDriveReportTags verifies one captured Drive report and returns its tags.
func decodeDriveReportTags(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
if len(stub.CapturedBodies) != 1 {
t.Fatalf("report call count = %d, want 1", len(stub.CapturedBodies))
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBodies[0], &body); err != nil {
t.Fatalf("decode report body: %v", err)
}
if got := body["file_scene"]; got != "lark-cli" {
t.Fatalf("file_scene = %v, want lark-cli", got)
}
if got := body["scene"]; got != "upload" {
t.Fatalf("scene = %v, want upload", got)
}
if _, ok := body["user_id"]; ok {
t.Fatalf("user_id must be omitted, got %v", body["user_id"])
}
if _, ok := body["tenant_id"]; ok {
t.Fatalf("tenant_id must be omitted, got %v", body["tenant_id"])
}
tags, ok := body["tags"].(map[string]interface{})
if !ok {
t.Fatalf("tags = %#v, want object", body["tags"])
}
return tags
}
func TestDriveUploadSmallFileReportFileEventOnSuccess(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-small-ok", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStub(t, reg)
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": "file_report_ok"},
},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("expected upload to succeed, got error: %v", err)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "success" {
t.Fatalf("tags.status = %v, want success", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/files/upload_all" {
t.Fatalf("tags.api_path = %v", got)
}
if got := tags["upload_mode"]; got != "singlepart" {
t.Fatalf("tags.upload_mode = %v, want singlepart", got)
}
if got := tags["resource_type"]; got != "file" {
t.Fatalf("tags.resource_type = %v, want file", got)
}
if got := tags["mount_point"]; got != driveUploadParentTypeExplorer {
t.Fatalf("tags.mount_point = %v, want %s", got, driveUploadParentTypeExplorer)
}
if got := tags["file_token"]; got != "file_report_ok" {
t.Fatalf("tags.file_token = %v, want file_report_ok", got)
}
}
func TestDriveUploadSmallFileReportFileEventOnError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStubWithMsg(t, reg, testDriveCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1061101 {
t.Fatalf("code = %d, want original 1061101", p.Code)
}
if !strings.Contains(p.Hint, testDriveCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "error" {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["code"]; got != "1061101" {
t.Fatalf("tags.code = %v, want 1061101", got)
}
}
func TestDriveUploadLargeFileReportFileEventOnPrepareError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-large-prepare-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reportStub := registerDriveReportStubWithMsg(t, reg, testDriveCapacityExpansionURL)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_prepare",
Body: map[string]interface{}{"code": 1061101, "msg": "tenant capacity exceeded"},
})
origDir, _ := os.Getwd()
tmpDir := t.TempDir()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Chdir() error: %v", err)
}
defer os.Chdir(origDir)
fh, err := os.Create("large.bin")
if err != nil {
t.Fatalf("Create() error: %v", err)
}
if err := fh.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() error: %v", err)
}
if err := fh.Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
err = mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "large.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Code != 1061101 {
t.Fatalf("expected typed api error code 1061101, got %T (%v)", err, err)
}
if !strings.Contains(p.Hint, testDriveCapacityExpansionURL) {
t.Fatalf("hint = %q, want capacity expansion URL", p.Hint)
}
tags := decodeDriveReportTags(t, reportStub)
if got := tags["status"]; got != "error" {
t.Fatalf("tags.status = %v, want error", got)
}
if got := tags["upload_mode"]; got != "multipart" {
t.Fatalf("tags.upload_mode = %v, want multipart", got)
}
if got := tags["api_path"]; got != "/open-apis/drive/v1/files/upload_prepare" {
t.Fatalf("tags.api_path = %v, want upload_prepare", got)
}
}
func TestDriveUploadReportFileEventFailureKeepsUploadError(t *testing.T) {
uploadTestConfig := &core.CliConfig{
AppID: "drive-upload-report-keeps-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: driveReportFileEventPath,
Body: map[string]interface{}{"code": 500, "msg": "report rejected"},
Reusable: true,
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{"code": 1001, "msg": "quota exceeded"},
})
withDriveWorkingDir(t, t.TempDir())
if err := os.WriteFile("small.bin", make([]byte, 1024), 0644); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
err := mountAndRunDrive(t, DriveUpload, []string{
"+upload", "--file", "small.bin", "--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T (%v)", err, err)
}
if p.Code != 1001 {
t.Fatalf("code = %d, want original upload code 1001", p.Code)
}
if !strings.Contains(err.Error(), "quota exceeded") {
t.Fatalf("error lost original message: %v", err)
}
}

View File

@@ -23,6 +23,13 @@ const (
driveUploadParentTypeWiki = "wiki"
)
const (
driveUploadAllPath = "/open-apis/drive/v1/files/upload_all"
driveUploadPreparePath = "/open-apis/drive/v1/files/upload_prepare"
driveUploadPartPath = "/open-apis/drive/v1/files/upload_part"
driveUploadFinishPath = "/open-apis/drive/v1/files/upload_finish"
)
type driveUploadSpec struct {
FilePath string
FileToken string
@@ -123,8 +130,15 @@ var DriveUpload = common.Shortcut{
}
d := common.NewDryRunAPI().
Desc("multipart/form-data upload (files > 20MB use chunked 3-step upload), then fetch the real Drive URL via metadata").
POST("/open-apis/drive/v1/files/upload_all").
POST(driveUploadAllPath).
Body(body)
common.AppendUploadFileEventDryRun(d, runtime, common.LarkCLIFileEventMeta{
APIPath: driveUploadAllPath,
UploadMode: "singlepart",
ResourceType: "file",
ParentType: target.ParentType,
FileToken: "<file_token from upload response>",
})
d.POST("/open-apis/drive/v1/metas/batch_query").
Desc("Fetch the uploaded file's real access URL").
Body(map[string]interface{}{
@@ -253,26 +267,35 @@ func uploadFileToDrive(ctx context.Context, runtime *common.RuntimeContext, file
}
fd.AddFile("file", f)
meta := common.LarkCLIFileEventMeta{
APIPath: driveUploadAllPath,
UploadMode: "singlepart",
ResourceType: "file",
ParentType: target.ParentType,
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_all",
ApiPath: driveUploadAllPath,
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
if errs.IsTyped(err) {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload failed: %v", err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, wrapDriveNetworkErr(err, "upload failed: %v", err), meta)
}
data, err := runtime.ClassifyAPIResponse(apiResp)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fileToken := common.GetString(data, "file_token")
if fileToken == "" {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned"), meta)
}
meta.FileToken = fileToken
common.ReportUploadFileEvent(runtime, meta)
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(data),
@@ -294,9 +317,17 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
if existingFileToken != "" {
prepareBody["file_token"] = existingFileToken
}
prepareResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody)
meta := common.LarkCLIFileEventMeta{
APIPath: driveUploadPreparePath,
UploadMode: "multipart",
ResourceType: "file",
ParentType: target.ParentType,
}
prepareResult, err := runtime.CallAPITyped("POST", driveUploadPreparePath, nil, prepareBody)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
uploadID := common.GetString(prepareResult, "upload_id")
@@ -306,15 +337,16 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
blockNum := int(blockNumF)
if uploadID == "" || blockSize <= 0 || blockNum <= 0 {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse,
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse,
"upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d",
uploadID, blockSize, blockNum)
uploadID, blockSize, blockNum), meta)
}
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload: %s, block size %s, %d block(s)\n",
common.FormatSize(fileSize), common.FormatSize(blockSize), blockNum)
// Step 2: Upload parts
meta.APIPath = driveUploadPartPath
for seq := 0; seq < blockNum; seq++ {
offset := int64(seq) * blockSize
partSize := blockSize
@@ -324,7 +356,7 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
partFile, err := runtime.FileIO().Open(filePath)
if err != nil {
return driveUploadResult{}, driveInputStatError(err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, driveInputStatError(err), meta)
}
fd := larkcore.NewFormdata()
@@ -335,39 +367,42 @@ func uploadFileMultipart(_ context.Context, runtime *common.RuntimeContext, file
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/files/upload_part",
ApiPath: driveUploadPartPath,
Body: fd,
}, larkcore.WithFileUpload())
partFile.Close()
if err != nil {
if errs.IsTyped(err) {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
return driveUploadResult{}, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err)
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, wrapDriveNetworkErr(err, "upload part %d/%d failed: %v", seq+1, blockNum, err), meta)
}
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, blockNum, common.FormatSize(partSize))
}
// Step 3: Finish
meta.APIPath = driveUploadFinishPath
finishBody := map[string]interface{}{
"upload_id": uploadID,
"block_num": blockNum,
}
finishResult, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/files/upload_finish", nil, finishBody)
finishResult, err := runtime.CallAPITyped("POST", driveUploadFinishPath, nil, finishBody)
if err != nil {
return driveUploadResult{}, err
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, err, meta)
}
fileToken := common.GetString(finishResult, "file_token")
if fileToken == "" {
return driveUploadResult{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned")
return driveUploadResult{}, common.ReportUploadFileEventOnError(runtime, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload_finish succeeded but no file_token returned"), meta)
}
meta.FileToken = fileToken
common.ReportUploadFileEvent(runtime, meta)
return driveUploadResult{
FileToken: fileToken,
Version: driveUploadVersionFromData(finishResult),

View File

@@ -2,13 +2,14 @@
## Metrics
- Denominator: 32 leaf commands
- Covered: 13
- Coverage: 40.6%
- Covered: 14
- Coverage: 43.8%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed.
- TestDrive_ImportWorkflow: proves `drive +import` against the real backend. It imports a temporary Markdown file as docx, waits for the async task when needed, verifies the returned document token, and deletes the imported document during cleanup.
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
@@ -34,7 +35,7 @@
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| | drive +import | shortcut | | none | no import workflow yet |
| | drive +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRunFolderTokenWikiProbe + drive_import_workflow_test.go::TestDrive_ImportWorkflow | `--file`; `--type docx`; upload report request shape; async ticket polling; imported token cleanup | dry-run pins the upload/report/import request chain; live workflow imports a real Markdown fixture and deletes the resulting docx |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |

View File

@@ -51,7 +51,30 @@ func TestDriveImportDryRunFolderTokenWikiProbe(t *testing.T) {
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("data.api.1.url = %q, want upload_all\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.2.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.2.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
if got := clie2e.DryRunGet(out, "api.2.method").String(); got != "POST" {
t.Fatalf("data.api.2.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.2.url").String(); got != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("data.api.2.url = %q, want report_file_event\nstdout:\n%s", got, out)
}
reportChecks := map[string]string{
"api.2.body.file_scene": "lark-cli",
"api.2.body.scene": "upload",
"api.2.body.operation": "upload",
"api.2.body.tags.api_path": "/open-apis/drive/v1/medias/upload_all",
"api.2.body.tags.command": "drive +import",
"api.2.body.tags.upload_mode": "singlepart",
"api.2.body.tags.resource_type": "media",
"api.2.body.tags.status": "success",
"api.2.body.tags.mount_point": "ccm_import_open",
"api.2.body.tags.file_token": "<file_token from upload response>",
}
for path, want := range reportChecks {
if got := clie2e.DryRunGet(out, path).String(); got != want {
t.Fatalf("data.%s = %q, want %q\nstdout:\n%s", path, got, want, out)
}
}
if got := clie2e.DryRunGet(out, "api.3.body.point.mount_key").String(); got != "fldcnImportDryRunTarget" {
t.Fatalf("data.api.3.body.point.mount_key = %q, want fldcnImportDryRunTarget\nstdout:\n%s", got, out)
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_ImportWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
workDir := t.TempDir()
fileName := "import-" + clie2e.GenerateSuffix() + ".md"
if err := os.WriteFile(filepath.Join(workDir, fileName), []byte("# lark-cli import e2e\n"), 0o644); err != nil {
t.Fatalf("write import fixture: %v", err)
}
var importedToken string
importedType := "docx"
parentT.Cleanup(func() {
if importedToken == "" {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
deleteResult, deleteErr := DeleteDriveResourceAndVerify(cleanupCtx, importedToken, importedType, "bot")
clie2e.ReportCleanupFailure(parentT, "delete imported document "+importedToken, deleteResult, deleteErr)
})
importCtx, importCancel := context.WithTimeout(ctx, 90*time.Second)
result, err := clie2e.RunCmd(importCtx, clie2e.Request{
Args: []string{"drive", "+import", "--file", fileName, "--type", "docx"},
WorkDir: workDir,
DefaultAs: "bot",
})
importCancel()
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
ticket := gjson.Get(result.Stdout, "data.ticket").String()
require.NotEmpty(t, ticket, "import should return a task ticket, stdout:\n%s", result.Stdout)
if got := gjson.Get(result.Stdout, "data.type").String(); got != "" {
importedType = got
}
importedToken = gjson.Get(result.Stdout, "data.token").String()
if importedToken == "" {
importedToken, importedType = waitDriveImportReady(t, ctx, ticket, importedType)
}
require.NotEmpty(t, importedToken, "ready import should return a document token")
for _, reportOnlyField := range []string{"data.file_scene", "data.scene", "data.operation"} {
if gjson.Get(result.Stdout, reportOnlyField).Exists() {
t.Fatalf("report-only field %q leaked into import stdout:\n%s", reportOnlyField, result.Stdout)
}
}
}
// waitDriveImportReady polls an import task until it returns a document token or
// the workflow timeout expires.
func waitDriveImportReady(t *testing.T, ctx context.Context, ticket, fallbackType string) (string, string) {
t.Helper()
deadline := time.NewTimer(90 * time.Second)
defer deadline.Stop()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+task_result", "--scenario", "import", "--ticket", ticket},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
if gjson.Get(result.Stdout, "data.failed").Bool() {
t.Fatalf("import task failed: %s", result.Stdout)
}
if gjson.Get(result.Stdout, "data.ready").Bool() {
docType := gjson.Get(result.Stdout, "data.type").String()
if docType == "" {
docType = fallbackType
}
return gjson.Get(result.Stdout, "data.token").String(), docType
}
select {
case <-ctx.Done():
t.Fatalf("wait for import task %s: %v", ticket, ctx.Err())
case <-deadline.C:
t.Fatalf("import task %s did not become ready within 90s", ticket)
case <-ticker.C:
}
}
}

View File

@@ -40,6 +40,7 @@ func TestDriveUploadDryRun_WikiTarget(t *testing.T) {
assert.Contains(t, output, "parent_node")
assert.Contains(t, output, "wikcnDryRunUploadTarget")
assert.Contains(t, output, `"parent_type": "wiki"`)
assertDriveUploadReportDryRun(t, result.Stdout, "wiki")
}
func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
@@ -67,6 +68,7 @@ func TestDriveUploadDryRun_WithFileToken(t *testing.T) {
assert.Contains(t, output, `"with_url": true`)
assert.Contains(t, output, `"parent_node": "fldDryRunUploadTarget"`)
assert.Equal(t, "boxcnDryRunOverwriteTarget", clie2e.DryRunGet(output, "api.0.body.file_token").String())
assertDriveUploadReportDryRun(t, result.Stdout, "explorer")
}
func TestDriveUploadDryRunRejectsEmptyWikiToken(t *testing.T) {
@@ -96,3 +98,32 @@ func setDriveDryRunConfigEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_APP_SECRET", "drive_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// assertDriveUploadReportDryRun verifies the upload report request in a dry-run
// plan for the expected Drive mount point.
func assertDriveUploadReportDryRun(t *testing.T, out, mountPoint string) {
t.Helper()
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "POST" {
t.Fatalf("data.api.1.method = %q, want POST\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/lark_cli_file_event/report" {
t.Fatalf("data.api.1.url = %q, want report_file_event\nstdout:\n%s", got, out)
}
checks := map[string]string{
"api.1.body.file_scene": "lark-cli",
"api.1.body.scene": "upload",
"api.1.body.operation": "upload",
"api.1.body.tags.api_path": "/open-apis/drive/v1/files/upload_all",
"api.1.body.tags.command": "drive +upload",
"api.1.body.tags.upload_mode": "singlepart",
"api.1.body.tags.resource_type": "file",
"api.1.body.tags.status": "success",
"api.1.body.tags.mount_point": mountPoint,
"api.1.body.tags.file_token": "<file_token from upload response>",
}
for path, want := range checks {
if got := clie2e.DryRunGet(out, path).String(); got != want {
t.Fatalf("data.%s = %q, want %q\nstdout:\n%s", path, got, want, out)
}
}
}

View File

@@ -64,7 +64,10 @@ func TestDrive_UploadWorkflow(t *testing.T) {
args = append(args, "--file-token", fileToken)
}
result, err := clie2e.RunCmd(ctx, clie2e.Request{
uploadCtx, uploadCancel := context.WithTimeout(ctx, 45*time.Second)
defer uploadCancel()
result, err := clie2e.RunCmd(uploadCtx, clie2e.Request{
Args: args,
WorkDir: workDir,
DefaultAs: "bot",
@@ -72,6 +75,11 @@ func TestDrive_UploadWorkflow(t *testing.T) {
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
for _, reportOnlyField := range []string{"data.file_scene", "data.scene", "data.operation"} {
if gjson.Get(result.Stdout, reportOnlyField).Exists() {
t.Fatalf("report-only field %q leaked into upload stdout:\n%s", reportOnlyField, result.Stdout)
}
}
gotToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, gotToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)

View File

@@ -14,12 +14,11 @@ import (
"github.com/stretchr/testify/require"
)
// TestSheets_WorkbookImportDryRun pins the +workbook-import dry-run shape: a
// two-step plan that uploads the local file (drive media upload) and creates
// an import task with the doc type pinned to "sheet". This is the new shortcut
// added in this branch — distinct from generic drive +import because it
// hard-codes type=sheet and uses --name instead of --file-name. AGENTS.md
// requires a dry-run E2E to lock the request shape before a live run.
// TestSheets_WorkbookImportDryRun pins the +workbook-import dry-run shape:
// upload the local file, best-effort report the upload, create an import task,
// and poll it with the doc type pinned to "sheet". The shortcut is distinct
// from generic drive +import because it hard-codes type=sheet and uses --name
// instead of --file-name.
func TestSheets_WorkbookImportDryRun(t *testing.T) {
setSheetsDryRunEnv(t)
@@ -56,17 +55,28 @@ func TestSheets_WorkbookImportDryRun(t *testing.T) {
require.Equal(t, "ccm_import_open", clie2e.DryRunGet(out, "api.0.body.parent_type").String(),
"stdout:\n%s", out)
// api.1 — create import task. type=sheet is the wrapper's whole reason for
// api.1 — report the completed upload using the workbook-import command
// identity while retaining the shared drive import mount point.
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/drive/v1/lark_cli_file_event/report",
clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "lark-cli", clie2e.DryRunGet(out, "api.1.body.file_scene").String(), "stdout:\n%s", out)
require.Equal(t, "sheets +workbook-import", clie2e.DryRunGet(out, "api.1.body.tags.command").String(),
"stdout:\n%s", out)
require.Equal(t, "ccm_import_open", clie2e.DryRunGet(out, "api.1.body.tags.mount_point").String(),
"stdout:\n%s", out)
// api.2 — create import task. type=sheet is the wrapper's whole reason for
// existing (drive +import would require --doc-type sheet explicitly);
// --name reaches the wire as file_name; file_extension is sniffed from
// the local file (.csv).
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/drive/v1/import_tasks",
clie2e.DryRunGet(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "sheet", clie2e.DryRunGet(out, "api.1.body.type").String(),
clie2e.DryRunGet(out, "api.2.url").String(), "stdout:\n%s", out)
require.Equal(t, "sheet", clie2e.DryRunGet(out, "api.2.body.type").String(),
"workbook-import must hard-code type=sheet; stdout:\n%s", out)
require.Equal(t, "imported", clie2e.DryRunGet(out, "api.1.body.file_name").String(),
require.Equal(t, "imported", clie2e.DryRunGet(out, "api.2.body.file_name").String(),
"--name should reach file_name; stdout:\n%s", out)
require.Equal(t, "csv", clie2e.DryRunGet(out, "api.1.body.file_extension").String(),
require.Equal(t, "csv", clie2e.DryRunGet(out, "api.2.body.file_extension").String(),
"file_extension sniffed from .csv; stdout:\n%s", out)
}