// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package doc import ( "bytes" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/shortcuts/common" ) func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) { for name, flags := range map[string][]common.Flag{ "create": v2CreateFlags(), "update": v2UpdateFlags(), } { t.Run(name, func(t *testing.T) { flag := findDocsTestFlag(flags, "reference-map") if flag.Name == "" { t.Fatal("reference-map flag not found") } if flag.Hidden { t.Fatal("reference-map flag should be public") } if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) { t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input) } if !strings.Contains(flag.Desc, "@reference-map.json") { t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc) } }) } } func TestDocsV2InputFlagIsNotAvailable(t *testing.T) { for name, flags := range map[string][]common.Flag{ "create": v2CreateFlags(), "update": v2UpdateFlags(), } { t.Run(name, func(t *testing.T) { for _, flag := range flags { if flag.Name == "input" { t.Fatalf("%s should not expose input flag", name) } } }) } } func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) { t.Parallel() runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ "command": "append", "content": `

`, "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`, }) body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) if err != nil { t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err) } refMap, ok := body["reference_map"].(map[string]interface{}) if !ok { t.Fatalf("reference_map = %#v, want object", body["reference_map"]) } widget, _ := refMap["widget"].(map[string]interface{}) r1, _ := widget["r1"].(map[string]interface{}) if got := r1["label"]; got != "widget-ref-value" { t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body) } } func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) if err := os.WriteFile("widget.html", []byte("hello"), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_new_doc", "revision_id": float64(1), }, }) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", `demo`, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) if got := body["content"].(string); !strings.Contains(got, ``) { t.Fatalf("content was not rewritten with data-ref: %s", got) } refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "hello" { t.Fatalf("reference_map html data = %q", got) } if _, ok := body["resources"]; ok { t.Fatalf("request body must not use resources: %#v", body) } } func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) files := map[string]string{ "diagram.svg": `A`, "flow.mmd": "flowchart TD\nA --> B", "sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml", } for name, content := range files { if err := os.WriteFile(name, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile(%s) error: %v", name, err) } } f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_new_doc", "revision_id": float64(1), }, }) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", strings.Join([]string{ ``, `@flow.mmd`, ``, }, "\n"), "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) got := body["content"].(string) for _, want := range []string{ `A`, "flowchart TD\nA --> B", "@startuml\nAlice -> Bob: hi\n@enduml", } { if !strings.Contains(got, want) { t.Fatalf("content missing %q:\n%s", want, got) } } if strings.Contains(got, `path="@`) { t.Fatalf("content still contains whiteboard path attr: %s", got) } if _, ok := body["reference_map"]; ok { t.Fatalf("whiteboard file input must not create reference_map: %#v", body) } } func findDocsTestFlag(flags []common.Flag, name string) common.Flag { for _, flag := range flags { if flag.Name == name { return flag } } return common.Flag{} } func hasDocsTestInput(flag common.Flag, input string) bool { for _, item := range flag.Input { if item == input { return true } } return false } func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) if err := os.WriteFile("widget.html", []byte("
updated
"), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update")) stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{ "document": map[string]interface{}{ "revision_id": float64(2), "new_blocks": []interface{}{ map[string]interface{}{ "block_type": "html5-block", "block_id": "blk_html5", "block_token": "boardXXXX", }, }, }, "result": "success", }) err := mountAndRunDocs(t, DocsUpdate, []string{ "+update", "--api-version", "v2", "--doc", "doxcn_doc", "--command", "append", "--content", ``, "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) if got := body["content"].(string); got != `` { t.Fatalf("content = %q", got) } refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "
updated
" { t.Fatalf("reference_map html data = %q", got) } var envelope map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } data, _ := envelope["data"].(map[string]interface{}) doc, _ := data["document"].(map[string]interface{}) if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 { t.Fatalf("new_blocks not preserved in stdout: %#v", doc) } } func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch")) registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_fetch", "revision_id": float64(3), "content": ``, "reference_map": map[string]interface{}{ "html5-block": map[string]interface{}{ "html5_1": map[string]interface{}{"data": "
fetched
"}, }, }, }, "tips": "must_read_html_code", }) err := mountAndRunDocs(t, DocsFetch, []string{ "+fetch", "--api-version", "v2", "--doc", "doxcn_fetch", "--format", "json", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html") if _, err := os.Stat(written); err == nil { t.Fatalf("small html should stay inline, got file %s", written) } var envelope map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } data, _ := envelope["data"].(map[string]interface{}) doc, _ := data["document"].(map[string]interface{}) if got := doc["content"].(string); !strings.Contains(got, ``) { t.Fatalf("content should keep data-ref: %s", got) } refMap := decodeHTML5ReferenceMap(t, doc["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "
fetched
" { t.Fatalf("reference_map html data = %q", got) } if _, ok := doc["resources"]; ok { t.Fatalf("fetch output must not use resources: %#v", doc) } if _, ok := data["suggestions"]; ok { t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"]) } if got := data["tips"]; got != "must_read_html_code" { t.Fatalf("tips should be preserved from service response, got %#v", got) } } func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) largeHTML := "
" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "
" f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large")) registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_fetch", "revision_id": float64(3), "content": ``, "reference_map": map[string]interface{}{ "html5-block": map[string]interface{}{ "html5_1": map[string]interface{}{"data": largeHTML}, }, }, }, }) err := mountAndRunDocs(t, DocsFetch, []string{ "+fetch", "--api-version", "v2", "--doc", "doxcn_fetch", "--format", "json", "--as", "user", }, f, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) } written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html") raw, err := os.ReadFile(written) if err != nil { t.Fatalf("ReadFile(%s) error: %v", written, err) } if string(raw) != largeHTML { t.Fatalf("materialized html = %q", raw) } var envelope map[string]interface{} if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) } data, _ := envelope["data"].(map[string]interface{}) doc, _ := data["document"].(map[string]interface{}) if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) { t.Fatalf("content should keep data-ref and not path: %s", got) } refMap := decodeHTML5ReferenceMap(t, doc["reference_map"]) entry := refMap[html5BlockTag]["html5_1"] if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" { t.Fatalf("large html should be represented as path, got %#v", entry) } } func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_new_doc", "revision_id": float64(1), }, }) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", ``, "--reference-map", `{"html5-block":{"html5_1":{"data":""}}}`, "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) if got := body["content"].(string); got != `` { t.Fatalf("content = %q", got) } refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "" { t.Fatalf("reference_map html data = %q", got) } } func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"from file"}}}`), 0o600); err != nil { t.Fatalf("WriteFile(reference-map.json) error: %v", err) } f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_new_doc", "revision_id": float64(1), }, }) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", ``, "--reference-map", "@reference-map.json", "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "from file" { t.Fatalf("reference_map html data = %q", got) } } func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", ``, "--as", "user", }) if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) { t.Fatalf("expected missing reference_map error, got: %v", err) } } func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", ``, "--as", "user", }) if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) { t.Fatalf("expected internal data attr error, got: %v", err) } } func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", ``, "--as", "user", }) if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) { t.Fatalf("expected path read error, got: %v", err) } } func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", strings.Join([]string{ ``, `@missing.mmd`, ``, }, "\n"), "--as", "user", }) if err == nil { t.Fatal("expected aggregated whiteboard path error") } assertWhiteboardFileInputValidation(t, err, []string{ "missing.svg", "missing.mmd", "missing.puml", }, []string{ `whiteboard svg path "missing.svg" cannot be read`, `whiteboard mermaid path "missing.mmd" cannot be read`, `whiteboard plantuml path "missing.puml" cannot be read`, }) } func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--doc-format", "markdown", "--content", strings.Join([]string{ ``, "```", ``, "```", ``, }, "\n"), "--as", "user", }) if err == nil { t.Fatal("expected aggregated whiteboard path error") } assertWhiteboardFileInputValidation(t, err, []string{ "before.svg", "after.puml", }, []string{ `whiteboard svg path "before.svg" cannot be read`, `whiteboard plantuml path "after.puml" cannot be read`, }) if strings.Contains(err.Error(), "inside.svg") { t.Fatalf("error should ignore fenced whiteboard path, got: %v", err) } } func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) { t.Helper() problem, ok := errs.ProblemOf(err) if !ok { t.Fatalf("expected typed problem, got %T %v", err, err) } if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument) } var validationErr *errs.ValidationError if !errors.As(err, &validationErr) { t.Fatalf("expected *errs.ValidationError, got %T %v", err, err) } if validationErr.Param != "whiteboard" { t.Fatalf("param = %q, want whiteboard", validationErr.Param) } if validationErr.Cause == nil { t.Fatal("expected aggregated error to preserve cause") } var childValidationErr *errs.ValidationError if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil { t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause) } gotParams := make(map[string]string, len(validationErr.Params)) for _, param := range validationErr.Params { gotParams[param.Name] = param.Reason } if len(gotParams) != len(wantParams) { t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams) } for _, param := range wantParams { reason, ok := gotParams[param] if !ok { t.Fatalf("params = %#v, want name %q", validationErr.Params, param) } if reason == "" { t.Fatalf("param %q missing reason: %#v", param, validationErr.Params) } } for _, want := range wantMessages { if !strings.Contains(err.Error(), want) { t.Fatalf("error missing %q:\n%v", want, err) } if !strings.Contains(validationErr.Cause.Error(), want) { t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause) } } } func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) if err := os.WriteFile("widget.html", []byte("
from file
"), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--content", `
inline
`, "--as", "user", }) if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) { t.Fatalf("expected inline content error, got: %v", err) } } func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing")) registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_fetch", "revision_id": float64(3), "content": ``, "reference_map": map[string]interface{}{ "html5-block": map[string]interface{}{ "html5_1": map[string]interface{}{"data": ""}, }, }, }, }) err := mountAndRunDocs(t, DocsFetch, []string{ "+fetch", "--api-version", "v2", "--doc", "doxcn_fetch", "--format", "json", "--as", "user", }, f, stdout) if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") { t.Fatalf("expected missing reference_map error, got: %v", err) } } func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) { for _, fence := range []string{"```", "~~~"} { t.Run(fence, func(t *testing.T) { content := fence + "xml\n\n" + fence + "\n" if hasProcessableHTML5Block("markdown", content) { t.Fatalf("html5-block inside markdown code fence should be ignored") } }) } } func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) { runtime := newFetchShortcutTestRuntime(t, "", nil) tests := []struct { name string docToken string ref string want string }{ {name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"}, {name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"}, {name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"}, {name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "") if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want) } }) } } func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) { dir := t.TempDir() cmdutil.TestChdir(t, dir) if err := os.WriteFile("widget.html", []byte("markdown"), 0o600); err != nil { t.Fatalf("WriteFile() error: %v", err) } f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ "document": map[string]interface{}{ "document_id": "doxcn_new_doc", "revision_id": float64(1), }, }) err := runDocsCreateShortcut(t, f, stdout, []string{ "+create", "--api-version", "v2", "--doc-format", "markdown", "--content", "before\n\nafter", "--as", "user", }) if err != nil { t.Fatalf("unexpected error: %v", err) } body := decodeRequestBody(t, stub.CapturedBody) if got := body["content"].(string); !strings.Contains(got, ``) { t.Fatalf("content was not rewritten: %s", got) } refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) if got := refMap[html5BlockTag]["html5_1"].Data; got != "markdown" { t.Fatalf("reference_map html data = %q", got) } } func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub { stub := &httpmock.Stub{ Method: method, URL: url, Body: map[string]interface{}{ "code": 0, "msg": "ok", "data": data, }, } reg.Register(stub) return stub } func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} { t.Helper() var body map[string]interface{} if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil { t.Fatalf("decode request body: %v\n%s", err, raw) } return body } func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap { t.Helper() data, err := json.Marshal(raw) if err != nil { t.Fatalf("marshal reference_map: %v\n%#v", err, raw) } var refMap html5BlockReferenceMap if err := json.Unmarshal(data, &refMap); err != nil { t.Fatalf("decode reference_map: %v\n%s", err, data) } return refMap }