Files
larksuite__cli/shortcuts/doc/docs_create_test.go
SunPeiYang996 a6f3e635d4 feat(docs): add local authoring and resource workflows (#1921)
* feat(docs): add local authoring and resource workflows

Add docs +script workflows for isolated draft initialization and tolerant XML/Markdown profiling.

Support local and remote document resources across create and update flows with safe, bounded-concurrency uploads, binding verification, and cleanup.

Synchronize shared credential-source selection during concurrent uploads, refresh lark-doc guidance, and expand unit, dry-run, and live E2E coverage.

* docs(lark-doc): clarify genre reference paths

* fix(docs): address PR validation feedback

* docs(lark-doc): clarify remote image handling

* feat: streamline docs draft workflow

* fix(docs): clarify script input and resource cleanup

* fix(docs): align script dry-run test with auth flow

* fix(docs): authenticate local script e2e test

* fix(docs): refine script diagnostics and image preflight

* fix(docs): align draft workspace cleanup with VFS

* fix(docs): route workspace cleanup through FileIO

* docs(lark-doc): simplify profile check guidance
2026-08-07 18:15:28 +08:00

446 lines
14 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── V2 (OpenAPI) tests ──
func TestDocsCreateV2RemoteImageDryRunDownloadsAfterDocumentCreation(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", `<title>Remote image</title><img href="https://93.184.216.34/photo.png"/>`,
"--dry-run",
"--as", "bot",
})
if err != nil {
t.Fatalf("execute docs +create dry-run: %v", err)
}
var envelope struct {
Data struct {
API []common.DryRunAPICall `json:"api"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode dry-run output: %v\n%s", err, stdout)
}
if len(envelope.Data.API) < 3 {
t.Fatalf("dry-run API calls = %d, want create, download, and upload: %#v", len(envelope.Data.API), envelope.Data.API)
}
if got := envelope.Data.API[0].URL; got != "/open-apis/docs_ai/v1/documents" {
t.Fatalf("first API URL = %q, want document creation", got)
}
if got := envelope.Data.API[1].URL; got != "https://93.184.216.34/photo.png" {
t.Fatalf("second API URL = %q, want remote image download", got)
}
if got := envelope.Data.API[2].URL; got != "/open-apis/drive/v1/medias/upload_all" {
t.Fatalf("third API URL = %q, want image upload", got)
}
}
func TestDocsCreateV2RejectsBlockedRemoteImageBeforeDocumentCreation(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Optional: true,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
}
reg.Register(createStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", `<title>Blocked image</title><img href="http://127.0.0.1/image.png"/>`,
"--as", "bot",
})
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "href")
if len(createStub.CapturedBodies) != 0 {
t.Fatalf("document creation was called before remote image validation: %s", createStub.CapturedBody)
}
}
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", "<title>项目计划</title><h1>目标</h1>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted)
}
if grant["user_open_id"] != "ou_current_user" {
t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user")
}
if grant["message"] != "Granted the current CLI user full_access on the new document." {
t.Fatalf("permission_grant.message = %#v", grant["message"])
}
var body map[string]interface{}
if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil {
t.Fatalf("failed to parse permission request body: %v", err)
}
if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" {
t.Fatalf("unexpected permission request body: %#v", body)
}
}
func TestDocsCreateV2BotAutoGrantSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, stderr, 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://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantSkipped)
}
if _, ok := grant["user_open_id"]; ok {
t.Fatalf("did not expect user_open_id when current user is missing: %#v", grant)
}
if !strings.Contains(stderr.String(), "auto-grant was skipped") {
t.Fatalf("stderr missing auto-grant skipped warning; got:\n%s", stderr.String())
}
}
func TestDocsCreateV2UserSkipsPermissionGrantAugmentation(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", "<title>内容</title><p>正文</p>",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode output: %#v", data)
}
}
func TestDocsCreateV2BotAutoGrantFailureDoesNotFailCreate(t *testing.T) {
t.Parallel()
f, stdout, stderr, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "ou_current_user"))
registerDocsCreateAPIStub(reg, map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
"url": "https://example.feishu.cn/docx/doxcn_new_doc",
},
})
permStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/doxcn_new_doc/members",
Body: map[string]interface{}{
"code": 230001,
"msg": "no permission",
},
}
reg.Register(permStub)
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--content", "<title>内容</title><p>正文</p>",
"--as", "bot",
})
if err != nil {
t.Fatalf("document creation should still succeed when auto-grant fails, got: %v", err)
}
data := decodeDocsCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantFailed {
t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantFailed)
}
wantMessage := "Resource was created, but granting current user full_access failed: no permission. You can retry later or continue using bot identity."
if grant["message"] != wantMessage {
t.Fatalf("permission_grant.message = %q, want %q", grant["message"], wantMessage)
}
if !strings.Contains(stderr.String(), "auto-grant failed") {
t.Fatalf("stderr missing auto-grant failed warning; got:\n%s", stderr.String())
}
}
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",
"--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",
"--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)
}
}
func TestDocsCreateAPIVersionCompatFlagIsIgnored(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://example.feishu.cn/docx/doxcn_new_doc",
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "legacy",
"--content", "<title>项目计划</title>",
"--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["document_id"], "doxcn_new_doc"; got != want {
t.Fatalf("document.document_id = %#v, want %q", got, want)
}
}
func TestDocsCreateRejectsLegacyV1Flags(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--markdown", "## 目标",
"--as", "user",
})
if err == nil {
t.Fatal("expected legacy v1 flags to be rejected")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed problem", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if got, want := validationErr.Param, "--markdown"; got != want {
t.Fatalf("param = %q, want %q", got, want)
}
presented := problem.Message + "\n" + problem.Hint
for _, want := range []string{
"docs +create is v2-only",
"the old v1 interface has been shut down",
"legacy v1 flag(s) --markdown are no longer supported",
"--markdown -> use --content with --doc-format markdown",
"lark-cli docs +create --help",
} {
if !strings.Contains(presented, want) {
t.Fatalf("error missing %q: %v", want, err)
}
}
}
func TestDocsCreateV2EmptyContentFileReportsPathAndRecovery(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile("draft.xml", nil, 0o600); err != nil {
t.Fatalf("write empty draft: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--doc-format", "xml",
"--content", "@draft.xml",
"--as", "user",
})
assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--content")
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error does not expose a typed problem: %v", err)
}
if got, want := problem.Message, `--content file "draft.xml" is empty`; got != want {
t.Fatalf("message = %q, want %q", got, want)
}
if got, want := problem.Hint, `write non-empty XML or Markdown to this file; if the path was reserved by init-draft, use the exact data.draft_path returned by that command, then retry with --content "@./<data.draft_path>"`; got != want {
t.Fatalf("hint = %q, want %q", got, want)
}
}
// ── Helpers ──
func docsCreateTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-docs-create-" + suffix,
AppSecret: "secret-docs-create-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
func registerDocsCreateAPIStub(reg *httpmock.Registry, data map[string]interface{}) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": data,
},
})
}
func runDocsCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
return mountAndRunDocs(t, DocsCreate, args, f, stdout)
}
func decodeDocsCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}