mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(common): backfill resource URL when create APIs omit it (#680)
Add BuildResourceURL helper and wire it into doc/sheets/drive/base/wiki create paths so callers always receive a clickable link, even when the backend response (MCP degraded path or upstream OpenAPI) returns an empty URL field. The fallback uses the brand-standard host (www.feishu.cn / www.larksuite.com), which redirects to the tenant domain. Affected entries: - docs +create v1 / v2 - sheets +create - drive +create-folder / +import / +upload (newly exposes url) - wiki +node-create (newly exposes url) drive +create-shortcut is intentionally skipped because the URL form depends on the underlying file kind, which the shortcut payload does not carry.
This commit is contained in:
57
shortcuts/common/resource_url.go
Normal file
57
shortcuts/common/resource_url.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// BuildResourceURL returns a brand-standard, user-facing URL for a freshly
|
||||
// created Lark resource. It is intended as a fallback when the create API does
|
||||
// not return a URL field (e.g. drive +upload, wiki +node-create) or when the
|
||||
// returned URL is empty (e.g. degraded MCP responses for docs +create v1).
|
||||
//
|
||||
// The returned URL points at the brand's standard host (www.feishu.cn /
|
||||
// www.larksuite.com), which transparently redirects to the tenant-specific
|
||||
// domain. It is NOT a guess at the tenant's vanity domain.
|
||||
//
|
||||
// Returns "" when token is empty or kind is unrecognized — callers should
|
||||
// only set the field when the result is non-empty so that "" never overrides
|
||||
// a real URL the backend already returned.
|
||||
func BuildResourceURL(brand core.LarkBrand, kind, token string) string {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
host := "https://www.feishu.cn"
|
||||
if brand == core.BrandLark {
|
||||
host = "https://www.larksuite.com"
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case "docx":
|
||||
return host + "/docx/" + token
|
||||
case "doc":
|
||||
return host + "/doc/" + token
|
||||
case "sheet":
|
||||
return host + "/sheets/" + token
|
||||
case "bitable":
|
||||
return host + "/base/" + token
|
||||
case "wiki":
|
||||
return host + "/wiki/" + token
|
||||
case "file":
|
||||
return host + "/file/" + token
|
||||
case "folder":
|
||||
return host + "/drive/folder/" + token
|
||||
case "mindnote":
|
||||
return host + "/mindnote/" + token
|
||||
case "slides":
|
||||
return host + "/slides/" + token
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
50
shortcuts/common/resource_url_test.go
Normal file
50
shortcuts/common/resource_url_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
func TestBuildResourceURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
brand core.LarkBrand
|
||||
kind string
|
||||
token string
|
||||
want string
|
||||
}{
|
||||
{"feishu docx", core.BrandFeishu, "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
|
||||
{"feishu doc legacy", core.BrandFeishu, "doc", "doccnABC", "https://www.feishu.cn/doc/doccnABC"},
|
||||
{"feishu sheet", core.BrandFeishu, "sheet", "shtcnABC", "https://www.feishu.cn/sheets/shtcnABC"},
|
||||
{"feishu bitable", core.BrandFeishu, "bitable", "bascnABC", "https://www.feishu.cn/base/bascnABC"},
|
||||
{"feishu wiki", core.BrandFeishu, "wiki", "wikcnABC", "https://www.feishu.cn/wiki/wikcnABC"},
|
||||
{"feishu file", core.BrandFeishu, "file", "boxcnABC", "https://www.feishu.cn/file/boxcnABC"},
|
||||
{"feishu folder", core.BrandFeishu, "folder", "fldcnABC", "https://www.feishu.cn/drive/folder/fldcnABC"},
|
||||
{"feishu mindnote", core.BrandFeishu, "mindnote", "mncnABC", "https://www.feishu.cn/mindnote/mncnABC"},
|
||||
{"feishu slides", core.BrandFeishu, "slides", "slkcnABC", "https://www.feishu.cn/slides/slkcnABC"},
|
||||
{"lark docx", core.BrandLark, "docx", "doxcnABC", "https://www.larksuite.com/docx/doxcnABC"},
|
||||
{"lark wiki", core.BrandLark, "wiki", "wikcnABC", "https://www.larksuite.com/wiki/wikcnABC"},
|
||||
{"empty brand defaults to feishu", core.LarkBrand(""), "docx", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
|
||||
{"kind case-insensitive", core.BrandFeishu, "DOCX", "doxcnABC", "https://www.feishu.cn/docx/doxcnABC"},
|
||||
{"token whitespace trimmed", core.BrandFeishu, "docx", " doxcnABC ", "https://www.feishu.cn/docx/doxcnABC"},
|
||||
{"empty token", core.BrandFeishu, "docx", "", ""},
|
||||
{"whitespace-only token", core.BrandFeishu, "docx", " ", ""},
|
||||
{"unknown kind", core.BrandFeishu, "calendar", "calABC", ""},
|
||||
{"empty kind", core.BrandFeishu, "", "doxcnABC", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := BuildResourceURL(tt.brand, tt.kind, tt.token)
|
||||
if got != tt.want {
|
||||
t.Errorf("BuildResourceURL(%q, %q, %q) = %q, want %q", tt.brand, tt.kind, tt.token, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,24 @@ func augmentCreateResultV1(runtime *common.RuntimeContext, result map[string]int
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, target.Token, target.Type); grant != nil {
|
||||
result["permission_grant"] = grant
|
||||
}
|
||||
fallbackDocURLV1(runtime, result)
|
||||
}
|
||||
|
||||
// fallbackDocURLV1 fills result.doc_url with a brand-standard URL when the MCP
|
||||
// response did not include one but did include a doc_id. This protects against
|
||||
// degraded MCP responses (multi-content, non-JSON text) where ExtractMCPResult
|
||||
// drops structured fields.
|
||||
func fallbackDocURLV1(runtime *common.RuntimeContext, result map[string]interface{}) {
|
||||
if strings.TrimSpace(common.GetString(result, "doc_url")) != "" {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(result, "doc_id"))
|
||||
if docID == "" {
|
||||
return
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
|
||||
result["doc_url"] = u
|
||||
}
|
||||
}
|
||||
|
||||
func selectPermissionTarget(result map[string]interface{}) docsPermissionTarget {
|
||||
|
||||
@@ -182,6 +182,67 @@ func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2FallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
// "url" deliberately omitted to exercise the fallback.
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
t.Fatalf("missing document in envelope: %#v", data)
|
||||
}
|
||||
if got, want := doc["url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("document.url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2PreservesBackendURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateAPIStub(reg, map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"document_id": "doxcn_new_doc",
|
||||
"revision_id": float64(1),
|
||||
"url": "https://tenant.larkoffice.com/docx/doxcn_new_doc",
|
||||
},
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v2",
|
||||
"--content", "<title>内容</title><p>正文</p>",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if got, want := doc["url"], "https://tenant.larkoffice.com/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("document.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── V1 (MCP) tests ──
|
||||
|
||||
func TestDocsCreateV1BotAutoGrantSuccess(t *testing.T) {
|
||||
@@ -273,6 +334,60 @@ func TestDocsCreateV1WikiSpaceAutoGrantFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV1FallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
// "doc_url" deliberately omitted to exercise the fallback.
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v1",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if got, want := data["doc_url"], "https://www.feishu.cn/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("doc_url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV1PreservesBackendDocURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
|
||||
registerDocsCreateMCPStub(reg, map[string]interface{}{
|
||||
"doc_id": "doxcn_new_doc",
|
||||
"doc_url": "https://tenant.feishu.cn/docx/doxcn_new_doc",
|
||||
"message": "文档创建成功",
|
||||
})
|
||||
|
||||
err := runDocsCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--api-version", "v1",
|
||||
"--title", "项目计划",
|
||||
"--markdown", "## 目标",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDocsCreateEnvelope(t, stdout)
|
||||
if got, want := data["doc_url"], "https://tenant.feishu.cn/docx/doxcn_new_doc"; got != want {
|
||||
t.Fatalf("doc_url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
|
||||
@@ -51,6 +51,7 @@ func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
}
|
||||
|
||||
augmentDocsCreatePermission(runtime, data)
|
||||
fallbackDocsCreateURLV2(runtime, data)
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
}
|
||||
@@ -84,3 +85,23 @@ func augmentDocsCreatePermission(runtime *common.RuntimeContext, data map[string
|
||||
data["permission_grant"] = grant
|
||||
}
|
||||
}
|
||||
|
||||
// fallbackDocsCreateURLV2 fills data.document.url with a brand-standard URL
|
||||
// when the OpenAPI response did not include one. Backfills only when missing,
|
||||
// so any tenant-specific URL the backend returned is preserved.
|
||||
func fallbackDocsCreateURLV2(runtime *common.RuntimeContext, data map[string]interface{}) {
|
||||
doc, _ := data["document"].(map[string]interface{})
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(common.GetString(doc, "url")) != "" {
|
||||
return
|
||||
}
|
||||
docID := strings.TrimSpace(common.GetString(doc, "document_id"))
|
||||
if docID == "" {
|
||||
return
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "docx", docID); u != "" {
|
||||
doc["url"] = u
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +92,10 @@ var DriveCreateFolder = common.Shortcut{
|
||||
"folder_token": folderToken,
|
||||
"parent_folder_token": spec.FolderToken,
|
||||
}
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
if url := strings.TrimSpace(common.GetString(data, "url")); url != "" {
|
||||
out["url"] = url
|
||||
} else if u := common.BuildResourceURL(runtime.Config.Brand, "folder", folderToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
|
||||
@@ -232,6 +232,72 @@ func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderFallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_created",
|
||||
// "url" deliberately omitted to exercise the fallback.
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Weekly Reports",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, ""))
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/create_folder",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"token": "fld_created",
|
||||
"url": " ", // whitespace-only must trigger fallback, not pass through.
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveCreateFolder, []string{
|
||||
"+create-folder",
|
||||
"--name", "Weekly Reports",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/drive/folder/fld_created"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -119,8 +119,12 @@ var DriveImport = common.Shortcut{
|
||||
if status.Token != "" {
|
||||
out["token"] = status.Token
|
||||
}
|
||||
if status.URL != "" {
|
||||
out["url"] = status.URL
|
||||
if statusURL := strings.TrimSpace(status.URL); statusURL != "" {
|
||||
out["url"] = statusURL
|
||||
} else if status.Token != "" {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, normalizeDriveImportKindForURL(resultType, spec.DocType), status.Token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if status.JobErrorMsg != "" {
|
||||
out["job_error_msg"] = status.JobErrorMsg
|
||||
@@ -205,6 +209,20 @@ func appendDriveImportUploadDryRun(dry *common.DryRunAPI, spec driveImportSpec,
|
||||
})
|
||||
}
|
||||
|
||||
// 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"
|
||||
// or "sheet_v2" the URL construction would silently fall through. Fall back
|
||||
// to the user-supplied --type, which is already validated to docx/sheet/
|
||||
// bitable, so out.url stays populated whenever status.Token is set.
|
||||
func normalizeDriveImportKindForURL(serverType, fallback string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(serverType)) {
|
||||
case "docx", "sheet", "bitable":
|
||||
return strings.ToLower(strings.TrimSpace(serverType))
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// importTargetFileName returns the explicit import name when present, otherwise
|
||||
// derives one from the local file name.
|
||||
func importTargetFileName(filePath, explicitName string) string {
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -362,3 +365,206 @@ func TestDriveImportCreateTaskBodyKeepsEmptyMountKeyForRoot(t *testing.T) {
|
||||
t.Fatalf("mount_key = %q, want %q", got, "fld_test")
|
||||
}
|
||||
}
|
||||
|
||||
// driveImportMockEnv mounts the three stubs needed for a full +import run:
|
||||
// media upload_all -> import_tasks (create) -> import_tasks/<ticket> (poll).
|
||||
// Returns nothing; caller asserts on stdout via decodeDriveEnvelope.
|
||||
func driveImportMockEnv(t *testing.T, reg *httpmock.Registry, ticket string, pollData map[string]interface{}) {
|
||||
t.Helper()
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/medias/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"file_token": "file_import_media"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/import_tasks",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"ticket": ticket},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/import_tasks/" + ticket,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{"result": pollData},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// driveImportTestConfig builds a CliConfig for the import fallback tests.
|
||||
// The brand defaults to BrandFeishu when omitted; pass core.BrandLark to
|
||||
// exercise the larksuite.com branch of BuildResourceURL.
|
||||
func driveImportTestConfig(suffix string, brands ...core.LarkBrand) *core.CliConfig {
|
||||
brand := core.BrandFeishu
|
||||
if len(brands) > 0 {
|
||||
brand = brands[0]
|
||||
}
|
||||
return &core.CliConfig{
|
||||
AppID: "drive-import-fallback-" + suffix,
|
||||
AppSecret: "test-secret",
|
||||
Brand: brand,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("missing-url"))
|
||||
driveImportMockEnv(t, reg, "ticket_fallback", map[string]interface{}{
|
||||
"token": "doxcn_imported",
|
||||
"type": "docx",
|
||||
"job_status": float64(0),
|
||||
// "url" deliberately omitted: import API frequently returns the doc
|
||||
// without an absolute URL, leaving the CLI to backfill from token.
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("import should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportPreservesBackendURL(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("preserve-url"))
|
||||
driveImportMockEnv(t, reg, "ticket_preserve", map[string]interface{}{
|
||||
"token": "doxcn_imported",
|
||||
"type": "docx",
|
||||
"job_status": float64(0),
|
||||
"url": "https://tenant.larkoffice.com/docx/doxcn_imported",
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("import should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://tenant.larkoffice.com/docx/doxcn_imported"; got != want {
|
||||
t.Fatalf("data.url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFallbackURLWhenServerURLIsWhitespace(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("whitespace-url"))
|
||||
driveImportMockEnv(t, reg, "ticket_whitespace", map[string]interface{}{
|
||||
"token": "doxcn_imported",
|
||||
"type": "docx",
|
||||
"job_status": float64(0),
|
||||
"url": " ", // whitespace-only must trigger fallback, not pass through.
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("import should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/docx/doxcn_imported"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFallbackURLForLarkBrand(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("lark-brand", core.BrandLark))
|
||||
driveImportMockEnv(t, reg, "ticket_lark", map[string]interface{}{
|
||||
"token": "doxcn_imported",
|
||||
"type": "docx",
|
||||
"job_status": float64(0),
|
||||
// "url" omitted to force the fallback through the lark host branch.
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
if err := os.WriteFile("notes.md", []byte("# Hi"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import", "--file", "notes.md", "--type", "docx", "--as", "user",
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("import should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.larksuite.com/docx/doxcn_imported"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (lark brand fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveImportFallbackURLWhenServerTypeIsAlias(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveImportTestConfig("alias-type"))
|
||||
driveImportMockEnv(t, reg, "ticket_alias", map[string]interface{}{
|
||||
"token": "shtcn_imported",
|
||||
"type": "sheets", // non-canonical alias the server may return
|
||||
"job_status": float64(0),
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if err := mountAndRunDrive(t, DriveImport, []string{
|
||||
"+import", "--file", "data.csv", "--type", "sheet", "--as", "user",
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("import should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
// Server returned "sheets" (alias) — normalize falls back to the user
|
||||
// --type "sheet", so BuildResourceURL picks the canonical /sheets/ path.
|
||||
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_imported"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (alias normalized via spec.DocType fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,84 @@ func TestDriveUploadSmallFileToWiki(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadFallbackURLForExplorerParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-explorer-fallback-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/drive/v1/files/upload_all",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
// upload_all only ever returns file_token; url is never present —
|
||||
// this exercises the fallback path unconditionally for explorer
|
||||
// parents.
|
||||
"data": map[string]interface{}{"file_token": "file_explorer_small"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
origDir, _ := os.Getwd()
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("Chdir() error: %v", err)
|
||||
}
|
||||
defer os.Chdir(origDir)
|
||||
|
||||
if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload", "--file", "hello.bin", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("upload should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/file/file_explorer_small"; got != want {
|
||||
t.Fatalf("data.url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadOmitsURLForWikiParent(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-wiki-no-url", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
}
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, uploadTestConfig)
|
||||
|
||||
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_wiki_small"},
|
||||
},
|
||||
})
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.WriteFile("hello.bin", make([]byte, 64), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
err := mountAndRunDrive(t, DriveUpload, []string{
|
||||
"+upload", "--file", "hello.bin",
|
||||
"--wiki-token", "wikcn_parent",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("upload should succeed, got: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if _, ok := data["url"]; ok {
|
||||
t.Fatalf("data.url should be omitted for wiki-hosted files (no standalone URL); got %#v", data["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveUploadSmallFileAPIError(t *testing.T) {
|
||||
uploadTestConfig := &core.CliConfig{
|
||||
AppID: "drive-upload-small-err", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -146,6 +146,14 @@ var DriveUpload = common.Shortcut{
|
||||
"file_name": fileName,
|
||||
"size": fileSize,
|
||||
}
|
||||
// wiki-hosted files have no standalone /file/<token> URL — only the
|
||||
// wiki node URL, which the upload response doesn't carry. Skip the
|
||||
// fallback for parent_type=wiki rather than emit a link that 404s.
|
||||
if target.ParentType == driveUploadParentTypeExplorer {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "file", fileToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, fileToken, "file"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -108,7 +109,12 @@ var SheetCreate = common.Shortcut{
|
||||
out := map[string]interface{}{
|
||||
"spreadsheet_token": token,
|
||||
"title": title,
|
||||
"url": spreadsheet["url"],
|
||||
}
|
||||
url, _ := spreadsheet["url"].(string)
|
||||
if url = strings.TrimSpace(url); url != "" {
|
||||
out["url"] = url
|
||||
} else if u := common.BuildResourceURL(runtime.Config.Brand, "sheet", token); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, token, "sheet"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
|
||||
@@ -110,6 +110,142 @@ func TestSheetCreateUserSkipsPermissionGrantAugmentation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateFallbackURLWhenBackendOmitsIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_new_sheet",
|
||||
// "url" deliberately omitted to exercise the fallback.
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目排期",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (brand-standard fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreatePreservesBackendURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_new_sheet",
|
||||
"url": "https://tenant.larkoffice.com/sheets/shtcn_new_sheet",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目排期",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
|
||||
t.Fatalf("url = %#v, want backend tenant URL %q (fallback must not overwrite)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateFallbackURLWhenBackendURLIsWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_new_sheet",
|
||||
"url": " ", // whitespace-only must trigger fallback, not pass through.
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目排期",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://www.feishu.cn/sheets/shtcn_new_sheet"; got != want {
|
||||
t.Fatalf("url = %#v, want %q (whitespace-only backend URL must yield fallback)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetCreateTrimsPaddedBackendURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, sheetCreateTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/sheets/v3/spreadsheets",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"spreadsheet": map[string]interface{}{
|
||||
"spreadsheet_token": "shtcn_new_sheet",
|
||||
"url": " https://tenant.larkoffice.com/sheets/shtcn_new_sheet ",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSheetCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--title", "项目排期",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSheetCreateEnvelope(t, stdout)
|
||||
if got, want := data["url"], "https://tenant.larkoffice.com/sheets/shtcn_new_sheet"; got != want {
|
||||
t.Fatalf("url = %#v, want trimmed backend URL %q (whitespace must not leak into output)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func sheetCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -479,5 +479,8 @@ func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wiki
|
||||
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil {
|
||||
out["permission_grant"] = grant
|
||||
}
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, "wiki", execution.Node.NodeToken); u != "" {
|
||||
out["url"] = u
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -502,6 +502,9 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) {
|
||||
if envelope.Data["node_token"] != "wik_created" {
|
||||
t.Fatalf("node_token = %#v, want %q", envelope.Data["node_token"], "wik_created")
|
||||
}
|
||||
if got, want := envelope.Data["url"], "https://www.feishu.cn/wiki/wik_created"; got != want {
|
||||
t.Fatalf("url = %#v, want %q", got, want)
|
||||
}
|
||||
|
||||
var captured map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &captured); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user