bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
1229 lines
38 KiB
Go
1229 lines
38 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
func TestBuildCreateBlockDataUsesConcreteAppendIndex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("image", 3, 0)
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 27,
|
|
"image": map[string]interface{}{},
|
|
},
|
|
},
|
|
"index": 3,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildCreateBlockDataForFileIncludesFilePayload(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("file", 1, 0)
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 23,
|
|
"file": map[string]interface{}{},
|
|
},
|
|
},
|
|
"index": 1,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData(file) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// The `--file-view card` path sends a different request shape than
|
|
// omitting the flag entirely: omitting produces `file: {}`, while
|
|
// `card` produces `file: {view_type: 1}`. The two are intended to be
|
|
// semantically equivalent at the API level, but the on-the-wire payload
|
|
// is different and is part of the public flag contract, so pin it down.
|
|
func TestBuildCreateBlockDataForFileWithCardView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("file", 0, 1) // card
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 23,
|
|
"file": map[string]interface{}{
|
|
"view_type": 1,
|
|
},
|
|
},
|
|
},
|
|
"index": 0,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData(file, card) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildCreateBlockDataForFileWithPreviewView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("file", 0, 2) // preview
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 23,
|
|
"file": map[string]interface{}{
|
|
"view_type": 2,
|
|
},
|
|
},
|
|
},
|
|
"index": 0,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData(file, preview) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildCreateBlockDataForFileWithInlineView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("file", 0, 3) // inline
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 23,
|
|
"file": map[string]interface{}{
|
|
"view_type": 3,
|
|
},
|
|
},
|
|
},
|
|
"index": 0,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData(file, inline) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// view_type must never leak into non-file blocks even if the caller
|
|
// accidentally passes a non-zero fileViewType alongside --type=image.
|
|
func TestBuildCreateBlockDataForImageIgnoresFileViewType(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildCreateBlockData("image", 0, 2)
|
|
want := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_type": 27,
|
|
"image": map[string]interface{}{},
|
|
},
|
|
},
|
|
"index": 0,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildCreateBlockData(image, preview) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestFileViewMapCoversDocumentedValues(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Assert only the documented keys — leave room for future aliases
|
|
// (e.g. a "player" synonym for preview) without breaking this test.
|
|
want := map[string]int{
|
|
"card": 1,
|
|
"preview": 2,
|
|
"inline": 3,
|
|
}
|
|
for key, expected := range want {
|
|
got, ok := fileViewMap[key]
|
|
if !ok {
|
|
t.Errorf("fileViewMap missing required key %q", key)
|
|
continue
|
|
}
|
|
if got != expected {
|
|
t.Errorf("fileViewMap[%q] = %d, want %d", key, got, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDeleteBlockDataUsesHalfOpenInterval(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildDeleteBlockData(5)
|
|
want := map[string]interface{}{
|
|
"start_index": 5,
|
|
"end_index": 6,
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildDeleteBlockData() = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBatchUpdateDataForImage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 0, 0)
|
|
want := map[string]interface{}{
|
|
"requests": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "blk_1",
|
|
"replace_image": map[string]interface{}{
|
|
"token": "file_tok",
|
|
"align": 2,
|
|
"caption": map[string]interface{}{
|
|
"content": "caption text",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildBatchUpdateData(image) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBatchUpdateDataForFile(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 0, 0)
|
|
want := map[string]interface{}{
|
|
"requests": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "blk_2",
|
|
"replace_file": map[string]interface{}{
|
|
"token": "file_tok",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildBatchUpdateData(file) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBatchUpdateDataForImageWithWidthHeight(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildBatchUpdateData("blk_1", "image", "file_tok", "center", "caption text", 800, 447)
|
|
want := map[string]interface{}{
|
|
"requests": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "blk_1",
|
|
"replace_image": map[string]interface{}{
|
|
"token": "file_tok",
|
|
"width": 800,
|
|
"height": 447,
|
|
"align": 2,
|
|
"caption": map[string]interface{}{"content": "caption text"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildBatchUpdateData(image, 800, 447) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildBatchUpdateDataForFileIgnoresWidthHeight(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got := buildBatchUpdateData("blk_2", "file", "file_tok", "", "", 800, 600)
|
|
want := map[string]interface{}{
|
|
"requests": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "blk_2",
|
|
"replace_file": map[string]interface{}{
|
|
"token": "file_tok",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("buildBatchUpdateData(file, 800, 600) = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestExtractAppendTargetUsesRootChildrenCount(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rootData := map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": "root_block",
|
|
"children": []interface{}{"c1", "c2", "c3"},
|
|
},
|
|
}
|
|
|
|
blockID, index, children, err := extractAppendTarget(rootData, "fallback")
|
|
if err != nil {
|
|
t.Fatalf("extractAppendTarget() unexpected error: %v", err)
|
|
}
|
|
if blockID != "root_block" {
|
|
t.Fatalf("extractAppendTarget() blockID = %q, want %q", blockID, "root_block")
|
|
}
|
|
if index != 3 {
|
|
t.Fatalf("extractAppendTarget() index = %d, want 3", index)
|
|
}
|
|
if len(children) != 3 {
|
|
t.Fatalf("extractAppendTarget() children len = %d, want 3", len(children))
|
|
}
|
|
}
|
|
|
|
// buildLocateDocMCPResponse builds a JSON-RPC 2.0 response for a locate-doc MCP call.
|
|
func buildLocateDocMCPResponse(matches []map[string]interface{}) map[string]interface{} {
|
|
resultJSON, _ := json.Marshal(map[string]interface{}{"matches": matches})
|
|
return map[string]interface{}{
|
|
"jsonrpc": "2.0",
|
|
"id": "test-id",
|
|
"result": map[string]interface{}{
|
|
"content": []interface{}{
|
|
map[string]interface{}{
|
|
"type": "text",
|
|
"text": string(resultJSON),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// registerInsertWithSelectionStubs wires the minimal stub set for the
|
|
// --selection-with-ellipsis happy path. Returns the create-block stub so
|
|
// callers can inspect the request body (e.g. to verify the computed index).
|
|
func registerInsertWithSelectionStubs(reg interface {
|
|
Register(*httpmock.Stub)
|
|
}, docID, anchorBlockID, parentBlockID string, rootChildren []interface{}) *httpmock.Stub {
|
|
// Root block
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": rootChildren,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// MCP locate-doc
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{
|
|
{"anchor_block_id": anchorBlockID, "parent_block_id": parentBlockID},
|
|
}),
|
|
})
|
|
// Create block — returned so the test can inspect index in CapturedBody.
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
// Upload
|
|
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": "ftok_test"},
|
|
},
|
|
})
|
|
// Batch update
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
})
|
|
return createStub
|
|
}
|
|
|
|
// assertCreateBlockIndex decodes the create-block request body and asserts the
|
|
// `index` field equals want. Fails the test if the body is missing or wrong.
|
|
func assertCreateBlockIndex(t *testing.T, stub *httpmock.Stub, want int) {
|
|
t.Helper()
|
|
if stub.CapturedBody == nil {
|
|
t.Fatalf("create-block stub captured no body")
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
|
t.Fatalf("decode create-block body: %v (raw: %s)", err, stub.CapturedBody)
|
|
}
|
|
got, _ := body["index"].(float64)
|
|
if int(got) != want {
|
|
t.Fatalf("create-block index = %v, want %d (body: %s)", body["index"], want, stub.CapturedBody)
|
|
}
|
|
}
|
|
|
|
// TestLocateInsertIndexAfterModeViaExecute verifies that
|
|
// --selection-with-ellipsis (default after-mode) places the new block
|
|
// immediately after the matched root-level block. Uses three root children so
|
|
// the after-index (2) differs from what --before would produce (1), and
|
|
// inspects the create-block request body to prove the computed index actually
|
|
// reaches the /children API.
|
|
func TestLocateInsertIndexAfterModeViaExecute(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-after-app"))
|
|
createStub := registerInsertWithSelectionStubs(reg, "doxcnSEL", "blk_b", "doxcnSEL",
|
|
[]interface{}{"blk_a", "blk_b", "blk_c"})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", "doxcnSEL",
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "Introduction",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err != nil {
|
|
t.Fatalf("Execute() error: %v", err)
|
|
}
|
|
// after blk_b (index 1) → insert at index 2, between blk_b and blk_c.
|
|
assertCreateBlockIndex(t, createStub, 2)
|
|
}
|
|
|
|
// TestLocateInsertIndexBeforeModeViaExecute verifies that --before inserts
|
|
// before the matched root-level block. Pairs with the after-mode test above:
|
|
// same fixture, same anchor, but --before should flip the index from 2 to 1.
|
|
// A regression that ignored --before would still pass the success check alone,
|
|
// so we assert the create-block body explicitly.
|
|
func TestLocateInsertIndexBeforeModeViaExecute(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-before-app"))
|
|
createStub := registerInsertWithSelectionStubs(reg, "doxcnSEL2", "blk_b", "doxcnSEL2",
|
|
[]interface{}{"blk_a", "blk_b", "blk_c"})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", "doxcnSEL2",
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "Architecture",
|
|
"--before",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err != nil {
|
|
t.Fatalf("Execute() error: %v", err)
|
|
}
|
|
// before blk_b (index 1) → insert at index 1, between blk_a and blk_b.
|
|
assertCreateBlockIndex(t, createStub, 1)
|
|
}
|
|
|
|
// TestLocateInsertIndexNestedBlockViaExecute verifies that a deeply-nested
|
|
// anchor (2+ levels below root) walks up through an intermediate block via
|
|
// the GET /blocks/{id} API to find the root-level ancestor. This exercises
|
|
// the fallback ancestor-walk path in locateInsertIndex — the parent_block_id
|
|
// hint from locate-doc is only good for one level, so deeper nesting must hit
|
|
// the block-fetch loop.
|
|
func TestLocateInsertIndexNestedBlockViaExecute(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nested-app"))
|
|
|
|
docID := "doxcnNESTED"
|
|
// Root children: blk_section (index 0), blk_other (index 1).
|
|
// Anchor blk_grandchild is nested two levels deep:
|
|
// root → blk_section → blk_section_child → blk_grandchild
|
|
// locate-doc gives us parent_block_id = blk_section_child (one level up);
|
|
// the walk must fetch blk_section_child to discover its parent = blk_section
|
|
// before it can land on a root child.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": []interface{}{"blk_section", "blk_other"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{
|
|
{"anchor_block_id": "blk_grandchild", "parent_block_id": "blk_section_child"},
|
|
}),
|
|
})
|
|
// Intermediate block lookup — this is the key step that exercises the
|
|
// fallback walk. Without this stub the test would fail.
|
|
intermediateStub := &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_section_child",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": "blk_section_child",
|
|
"parent_id": "blk_section",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(intermediateStub)
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
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": "ftok_nested"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", docID,
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "nested content",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err != nil {
|
|
t.Fatalf("Execute() error: %v", err)
|
|
}
|
|
// Confirm the ancestor-walk actually fired — without this assertion a
|
|
// regression that short-circuited the walk would still pass.
|
|
if intermediateStub.CapturedBody == nil && intermediateStub.CapturedHeaders == nil {
|
|
t.Errorf("expected GET /blocks/blk_section_child to be invoked by the parent-walk; stub was not hit")
|
|
}
|
|
// after blk_section (index 0) → insert at index 1, between blk_section and blk_other.
|
|
assertCreateBlockIndex(t, createStub, 1)
|
|
}
|
|
|
|
// TestLocateInsertIndexNoMatchReturnsError verifies that when locate-doc returns
|
|
// no matches, Execute returns a descriptive error.
|
|
func TestLocateInsertIndexNoMatchReturnsError(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nomatch-app"))
|
|
|
|
docID := "doxcnNOMATCH"
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": []interface{}{"blk_a"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{}),
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", docID,
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "nonexistent text",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected no-match error, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "no_match") && !strings.Contains(err.Error(), "did not find") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLocateInsertIndexDryRunIncludesMCPStep verifies that the dry-run output
|
|
// includes a locate-doc MCP step when --selection-with-ellipsis is provided.
|
|
func TestLocateInsertIndexDryRunIncludesMCPStep(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cmd := &cobra.Command{Use: "docs +media-insert"}
|
|
cmd.Flags().String("file", "", "")
|
|
cmd.Flags().String("doc", "", "")
|
|
cmd.Flags().String("type", "image", "")
|
|
cmd.Flags().String("align", "", "")
|
|
cmd.Flags().String("caption", "", "")
|
|
cmd.Flags().String("selection-with-ellipsis", "", "")
|
|
cmd.Flags().Bool("before", false, "")
|
|
_ = cmd.Flags().Set("file", "img.png")
|
|
_ = cmd.Flags().Set("doc", "doxcnABCDEF")
|
|
_ = cmd.Flags().Set("selection-with-ellipsis", "Introduction")
|
|
|
|
rt := common.TestNewRuntimeContext(cmd, docsTestConfigWithAppID("dry-run-app"))
|
|
dryAPI := DocMediaInsert.DryRun(context.Background(), rt)
|
|
raw, _ := json.Marshal(dryAPI)
|
|
|
|
var dry struct {
|
|
Description string `json:"description"`
|
|
API []struct {
|
|
Desc string `json:"desc"`
|
|
URL string `json:"url"`
|
|
Body map[string]interface{} `json:"body"`
|
|
} `json:"api"`
|
|
}
|
|
if err := json.Unmarshal(raw, &dry); err != nil {
|
|
t.Fatalf("decode dry-run: %v", err)
|
|
}
|
|
|
|
foundMCP := false
|
|
for _, step := range dry.API {
|
|
if strings.Contains(step.Desc, "locate-doc") {
|
|
foundMCP = true
|
|
}
|
|
}
|
|
if !foundMCP {
|
|
t.Fatalf("dry-run should include a locate-doc step, got: %+v", dry.API)
|
|
}
|
|
if !strings.Contains(dry.Description, "locate-doc") {
|
|
t.Fatalf("dry-run description should mention 'locate-doc', got: %s", dry.Description)
|
|
}
|
|
|
|
// Verify create-block step shows <locate_index> not <children_len>
|
|
for _, step := range dry.API {
|
|
if strings.Contains(step.URL, "/children") && step.Body != nil {
|
|
if idx, ok := step.Body["index"]; ok {
|
|
if idx != "<locate_index>" {
|
|
t.Fatalf("create-block index in selection mode = %q, want <locate_index>", idx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractCreatedBlockTargetsForImage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
createData := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "img_outer",
|
|
},
|
|
},
|
|
}
|
|
|
|
blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "image")
|
|
if blockID != "img_outer" || uploadParentNode != "img_outer" || replaceBlockID != "img_outer" {
|
|
t.Fatalf("extractCreatedBlockTargets(image) = (%q, %q, %q)", blockID, uploadParentNode, replaceBlockID)
|
|
}
|
|
}
|
|
|
|
func TestExtractCreatedBlockTargetsForFileUsesNestedFileBlock(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
createData := map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{
|
|
"block_id": "view_outer",
|
|
"children": []interface{}{"file_inner"},
|
|
},
|
|
},
|
|
}
|
|
|
|
blockID, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, "file")
|
|
if blockID != "view_outer" {
|
|
t.Fatalf("extractCreatedBlockTargets(file) blockID = %q, want %q", blockID, "view_outer")
|
|
}
|
|
if uploadParentNode != "file_inner" {
|
|
t.Fatalf("extractCreatedBlockTargets(file) uploadParentNode = %q, want %q", uploadParentNode, "file_inner")
|
|
}
|
|
if replaceBlockID != "file_inner" {
|
|
t.Fatalf("extractCreatedBlockTargets(file) replaceBlockID = %q, want %q", replaceBlockID, "file_inner")
|
|
}
|
|
}
|
|
|
|
// newMediaInsertValidateRuntime builds a bare RuntimeContext wired with
|
|
// only the flags that DocMediaInsert.Validate reads. It exists so the
|
|
// Validate tests below can exercise the CLI contract without going
|
|
// through the full cobra command tree.
|
|
func newMediaInsertValidateRuntime(t *testing.T, doc, mediaType, fileView string) *common.RuntimeContext {
|
|
t.Helper()
|
|
|
|
cmd := &cobra.Command{Use: "docs +media-insert"}
|
|
cmd.Flags().String("file", "", "")
|
|
cmd.Flags().Bool("from-clipboard", false, "")
|
|
cmd.Flags().String("doc", "", "")
|
|
cmd.Flags().String("type", "", "")
|
|
cmd.Flags().String("file-view", "", "")
|
|
// A non-empty --file satisfies the file/clipboard xor check so Validate
|
|
// reaches the --file-view logic under test below.
|
|
if err := cmd.Flags().Set("file", "dummy.bin"); err != nil {
|
|
t.Fatalf("set --file: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("doc", doc); err != nil {
|
|
t.Fatalf("set --doc: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
|
t.Fatalf("set --type: %v", err)
|
|
}
|
|
if fileView != "" {
|
|
if err := cmd.Flags().Set("file-view", fileView); err != nil {
|
|
t.Fatalf("set --file-view: %v", err)
|
|
}
|
|
}
|
|
return common.TestNewRuntimeContext(cmd, nil)
|
|
}
|
|
|
|
func newMediaInsertValidateRuntimeWithSize(t *testing.T, doc, mediaType string, width, height int, setWidth, setHeight bool) *common.RuntimeContext {
|
|
t.Helper()
|
|
|
|
cmd := &cobra.Command{Use: "docs +media-insert"}
|
|
cmd.Flags().String("file", "", "")
|
|
cmd.Flags().Bool("from-clipboard", false, "")
|
|
cmd.Flags().String("doc", "", "")
|
|
cmd.Flags().String("type", "", "")
|
|
cmd.Flags().String("file-view", "", "")
|
|
cmd.Flags().Int("width", 0, "")
|
|
cmd.Flags().Int("height", 0, "")
|
|
cmd.Flags().String("selection-with-ellipsis", "", "")
|
|
cmd.Flags().Bool("before", false, "")
|
|
if err := cmd.Flags().Set("file", "dummy.bin"); err != nil {
|
|
t.Fatalf("set --file: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("doc", doc); err != nil {
|
|
t.Fatalf("set --doc: %v", err)
|
|
}
|
|
if err := cmd.Flags().Set("type", mediaType); err != nil {
|
|
t.Fatalf("set --type: %v", err)
|
|
}
|
|
if setWidth {
|
|
if err := cmd.Flags().Set("width", fmt.Sprintf("%d", width)); err != nil {
|
|
t.Fatalf("set --width: %v", err)
|
|
}
|
|
}
|
|
if setHeight {
|
|
if err := cmd.Flags().Set("height", fmt.Sprintf("%d", height)); err != nil {
|
|
t.Fatalf("set --height: %v", err)
|
|
}
|
|
}
|
|
return common.TestNewRuntimeContext(cmd, nil)
|
|
}
|
|
|
|
func TestDocMediaInsertValidateWidthHeightOnlyForImage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
mediaType string
|
|
width int
|
|
height int
|
|
setWidth bool
|
|
setHeight bool
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "width with file type is rejected",
|
|
mediaType: "file",
|
|
width: 800,
|
|
setWidth: true,
|
|
wantErr: "--width/--height only apply when --type=image",
|
|
},
|
|
{
|
|
name: "height with file type is rejected",
|
|
mediaType: "file",
|
|
height: 600,
|
|
setHeight: true,
|
|
wantErr: "--width/--height only apply when --type=image",
|
|
},
|
|
{
|
|
name: "explicit zero width is rejected",
|
|
mediaType: "image",
|
|
width: 0,
|
|
setWidth: true,
|
|
wantErr: "--width must be a positive integer",
|
|
},
|
|
{
|
|
name: "negative width is rejected",
|
|
mediaType: "image",
|
|
width: -1,
|
|
setWidth: true,
|
|
wantErr: "--width must be a positive integer",
|
|
},
|
|
{
|
|
name: "negative height is rejected",
|
|
mediaType: "image",
|
|
height: -5,
|
|
setHeight: true,
|
|
wantErr: "--height must be a positive integer",
|
|
},
|
|
{
|
|
name: "valid width with image type is accepted",
|
|
mediaType: "image",
|
|
width: 800,
|
|
setWidth: true,
|
|
},
|
|
{
|
|
name: "valid width and height with image type is accepted",
|
|
mediaType: "image",
|
|
width: 800,
|
|
height: 600,
|
|
setWidth: true,
|
|
setHeight: true,
|
|
},
|
|
}
|
|
|
|
for _, ttTemp := range tests {
|
|
tt := ttTemp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnValidateSize", tt.mediaType, tt.width, tt.height, tt.setWidth, tt.setHeight)
|
|
err := DocMediaInsert.Validate(context.Background(), rt)
|
|
if tt.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDocMediaInsertValidateNoWidthHeightIsValid(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := newMediaInsertValidateRuntimeWithSize(t, "doxcnNoSize", "image", 0, 0, false, false)
|
|
err := DocMediaInsert.Validate(context.Background(), rt)
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error when neither --width nor --height passed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAutoAspectRatioFromWidth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Native image: 1200x800 (3:2 ratio)
|
|
// User provides width=600 → expected height = 600 * 800 / 1200 = 400
|
|
got := computeMissingDimension(600, 0, 1200, 800)
|
|
wantWidth, wantHeight := 600, 400
|
|
if got.width != wantWidth || got.height != wantHeight {
|
|
t.Fatalf("computeMissingDimension(600, 0, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
|
|
}
|
|
}
|
|
|
|
func TestAutoAspectRatioFromHeight(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Native image: 1200x800 (3:2 ratio)
|
|
// User provides height=400 → expected width = 400 * 1200 / 800 = 600
|
|
got := computeMissingDimension(0, 400, 1200, 800)
|
|
wantWidth, wantHeight := 600, 400
|
|
if got.width != wantWidth || got.height != wantHeight {
|
|
t.Fatalf("computeMissingDimension(0, 400, 1200, 800) = (%d, %d), want (%d, %d)", got.width, got.height, wantWidth, wantHeight)
|
|
}
|
|
}
|
|
|
|
func TestComputeMissingDimensionBothProvided(t *testing.T) {
|
|
t.Parallel()
|
|
got := computeMissingDimension(800, 600, 1200, 900)
|
|
if got.width != 800 || got.height != 600 {
|
|
t.Fatalf("computeMissingDimension(800, 600, 1200, 900) = (%d, %d), want (800, 600)", got.width, got.height)
|
|
}
|
|
}
|
|
|
|
func TestComputeMissingDimensionNeitherProvided(t *testing.T) {
|
|
t.Parallel()
|
|
got := computeMissingDimension(0, 0, 1200, 900)
|
|
if got.width != 0 || got.height != 0 {
|
|
t.Fatalf("computeMissingDimension(0, 0, 1200, 900) = (%d, %d), want (0, 0)", got.width, got.height)
|
|
}
|
|
}
|
|
|
|
func TestComputeMissingDimensionZeroNativeWidth(t *testing.T) {
|
|
t.Parallel()
|
|
got := computeMissingDimension(600, 0, 0, 800)
|
|
if got.width != 600 || got.height != 0 {
|
|
t.Fatalf("computeMissingDimension(600, 0, 0, 800) = (%d, %d), want (600, 0)", got.width, got.height)
|
|
}
|
|
}
|
|
|
|
func TestComputeMissingDimensionZeroNativeHeight(t *testing.T) {
|
|
t.Parallel()
|
|
got := computeMissingDimension(0, 400, 1200, 0)
|
|
if got.width != 0 || got.height != 400 {
|
|
t.Fatalf("computeMissingDimension(0, 400, 1200, 0) = (%d, %d), want (0, 400)", got.width, got.height)
|
|
}
|
|
}
|
|
|
|
func TestComputeMissingDimensionRounding(t *testing.T) {
|
|
t.Parallel()
|
|
got := computeMissingDimension(999, 0, 1000, 333)
|
|
want := (999*333 + 500) / 1000
|
|
if got.height != want {
|
|
t.Fatalf("computeMissingDimension(999, 0, 1000, 333).height = %d, want %d (rounded)", got.height, want)
|
|
}
|
|
}
|
|
|
|
func TestDocMediaInsertValidateFileView(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
mediaType string
|
|
fileView string
|
|
wantErr string // substring; empty means success expected
|
|
}{
|
|
{
|
|
name: "file with card is accepted",
|
|
mediaType: "file",
|
|
fileView: "card",
|
|
},
|
|
{
|
|
name: "file with preview is accepted",
|
|
mediaType: "file",
|
|
fileView: "preview",
|
|
},
|
|
{
|
|
name: "file with inline is accepted",
|
|
mediaType: "file",
|
|
fileView: "inline",
|
|
},
|
|
{
|
|
name: "file without file-view is accepted",
|
|
mediaType: "file",
|
|
fileView: "",
|
|
},
|
|
{
|
|
name: "unknown file-view value is rejected",
|
|
mediaType: "file",
|
|
fileView: "bogus",
|
|
wantErr: "invalid --file-view value",
|
|
},
|
|
{
|
|
name: "file-view with image type is rejected",
|
|
mediaType: "image",
|
|
fileView: "preview",
|
|
wantErr: "--file-view only applies when --type=file",
|
|
},
|
|
}
|
|
|
|
for _, ttTemp := range tests {
|
|
tt := ttTemp
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := newMediaInsertValidateRuntime(t, "doxcnValidateFileView", tt.mediaType, tt.fileView)
|
|
err := DocMediaInsert.Validate(context.Background(), rt)
|
|
if tt.wantErr == "" {
|
|
if err != nil {
|
|
t.Fatalf("Validate() unexpected error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("Validate() error = nil, want error containing %q", tt.wantErr)
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Fatalf("Validate() error = %q, want substring %q", err.Error(), tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocateInsertIndexWarnsOnMultipleMatches verifies that when locate-doc
|
|
// returns more than one match, a warning is written to stderr pointing the user
|
|
// at the 'start...end' disambiguation syntax. Silently picking the first match
|
|
// of an ambiguous selection is a real UX trap — users who edit documents with
|
|
// repeated phrases (a heading that also appears in the TOC, for example) get
|
|
// no signal that another match existed.
|
|
func TestLocateInsertIndexWarnsOnMultipleMatches(t *testing.T) {
|
|
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-multi-app"))
|
|
|
|
docID := "doxcnMULTI"
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": []interface{}{"blk_a", "blk_b"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// Two matches — same selection appears in two different root-level blocks.
|
|
// locate-doc orders matches by document position, so matches[0] is still
|
|
// deterministic (blk_a) even with limit=2.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{
|
|
{"anchor_block_id": "blk_a", "parent_block_id": docID},
|
|
{"anchor_block_id": "blk_b", "parent_block_id": docID},
|
|
}),
|
|
})
|
|
createStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(createStub)
|
|
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": "ftok_multi"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", docID,
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "Repeated phrase",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err != nil {
|
|
t.Fatalf("Execute() error: %v", err)
|
|
}
|
|
|
|
// Warning should name the ambiguity and point at 'start...end'.
|
|
stderrOut := stderr.String()
|
|
if !strings.Contains(stderrOut, "matched more than one block") {
|
|
t.Errorf("stderr missing multi-match warning; got:\n%s", stderrOut)
|
|
}
|
|
if !strings.Contains(stderrOut, "start...end") {
|
|
t.Errorf("stderr missing 'start...end' disambiguation hint; got:\n%s", stderrOut)
|
|
}
|
|
// Should still insert at the first match (blk_a at index 0) → after ⇒ 1.
|
|
assertCreateBlockIndex(t, createStub, 1)
|
|
}
|
|
|
|
// TestLocateInsertIndexLogsNestedAnchor verifies that when the matched block is
|
|
// nested (not a direct root child), a note is written to stderr explaining that
|
|
// the media lands at the top-level ancestor. This protects users from being
|
|
// surprised when selecting text inside a callout or table cell and seeing the
|
|
// image appear outside that container.
|
|
func TestLocateInsertIndexLogsNestedAnchor(t *testing.T) {
|
|
f, _, stderr, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-nested-log-app"))
|
|
|
|
docID := "doxcnNESTEDLOG"
|
|
// Same shape as TestLocateInsertIndexNestedBlockViaExecute: anchor is two
|
|
// levels below root, so walkDepth == 2 when we hit the root ancestor.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": []interface{}{"blk_section", "blk_other"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{
|
|
{"anchor_block_id": "blk_grandchild", "parent_block_id": "blk_section_child"},
|
|
}),
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_section_child",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": "blk_section_child",
|
|
"parent_id": "blk_section",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID + "/children",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"children": []interface{}{
|
|
map[string]interface{}{"block_id": "blk_new", "block_type": 27, "image": map[string]interface{}{}},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
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": "ftok_nested_log"},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "PATCH",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/batch_update",
|
|
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
|
})
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", docID,
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "nested content",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err != nil {
|
|
t.Fatalf("Execute() error: %v", err)
|
|
}
|
|
|
|
stderrOut := stderr.String()
|
|
if !strings.Contains(stderrOut, "nested") || !strings.Contains(stderrOut, "top-level ancestor") {
|
|
t.Errorf("stderr missing nested-anchor note; got:\n%s", stderrOut)
|
|
}
|
|
}
|
|
|
|
// TestLocateInsertIndexCycleDetection verifies that a malformed parent chain
|
|
// (blk_x.parent = blk_y and blk_y.parent = blk_x, neither reachable from root)
|
|
// does not spin the locate-doc walk forever. The `visited` map must break the
|
|
// cycle, and the user must see the "not reachable from document root" error
|
|
// rather than the process hanging. Without this test, a regression that broke
|
|
// cycle protection would only surface in production with a stalled CLI.
|
|
func TestLocateInsertIndexCycleDetection(t *testing.T) {
|
|
f, _, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("locate-cycle-app"))
|
|
|
|
docID := "doxcnCYCLE"
|
|
// Root has unrelated children — neither blk_x nor blk_y reach root.
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/" + docID,
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": docID,
|
|
"children": []interface{}{"blk_unrelated_a", "blk_unrelated_b"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
// locate-doc hints parent_block_id = blk_y for anchor blk_x (first hop consumed).
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "mcp.feishu.cn/mcp",
|
|
Body: buildLocateDocMCPResponse([]map[string]interface{}{
|
|
{"anchor_block_id": "blk_x", "parent_block_id": "blk_y"},
|
|
}),
|
|
})
|
|
// blk_y claims blk_x as parent — closes the cycle. The walk must land here
|
|
// exactly once before visited[blk_x] triggers a break.
|
|
blkYStub := &httpmock.Stub{
|
|
Method: "GET",
|
|
URL: "/open-apis/docx/v1/documents/" + docID + "/blocks/blk_y",
|
|
Body: map[string]interface{}{
|
|
"code": 0, "msg": "ok",
|
|
"data": map[string]interface{}{
|
|
"block": map[string]interface{}{
|
|
"block_id": "blk_y",
|
|
"parent_id": "blk_x",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
reg.Register(blkYStub)
|
|
|
|
tmpDir := t.TempDir()
|
|
withDocsWorkingDir(t, tmpDir)
|
|
writeSizedDocTestFile(t, "img.png", 100)
|
|
|
|
err := mountAndRunDocs(t, DocMediaInsert, []string{
|
|
"+media-insert",
|
|
"--doc", docID,
|
|
"--file", "img.png",
|
|
"--selection-with-ellipsis", "cyclic anchor",
|
|
"--as", "bot",
|
|
}, f, nil)
|
|
if err == nil {
|
|
t.Fatal("expected 'block_not_reachable' error from cyclic parent chain; got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not reachable") && !strings.Contains(err.Error(), "block_not_reachable") {
|
|
t.Fatalf("unexpected error — want cycle-bounded 'not reachable', got: %v", err)
|
|
}
|
|
// blk_y should be fetched exactly once. Registering just one stub for it
|
|
// already enforces an upper bound (httpmock errors on extra calls), so if
|
|
// the walk looped more than once the test harness would fail differently.
|
|
if blkYStub.CapturedHeaders == nil && blkYStub.CapturedBody == nil {
|
|
t.Errorf("expected the walk to fetch blk_y once; stub was not hit")
|
|
}
|
|
}
|