mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
3 Commits
codex/base
...
feat/uploa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b573a3fe01 | ||
|
|
7846b89754 | ||
|
|
89138261fd |
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
261
shortcuts/common/lark_cli_file_event.go
Normal file
261
shortcuts/common/lark_cli_file_event.go
Normal 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()
|
||||
}
|
||||
369
shortcuts/common/lark_cli_file_event_test.go
Normal file
369
shortcuts/common/lark_cli_file_event_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
108
tests/cli_e2e/drive/drive_import_workflow_test.go
Normal file
108
tests/cli_e2e/drive/drive_import_workflow_test.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user