chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Wiki CLI E2E Coverage
|
||||
|
||||
## Metrics
|
||||
- Denominator: 6 leaf commands + 3 shortcut commands
|
||||
- Covered: 9
|
||||
- Coverage: 100.0%
|
||||
|
||||
## Summary
|
||||
- TestWiki_NodeWorkflow: proves the full currently-tested bare-API surface; key `t.Run(...)` proof points are `create node as bot`, `get created node as bot`, `get space as bot`, `list spaces as bot`, `list nodes and find created node as bot`, `copy node as bot`, and `list nodes and find copied node as bot`.
|
||||
- TestWiki_ShortcutWorkflow: covers the shortcut layer for `wiki +space-list`, `wiki +node-list`, and `wiki +node-copy` — flag→body mapping, envelope shape (`{spaces|nodes, has_more, page_token}` + `meta.count`), `--page-all` / `--page-limit` truncation, my_library alias resolution (user positive + bot validation rejection), and copy-source-survival.
|
||||
|
||||
## Command Table
|
||||
|
||||
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ✓ | wiki nodes copy | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/copy node as bot | `space_id`; `node_token` in `--params`; target/title in `--data` | |
|
||||
| ✓ | wiki nodes create | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/create node as bot | `space_id` in `--params`; `node_type`; `obj_type`; `title` in `--data` | |
|
||||
| ✓ | wiki nodes list | api | wiki/helpers_test.go::findWikiNodeByToken; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find created node as bot; wiki_workflow_test.go::TestWiki_NodeWorkflow/list nodes and find copied node as bot | `space_id`; `page_size`; optional `page_token` | |
|
||||
| ✓ | wiki spaces get | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get space as bot | `space_id` in `--params` | |
|
||||
| ✓ | wiki spaces get_node | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/get created node as bot | `token`; `obj_type` in `--params` | |
|
||||
| ✓ | wiki spaces list | api | wiki_workflow_test.go::TestWiki_NodeWorkflow/list spaces as bot | `page_size` in `--params` | |
|
||||
| ✓ | wiki +space-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+space-list: stable envelope shape | `--page-size`; `--format json`; bot identity | |
|
||||
| ✓ | wiki +node-list | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-list: finds child under parent; +node-list: --page-limit caps the loop and exposes cursor; +node-list --space-id my_library --as bot: validation rejection; +node-list --space-id my_library --as user: resolves and lists | `--space-id`; `--parent-node-token`; `--page-all`; `--page-size`; `--page-limit`; my_library alias | |
|
||||
| ✓ | wiki +node-copy | shortcut | wiki_shortcut_workflow_test.go::TestWiki_ShortcutWorkflow/+node-copy: copies child + verifies source survives + cleanup | `--space-id`; `--node-token`; `--target-space-id`; `--title` | |
|
||||
@@ -0,0 +1,530 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createWikiNode(t *testing.T, parentT *testing.T, ctx context.Context, spaceID string, data map[string]any) (gjson.Result, *clie2e.Result, error) {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Data: data,
|
||||
})
|
||||
if err != nil || result.ExitCode != 0 {
|
||||
return gjson.Result{}, result, err
|
||||
}
|
||||
|
||||
node := gjson.Get(result.Stdout, "data.node")
|
||||
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
nodeToken := node.Get("node_token").String()
|
||||
require.NotEmpty(t, nodeToken, "stdout:\n%s", result.Stdout)
|
||||
objType := node.Get("obj_type").String()
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, spaceID, nodeToken, objType)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete wiki node "+nodeToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
return node, result, nil
|
||||
}
|
||||
|
||||
// createWikiNodeUnderAnyHost creates an isolated parent under an existing
|
||||
// my_library root node. It avoids adding test nodes directly at the root level,
|
||||
// whose single-layer limit is easy to exhaust when cleanup regresses. If the
|
||||
// library is empty, it creates one reusable root host and keeps it for future
|
||||
// test runs.
|
||||
func createWikiNodeUnderAnyHost(t *testing.T, parentT *testing.T, ctx context.Context, title string) (gjson.Result, gjson.Result) {
|
||||
t.Helper()
|
||||
|
||||
hosts := listWikiRootHosts(t, ctx)
|
||||
if len(hosts) == 0 {
|
||||
hosts = append(hosts, createWikiRootHost(t, ctx))
|
||||
}
|
||||
|
||||
var layerLimitResults []string
|
||||
for _, host := range hosts {
|
||||
spaceID := host.Get("space_id").String()
|
||||
hostNodeToken := host.Get("node_token").String()
|
||||
if spaceID == "" || hostNodeToken == "" {
|
||||
continue
|
||||
}
|
||||
node, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": title,
|
||||
"parent_node_token": hostNodeToken,
|
||||
})
|
||||
if err == nil && result.ExitCode == 0 {
|
||||
return host, node
|
||||
}
|
||||
if isWikiLayerLimitResult(result) {
|
||||
layerLimitResults = append(layerLimitResults, fmt.Sprintf("host=%s stdout=%s stderr=%s", hostNodeToken, result.Stdout, result.Stderr))
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Failf(t, "create wiki node under host failed", "host=%s exit=%d stdout=%s stderr=%s", hostNodeToken, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
require.Failf(t, "create wiki node under host failed", "all candidate hosts hit the single-layer node limit:\n%s", strings.Join(layerLimitResults, "\n"))
|
||||
return gjson.Result{}, gjson.Result{}
|
||||
}
|
||||
|
||||
func createWikiRootHost(t *testing.T, ctx context.Context) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/my_library/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": "lark-cli-e2e-wiki-host",
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
host := gjson.Get(result.Stdout, "data.node")
|
||||
require.True(t, host.Exists(), "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, host.Get("space_id").String(), "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, host.Get("node_token").String(), "stdout:\n%s", result.Stdout)
|
||||
return host
|
||||
}
|
||||
|
||||
func listWikiRootHosts(t *testing.T, ctx context.Context) []gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
var hosts []gjson.Result
|
||||
pageToken := ""
|
||||
seenPageTokens := map[string]struct{}{}
|
||||
for {
|
||||
params := map[string]any{"page_size": 50}
|
||||
if pageToken != "" {
|
||||
if _, exists := seenPageTokens[pageToken]; exists {
|
||||
t.Fatalf("wiki root host pagination loop detected for page_token %q", pageToken)
|
||||
}
|
||||
seenPageTokens[pageToken] = struct{}{}
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/my_library/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Params: params,
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
listResult.AssertExitCode(t, 0)
|
||||
listResult.AssertStdoutStatus(t, true)
|
||||
|
||||
parsed := gjson.Parse(listResult.Stdout)
|
||||
hosts = append(hosts, parsed.Get("data.items").Array()...)
|
||||
|
||||
pageToken = parsed.Get("data.page_token").String()
|
||||
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
|
||||
return hosts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isWikiLayerLimitResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
combined := result.Stdout + "\n" + result.Stderr
|
||||
return strings.Contains(combined, "131003") ||
|
||||
strings.Contains(strings.ToLower(combined), "single-layer nodes")
|
||||
}
|
||||
|
||||
func getWikiNode(t *testing.T, ctx context.Context, nodeToken string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"token": nodeToken},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
node := gjson.Get(result.Stdout, "data.node")
|
||||
require.True(t, node.Exists(), "stdout:\n%s", result.Stdout)
|
||||
return node
|
||||
}
|
||||
|
||||
func getWikiSpace(t *testing.T, ctx context.Context, spaceID string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
space := gjson.Get(result.Stdout, "data.space")
|
||||
require.True(t, space.Exists(), "stdout:\n%s", result.Stdout)
|
||||
return space
|
||||
}
|
||||
|
||||
func listWikiSpaces(t *testing.T, ctx context.Context, pageSize int) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"page_size": pageSize},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
return gjson.Parse(result.Stdout)
|
||||
}
|
||||
|
||||
type wikiNodeInfo struct {
|
||||
NodeToken string
|
||||
ObjType string
|
||||
}
|
||||
|
||||
const (
|
||||
wikiDeleteVisibilityTimeout = 30 * time.Second
|
||||
wikiDeleteVisibilityPoll = 3 * time.Second
|
||||
)
|
||||
|
||||
// deleteWikiNodeAndVerify removes a wiki node, then polls get_node until the
|
||||
// original node token is gone. Wiki cleanup cannot use drive +delete because
|
||||
// wiki origin nodes need the backing obj_token and parent nodes must delete
|
||||
// children first.
|
||||
func deleteWikiNodeAndVerify(ctx context.Context, spaceID, nodeToken, objType string) (*clie2e.Result, error) {
|
||||
getResult, getErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"token": nodeToken},
|
||||
}, clie2e.RetryOptions{})
|
||||
if getErr != nil {
|
||||
return getResult, getErr
|
||||
}
|
||||
if getResult == nil {
|
||||
return nil, fmt.Errorf("get wiki node %s before delete returned nil result", nodeToken)
|
||||
}
|
||||
if getResult.ExitCode != 0 || !wikiAPISuccess(getResult.Stdout) {
|
||||
if isWikiNodeDeletedResult(getResult) {
|
||||
getResult.ExitCode = 0
|
||||
getResult.RunErr = nil
|
||||
return getResult, nil
|
||||
}
|
||||
return getResult, fmt.Errorf("get wiki node %s before delete failed: exit=%d stdout=%s stderr=%s", nodeToken, getResult.ExitCode, getResult.Stdout, getResult.Stderr)
|
||||
}
|
||||
|
||||
node := gjson.Get(getResult.Stdout, "data.node")
|
||||
originalNodeToken := nodeToken
|
||||
if resolvedSpaceID := node.Get("space_id").String(); resolvedSpaceID != "" {
|
||||
spaceID = resolvedSpaceID
|
||||
}
|
||||
if resolvedObjType := node.Get("obj_type").String(); resolvedObjType != "" {
|
||||
objType = resolvedObjType
|
||||
}
|
||||
if objType == "" {
|
||||
objType = "docx"
|
||||
}
|
||||
|
||||
children, childListResult, childListErr := listWikiNodeChildren(ctx, spaceID, originalNodeToken)
|
||||
if childListErr != nil || childListResult == nil || childListResult.ExitCode != 0 {
|
||||
return childListResult, childListErr
|
||||
}
|
||||
for _, child := range children {
|
||||
childDeleteResult, childDeleteErr := deleteWikiNodeAndVerify(ctx, spaceID, child.NodeToken, child.ObjType)
|
||||
if childDeleteErr != nil || childDeleteResult == nil || (childDeleteResult.ExitCode != 0 && !isWikiNodeDeletedResult(childDeleteResult)) {
|
||||
return childDeleteResult, childDeleteErr
|
||||
}
|
||||
}
|
||||
|
||||
deleteToken := originalNodeToken
|
||||
if node.Get("node_type").String() == "origin" {
|
||||
if objToken := node.Get("obj_token").String(); objToken != "" {
|
||||
deleteToken = objToken
|
||||
}
|
||||
}
|
||||
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "delete", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + deleteToken},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{"obj_type": objType},
|
||||
}, clie2e.RetryOptions{})
|
||||
if deleteErr != nil || deleteResult == nil {
|
||||
return deleteResult, deleteErr
|
||||
}
|
||||
if deleteResult.ExitCode != 0 || !wikiAPISuccess(deleteResult.Stdout) {
|
||||
deleted, verifyErr := isWikiNodeDeleted(ctx, originalNodeToken)
|
||||
if verifyErr != nil {
|
||||
return deleteResult, verifyErr
|
||||
}
|
||||
if deleted {
|
||||
deleteResult.ExitCode = 0
|
||||
return deleteResult, nil
|
||||
}
|
||||
return deleteResult, fmt.Errorf("wiki node %s still exists after delete failed: exit=%d stdout=%s stderr=%s", originalNodeToken, deleteResult.ExitCode, deleteResult.Stdout, deleteResult.Stderr)
|
||||
}
|
||||
if err := waitWikiNodeDeleted(ctx, originalNodeToken); err != nil {
|
||||
return deleteResult, err
|
||||
}
|
||||
return deleteResult, nil
|
||||
}
|
||||
|
||||
func listWikiNodeChildren(ctx context.Context, spaceID, parentNodeToken string) ([]wikiNodeInfo, *clie2e.Result, error) {
|
||||
var children []wikiNodeInfo
|
||||
pageToken := ""
|
||||
seenPageTokens := map[string]struct{}{}
|
||||
for {
|
||||
params := map[string]any{
|
||||
"page_size": 50,
|
||||
"parent_node_token": parentNodeToken,
|
||||
}
|
||||
if pageToken != "" {
|
||||
if _, exists := seenPageTokens[pageToken]; exists {
|
||||
return children, nil, fmt.Errorf("wiki children pagination loop detected for parent %s page_token %q", parentNodeToken, pageToken)
|
||||
}
|
||||
seenPageTokens[pageToken] = struct{}{}
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Params: params,
|
||||
}, clie2e.RetryOptions{})
|
||||
if err != nil || result == nil || result.ExitCode != 0 {
|
||||
return children, result, err
|
||||
}
|
||||
if !wikiAPISuccess(result.Stdout) {
|
||||
return children, result, fmt.Errorf("list wiki node children for parent %s failed: stdout=%s stderr=%s", parentNodeToken, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
parsed := gjson.Parse(result.Stdout)
|
||||
for _, item := range parsed.Get("data.items").Array() {
|
||||
nodeToken := item.Get("node_token").String()
|
||||
if nodeToken == "" {
|
||||
continue
|
||||
}
|
||||
objType := item.Get("obj_type").String()
|
||||
if objType == "" {
|
||||
objType = "docx"
|
||||
}
|
||||
children = append(children, wikiNodeInfo{NodeToken: nodeToken, ObjType: objType})
|
||||
}
|
||||
|
||||
pageToken = parsed.Get("data.page_token").String()
|
||||
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
|
||||
return children, result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitWikiNodeDeleted(ctx context.Context, nodeToken string) error {
|
||||
var lastTransientErr error
|
||||
|
||||
opts := clie2e.WaitOptions{
|
||||
Timeout: wikiDeleteVisibilityTimeout,
|
||||
Interval: wikiDeleteVisibilityPoll,
|
||||
TimeoutError: func() error {
|
||||
if lastTransientErr != nil {
|
||||
return fmt.Errorf("wiki node %s delete verification kept hitting transient errors: %w", nodeToken, lastTransientErr)
|
||||
}
|
||||
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
||||
},
|
||||
}
|
||||
|
||||
return clie2e.WaitForCondition(ctx, opts, func() (bool, error) {
|
||||
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
|
||||
if err != nil {
|
||||
if isWikiVerifyTransientError(err) {
|
||||
lastTransientErr = err
|
||||
return false, nil
|
||||
} else {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if deleted {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func isWikiNodeDeleted(ctx context.Context, nodeToken string) (bool, error) {
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/get_node"},
|
||||
DefaultAs: "bot",
|
||||
Params: map[string]any{"token": nodeToken},
|
||||
}, clie2e.RetryOptions{})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if result == nil {
|
||||
return false, fmt.Errorf("verify wiki node %s after delete returned nil result", nodeToken)
|
||||
}
|
||||
if result.ExitCode == 0 && wikiAPISuccess(result.Stdout) {
|
||||
return false, nil
|
||||
}
|
||||
if isWikiNodeDeletedResult(result) {
|
||||
return true, nil
|
||||
}
|
||||
if isWikiVerifyTransientResult(result) {
|
||||
return false, wikiVerifyTransientError{
|
||||
err: fmt.Errorf("verify wiki node %s after delete hit transient response: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr),
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
|
||||
}
|
||||
|
||||
type wikiVerifyTransientError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e wikiVerifyTransientError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientError(err error) bool {
|
||||
var transient wikiVerifyTransientError
|
||||
return err != nil && errors.As(err, &transient)
|
||||
}
|
||||
|
||||
func wikiAPISuccess(stdout string) bool {
|
||||
if ok := gjson.Get(stdout, "ok"); ok.Exists() {
|
||||
return ok.Bool()
|
||||
}
|
||||
if code := gjson.Get(stdout, "code"); code.Exists() {
|
||||
return code.Int() == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isWikiNodeDeletedResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
if code := gjson.Get(result.Stdout, "error.code"); code.Exists() && code.Int() == 131005 {
|
||||
return true
|
||||
}
|
||||
if code := gjson.Get(result.Stdout, "code"); code.Exists() && code.Int() == 131005 {
|
||||
return true
|
||||
}
|
||||
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
return strings.Contains(combined, "131005") ||
|
||||
strings.Contains(combined, "node not found") ||
|
||||
strings.Contains(combined, "not found")
|
||||
}
|
||||
|
||||
func isWikiVerifyTransientResult(result *clie2e.Result) bool {
|
||||
if result == nil {
|
||||
return false
|
||||
}
|
||||
payload := result.Stdout
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
payload = result.Stderr
|
||||
}
|
||||
if gjson.Get(payload, "error.type").String() != "internal" ||
|
||||
gjson.Get(payload, "error.subtype").String() != "invalid_response" {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(gjson.Get(payload, "error.message").String())
|
||||
return strings.Contains(message, "http 429") ||
|
||||
strings.Contains(message, "http 500") ||
|
||||
strings.Contains(message, "http 502") ||
|
||||
strings.Contains(message, "http 503") ||
|
||||
strings.Contains(message, "http 504")
|
||||
}
|
||||
|
||||
func TestWikiVerifyTransientResult(t *testing.T) {
|
||||
t.Run("matches invalid response from transient http status", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: failed to parse TAT response (HTTP 429): invalid character 'r' looking for beginning of value"}}`,
|
||||
}
|
||||
|
||||
require.True(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match unrelated invalid response", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 5,
|
||||
Stderr: `{"ok":false,"error":{"type":"internal","subtype":"invalid_response","message":"SDK returned an invalid JSON response: malformed body"}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
|
||||
t.Run("does not match api errors", func(t *testing.T) {
|
||||
result := &clie2e.Result{
|
||||
ExitCode: 1,
|
||||
Stderr: `{"ok":false,"error":{"type":"api","subtype":"conflict","message":"resource contention occurred, please retry","retryable":true}}`,
|
||||
}
|
||||
|
||||
require.False(t, isWikiVerifyTransientResult(result))
|
||||
})
|
||||
}
|
||||
|
||||
func findWikiNodeByToken(t *testing.T, ctx context.Context, spaceID string, nodeToken string, parentNodeTokens ...string) gjson.Result {
|
||||
t.Helper()
|
||||
|
||||
pageToken := ""
|
||||
lastStdout := ""
|
||||
seenPageTokens := map[string]struct{}{}
|
||||
for {
|
||||
params := map[string]any{"page_size": 50}
|
||||
if len(parentNodeTokens) > 0 && parentNodeTokens[0] != "" {
|
||||
params["parent_node_token"] = parentNodeTokens[0]
|
||||
}
|
||||
if pageToken != "" {
|
||||
if _, exists := seenPageTokens[pageToken]; exists {
|
||||
t.Fatalf("wiki list pagination loop detected for page_token %q, last stdout:\n%s", pageToken, lastStdout)
|
||||
}
|
||||
seenPageTokens[pageToken] = struct{}{}
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"api", "get", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes"},
|
||||
DefaultAs: "bot",
|
||||
Params: params,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
lastStdout = result.Stdout
|
||||
parsed := gjson.Parse(result.Stdout)
|
||||
node := parsed.Get(`data.items.#(node_token=="` + nodeToken + `")`)
|
||||
if node.Exists() {
|
||||
return node
|
||||
}
|
||||
|
||||
pageToken = parsed.Get("data.page_token").String()
|
||||
if pageToken == "" || !parsed.Get("data.has_more").Bool() {
|
||||
t.Fatalf("wiki node %q not found in listed pages, last stdout:\n%s", nodeToken, lastStdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWikiMemberAddDryRun(t *testing.T) {
|
||||
setWikiNodeCreateDryRunEnv(t)
|
||||
|
||||
t.Run("SupportsAppIDMemberType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+member-add",
|
||||
"--space-id", "space_42",
|
||||
"--member-id", "cli_app_123",
|
||||
"--member-type", "appid",
|
||||
"--member-role", "member",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/wiki/v2/spaces/space_42/members", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "cli_app_123", gjson.Get(result.Stdout, "api.0.body.member_id").String())
|
||||
assert.Equal(t, "appid", gjson.Get(result.Stdout, "api.0.body.member_type").String())
|
||||
assert.Equal(t, "member", gjson.Get(result.Stdout, "api.0.body.member_role").String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setWikiNodeCreateDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "wiki_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "wiki_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
// TestWikiNodeCreateDryRun pins the request shape and Validate behavior for
|
||||
// `wiki +node-create`.
|
||||
func TestWikiNodeCreateDryRun(t *testing.T) {
|
||||
setWikiNodeCreateDryRunEnv(t)
|
||||
|
||||
t.Run("HappyPath_ExplicitSpaceID", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--title", "TestDoc",
|
||||
"--obj-type", "docx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "origin", gjson.Get(result.Stdout, "api.0.body.node_type").String())
|
||||
assert.Equal(t, "docx", gjson.Get(result.Stdout, "api.0.body.obj_type").String())
|
||||
assert.Equal(t, "TestDoc", gjson.Get(result.Stdout, "api.0.body.title").String())
|
||||
})
|
||||
|
||||
t.Run("HappyPath_WithParentNodeToken", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--parent-node-token", "wikcnABC123",
|
||||
"--title", "ChildDoc",
|
||||
"--obj-type", "docx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
// 2-step: resolve parent node -> create node
|
||||
assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/wiki/v2/spaces/get_node", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.0.params.token").String())
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.1.method").String())
|
||||
assert.Equal(t, "/open-apis/wiki/v2/spaces/123456/nodes", gjson.Get(result.Stdout, "api.1.url").String())
|
||||
assert.Equal(t, "wikcnABC123", gjson.Get(result.Stdout, "api.1.body.parent_node_token").String())
|
||||
})
|
||||
|
||||
t.Run("HappyPath_ShortcutNodeType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--node-type", "shortcut",
|
||||
"--origin-node-token", "wikcnORIG",
|
||||
"--title", "ShortcutDoc",
|
||||
"--obj-type", "docx",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
assert.Equal(t, "shortcut", gjson.Get(result.Stdout, "api.0.body.node_type").String())
|
||||
assert.Equal(t, "wikcnORIG", gjson.Get(result.Stdout, "api.0.body.origin_node_token").String())
|
||||
})
|
||||
|
||||
t.Run("RejectsShortcutWithoutOriginNodeToken", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--node-type", "shortcut",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateWikiErrorMessage(result)
|
||||
assert.Contains(t, msg, "--origin-node-token is required")
|
||||
})
|
||||
|
||||
t.Run("RejectsOriginNodeTokenWithoutShortcutType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--origin-node-token", "wikcnORIG",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateWikiErrorMessage(result)
|
||||
assert.Contains(t, msg, "--origin-node-token can only be used when --node-type=shortcut")
|
||||
})
|
||||
|
||||
t.Run("RejectsBotWithoutSpaceIDOrParentNodeToken", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateWikiErrorMessage(result)
|
||||
assert.Contains(t, msg, "bot identity requires --space-id or --parent-node-token")
|
||||
})
|
||||
|
||||
t.Run("RejectsBotWithMyLibrarySpaceID", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "my_library",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateWikiErrorMessage(result)
|
||||
assert.Contains(t, msg, "bot identity does not support --space-id my_library")
|
||||
})
|
||||
|
||||
t.Run("RejectsInvalidObjType", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-create",
|
||||
"--space-id", "123456",
|
||||
"--obj-type", "pdf",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
msg := validateWikiErrorMessage(result)
|
||||
assert.Contains(t, msg, `"pdf"`)
|
||||
assert.Contains(t, msg, "invalid value")
|
||||
})
|
||||
}
|
||||
|
||||
func validateWikiErrorMessage(r *clie2e.Result) string {
|
||||
if msg := gjson.Get(r.Stdout, "error.message").String(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if msg := gjson.Get(r.Stderr, "error.message").String(); msg != "" {
|
||||
return msg
|
||||
}
|
||||
return r.Stdout + r.Stderr
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// TestWiki_ShortcutWorkflow exercises the shortcut layer (wiki +space-list,
|
||||
// +node-list, +node-copy) end-to-end against a real Lark tenant. The existing
|
||||
// TestWiki_NodeWorkflow only hits the bare `api` command, so it does not
|
||||
// protect against regressions in shortcut-specific behavior — flag → body
|
||||
// mapping, envelope shape ({spaces|nodes, has_more, page_token} + meta.count),
|
||||
// auto-pagination, my_library alias resolution, or required-flag validation.
|
||||
func TestWiki_ShortcutWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
parentTitle := "lark-cli-e2e-wiki-sc-parent-" + suffix
|
||||
childTitle := "lark-cli-e2e-wiki-sc-child-" + suffix
|
||||
copyTitle := "lark-cli-e2e-wiki-sc-copy-" + suffix
|
||||
|
||||
var spaceID, parentNodeToken, childNodeToken, childObjType, copiedNodeToken string
|
||||
|
||||
// Setup: reuse an existing first-layer node in my_library as the host so
|
||||
// we never bump the top-layer node count (the bot's my_library top layer
|
||||
// has hit the API's "single-layer nodes ... upper limit" — code 131003 —
|
||||
// in earlier CI runs because of leftover nodes). Then create a FRESH
|
||||
// intermediate parent under that host, and put the test child under the
|
||||
// fresh parent. We can't put the child directly under the host because
|
||||
// leftover nodes from prior runs accumulate as the host's children, so
|
||||
// `+node-list --parent-node-token=<host>` returns hundreds of unrelated
|
||||
// nodes and the just-created child gets paged out (regardless of
|
||||
// --page-limit) before the test can find it. An isolated intermediate
|
||||
// parent always has exactly the children this test creates, so the
|
||||
// pagination scan never has to dig through historical cruft.
|
||||
t.Run("setup: locate my_library host node + create isolated parent + create test child", func(t *testing.T) {
|
||||
host, isolatedParent := createWikiNodeUnderAnyHost(t, parentT, ctx, parentTitle)
|
||||
spaceID = host.Get("space_id").String()
|
||||
require.NotEmpty(t, spaceID, "host space_id must be present in listing")
|
||||
parentNodeToken = isolatedParent.Get("node_token").String()
|
||||
require.NotEmpty(t, parentNodeToken, "isolated parent node_token must be present after create")
|
||||
|
||||
// Create the test child UNDER the freshly-isolated parent.
|
||||
child, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": childTitle,
|
||||
"parent_node_token": parentNodeToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
childNodeToken = child.Get("node_token").String()
|
||||
childObjType = child.Get("obj_type").String()
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
})
|
||||
|
||||
// QA-P1: +space-list envelope shape is stable for JSON consumers.
|
||||
// `spaces` must always be an array (never null), and pagination metadata
|
||||
// fields must always exist so downstream agents can introspect.
|
||||
t.Run("+space-list: stable envelope shape", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+space-list", "--page-size", "1"},
|
||||
DefaultAs: "bot",
|
||||
Format: "json",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, out.Get("data.spaces").Exists(), "data.spaces must exist")
|
||||
assert.True(t, out.Get("data.spaces").IsArray(), "data.spaces must be an array, even when empty")
|
||||
assert.True(t, out.Get("data.has_more").Exists(), "data.has_more must always be present")
|
||||
assert.True(t, out.Get("data.page_token").Exists(), "data.page_token must always be present")
|
||||
// meta.count uses `json:",omitempty"` in the envelope framework, so the
|
||||
// field is dropped when the count is zero. Comparing values (gjson
|
||||
// returns 0 for missing keys) keeps the assertion correct in both the
|
||||
// "no spaces visible" and "some spaces" cases without requiring a
|
||||
// framework-level change.
|
||||
spacesLen := len(out.Get("data.spaces").Array())
|
||||
assert.Equal(t, float64(spacesLen), out.Get("meta.count").Float(),
|
||||
"meta.count must equal len(data.spaces) (or be omitted when zero); stdout:\n%s", result.Stdout)
|
||||
})
|
||||
|
||||
// QA-P1: +node-list correctly maps flags onto the underlying request body
|
||||
// and surfaces the child we just created under the parent.
|
||||
t.Run("+node-list: finds child under parent", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, parentNodeToken)
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-list",
|
||||
"--space-id", spaceID,
|
||||
"--parent-node-token", parentNodeToken,
|
||||
"--page-all",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
|
||||
match := out.Get(`data.nodes.#(node_token=="` + childNodeToken + `")`)
|
||||
require.True(t, match.Exists(), "+node-list did not return the child we created:\n%s", result.Stdout)
|
||||
assert.Equal(t, childTitle, match.Get("title").String())
|
||||
assert.Equal(t, parentNodeToken, match.Get("parent_node_token").String())
|
||||
})
|
||||
|
||||
// QA-P2: --page-size 1 --page-all --page-limit 1 must aggregate exactly
|
||||
// one page and surface the next cursor when has_more=true. This catches
|
||||
// regressions where the pagination loop overruns the cap or fails to
|
||||
// surface has_more / page_token.
|
||||
t.Run("+node-list: --page-limit caps the loop and exposes cursor", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-list",
|
||||
"--space-id", spaceID,
|
||||
"--page-size", "1",
|
||||
"--page-all",
|
||||
"--page-limit", "1",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
nodes := out.Get("data.nodes").Array()
|
||||
assert.LessOrEqual(t, len(nodes), 1, "--page-limit=1 + --page-size=1 should yield ≤1 node, got %d", len(nodes))
|
||||
// has_more / page_token must still exist — never elided — so
|
||||
// callers can resume regardless of whether the cap actually fired.
|
||||
assert.True(t, out.Get("data.has_more").Exists())
|
||||
assert.True(t, out.Get("data.page_token").Exists())
|
||||
})
|
||||
|
||||
// QA-P1: +node-copy creates a copy under the same space and the source
|
||||
// stays put (copy ≠ move). Cleanup deletes the copy. The copy is placed
|
||||
// under the same host parent we use for the test child, so it doesn't
|
||||
// add another top-layer node and trip the per-space limit.
|
||||
t.Run("+node-copy: copies child + verifies source survives + cleanup", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, parentNodeToken)
|
||||
require.NotEmpty(t, childNodeToken)
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"wiki", "+node-copy",
|
||||
"--space-id", spaceID,
|
||||
"--node-token", childNodeToken,
|
||||
"--target-parent-node-token", parentNodeToken,
|
||||
"--title", copyTitle,
|
||||
},
|
||||
// +node-copy is now declared high-risk-write to align with the
|
||||
// upstream API's `danger: true` flag, so the framework requires
|
||||
// explicit confirmation before issuing the request.
|
||||
Yes: true,
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
copiedNodeToken = out.Get("data.node_token").String()
|
||||
copiedSpaceID := out.Get("data.space_id").String()
|
||||
copiedObjType := out.Get("data.obj_type").String()
|
||||
require.NotEmpty(t, copiedNodeToken, "stdout:\n%s", result.Stdout)
|
||||
require.NotEmpty(t, copiedSpaceID)
|
||||
assert.Equal(t, copyTitle, out.Get("data.title").String())
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
|
||||
})
|
||||
|
||||
// Copy must be retrievable; source must still exist (copy ≠ move).
|
||||
copied := getWikiNode(t, ctx, copiedNodeToken)
|
||||
assert.Equal(t, copyTitle, copied.Get("title").String())
|
||||
original := getWikiNode(t, ctx, childNodeToken)
|
||||
assert.Equal(t, childTitle, original.Get("title").String(),
|
||||
"source node must remain after +node-copy (copy is non-destructive)")
|
||||
_ = childObjType // reserved for future +node-list filter checks
|
||||
})
|
||||
|
||||
// QA-P2: bot identity must be rejected upfront when --space-id=my_library
|
||||
// because the personal-library alias is per-user and meaningless for a
|
||||
// tenant_access_token. The shortcut layer should fail before sending any
|
||||
// HTTP request, with a validation error mentioning my_library.
|
||||
t.Run("+node-list --space-id my_library --as bot: validation rejection", func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+node-list", "--space-id", "my_library"},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, 0, result.ExitCode, "bot + my_library must fail")
|
||||
|
||||
combined := strings.ToLower(result.Stdout + "\n" + result.Stderr)
|
||||
assert.Contains(t, combined, "my_library",
|
||||
"error must mention my_library to disambiguate from generic auth failures; got stdout=%s stderr=%s",
|
||||
result.Stdout, result.Stderr)
|
||||
})
|
||||
|
||||
// QA-P2: user identity must positively resolve --space-id=my_library to a
|
||||
// real per-user space_id and proceed to list nodes. Skipped when no user
|
||||
// token is available (matches the rest of the suite's user-flow gating).
|
||||
t.Run("+node-list --space-id my_library --as user: resolves and lists", func(t *testing.T) {
|
||||
clie2e.SkipWithoutUserToken(t)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"wiki", "+node-list", "--space-id", "my_library", "--page-size", "1"},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
out := gjson.Parse(result.Stdout)
|
||||
require.True(t, out.Get("ok").Bool(), "stdout:\n%s", result.Stdout)
|
||||
assert.True(t, out.Get("data.nodes").Exists(), "data.nodes must exist after my_library resolution")
|
||||
assert.True(t, out.Get("data.nodes").IsArray(), "data.nodes must be an array")
|
||||
// stderr must record the my_library resolution so users/agents can
|
||||
// see what space_id the alias mapped to.
|
||||
assert.Contains(t, result.Stderr, "Resolved my_library",
|
||||
"expected my_library resolution log on stderr; got: %s", result.Stderr)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestWiki_NodeWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
parentTitle := "lark-cli-e2e-wiki-parent-" + suffix
|
||||
createdTitle := "lark-cli-e2e-wiki-create-" + suffix
|
||||
copiedTitle := "lark-cli-e2e-wiki-copy-" + suffix
|
||||
|
||||
var spaceID string
|
||||
var hostNodeToken string
|
||||
var parentNodeToken string
|
||||
var createdNodeToken string
|
||||
var createdObjToken string
|
||||
var copiedNodeToken string
|
||||
var copiedSpaceID string
|
||||
|
||||
t.Run("create isolated parent node under host as bot", func(t *testing.T) {
|
||||
host, parentNode := createWikiNodeUnderAnyHost(t, parentT, ctx, parentTitle)
|
||||
spaceID = parentNode.Get("space_id").String()
|
||||
hostNodeToken = host.Get("node_token").String()
|
||||
parentNodeToken = parentNode.Get("node_token").String()
|
||||
require.NotEmpty(t, spaceID)
|
||||
require.NotEmpty(t, hostNodeToken)
|
||||
require.NotEmpty(t, parentNodeToken)
|
||||
assert.Equal(t, parentTitle, parentNode.Get("title").String())
|
||||
})
|
||||
|
||||
t.Run("create node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, parentNodeToken, "parent node token should be created before child node")
|
||||
|
||||
node, result, err := createWikiNode(t, parentT, ctx, spaceID, map[string]any{
|
||||
"node_type": "origin",
|
||||
"obj_type": "docx",
|
||||
"title": createdTitle,
|
||||
"parent_node_token": parentNodeToken,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
createdNodeToken = node.Get("node_token").String()
|
||||
createdObjToken = node.Get("obj_token").String()
|
||||
require.NotEmpty(t, createdNodeToken)
|
||||
require.NotEmpty(t, createdObjToken)
|
||||
assert.Equal(t, createdTitle, node.Get("title").String())
|
||||
assert.Equal(t, "origin", node.Get("node_type").String())
|
||||
assert.Equal(t, "docx", node.Get("obj_type").String())
|
||||
assert.Equal(t, parentNodeToken, node.Get("parent_node_token").String())
|
||||
})
|
||||
|
||||
t.Run("get created node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be created before get_node")
|
||||
node := getWikiNode(t, ctx, createdNodeToken)
|
||||
assert.Equal(t, createdNodeToken, node.Get("node_token").String())
|
||||
assert.Equal(t, createdObjToken, node.Get("obj_token").String())
|
||||
assert.Equal(t, createdTitle, node.Get("title").String())
|
||||
assert.Equal(t, spaceID, node.Get("space_id").String())
|
||||
})
|
||||
|
||||
t.Run("get isolated parent node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, parentNodeToken, "parent node token should be created before get_node")
|
||||
node := getWikiNode(t, ctx, parentNodeToken)
|
||||
assert.Equal(t, parentNodeToken, node.Get("node_token").String())
|
||||
assert.Equal(t, parentTitle, node.Get("title").String())
|
||||
})
|
||||
|
||||
t.Run("get space as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before get")
|
||||
space := getWikiSpace(t, ctx, spaceID)
|
||||
assert.Equal(t, spaceID, space.Get("space_id").String())
|
||||
assert.NotEmpty(t, space.Get("name").String())
|
||||
})
|
||||
|
||||
t.Run("list spaces as bot", func(t *testing.T) {
|
||||
result := listWikiSpaces(t, ctx, 1)
|
||||
assert.True(t, result.Get("data.items").Exists(), "stdout:\n%s", result.Raw)
|
||||
})
|
||||
|
||||
t.Run("list nodes and find isolated parent node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before list")
|
||||
require.NotEmpty(t, hostNodeToken, "host node token should be available before list")
|
||||
require.NotEmpty(t, parentNodeToken, "parent node token should be available before list")
|
||||
|
||||
nodeItem := findWikiNodeByToken(t, ctx, spaceID, parentNodeToken, hostNodeToken)
|
||||
assert.Equal(t, parentTitle, nodeItem.Get("title").String())
|
||||
})
|
||||
|
||||
t.Run("copy node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, spaceID, "space ID should be available before copy")
|
||||
require.NotEmpty(t, createdNodeToken, "node token should be available before copy")
|
||||
|
||||
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
|
||||
Args: []string{"api", "post", "/open-apis/wiki/v2/spaces/" + spaceID + "/nodes/" + createdNodeToken + "/copy"},
|
||||
DefaultAs: "bot",
|
||||
Data: map[string]any{
|
||||
"target_space_id": spaceID,
|
||||
"target_parent_token": parentNodeToken,
|
||||
"title": copiedTitle,
|
||||
},
|
||||
}, clie2e.RetryOptions{})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
result.AssertStdoutStatus(t, true)
|
||||
|
||||
copiedNodeToken = gjson.Get(result.Stdout, "data.node.node_token").String()
|
||||
copiedSpaceID = gjson.Get(result.Stdout, "data.node.space_id").String()
|
||||
copiedObjType := gjson.Get(result.Stdout, "data.node.obj_type").String()
|
||||
require.NotEmpty(t, copiedNodeToken)
|
||||
require.NotEmpty(t, copiedSpaceID)
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cancel := clie2e.CleanupContext()
|
||||
defer cancel()
|
||||
|
||||
deleteResult, deleteErr := deleteWikiNodeAndVerify(cleanupCtx, copiedSpaceID, copiedNodeToken, copiedObjType)
|
||||
clie2e.ReportCleanupFailure(parentT, "delete copied wiki node "+copiedNodeToken, deleteResult, deleteErr)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("get copied node as bot", func(t *testing.T) {
|
||||
require.NotEmpty(t, copiedNodeToken, "copied node token should be available before verification")
|
||||
node := getWikiNode(t, ctx, copiedNodeToken)
|
||||
assert.Equal(t, copiedTitle, node.Get("title").String())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user