mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
448 lines
14 KiB
Go
448 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package wiki
|
|
|
|
import (
|
|
"context"
|
|
"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, 0)
|
|
|
|
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, 0)
|
|
|
|
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, 0)
|
|
|
|
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, 0)
|
|
|
|
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, 0)
|
|
return gjson.Parse(result.Stdout)
|
|
}
|
|
|
|
type wikiNodeInfo struct {
|
|
NodeToken string
|
|
ObjType string
|
|
}
|
|
|
|
// 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 {
|
|
deadline := time.NewTimer(20 * time.Second)
|
|
defer deadline.Stop()
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
deleted, err := isWikiNodeDeleted(ctx, nodeToken)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if deleted {
|
|
return nil
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-deadline.C:
|
|
return fmt.Errorf("wiki node %s still exists after delete", nodeToken)
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
return false, fmt.Errorf("verify wiki node %s after delete: exit=%d stdout=%s stderr=%s", nodeToken, result.ExitCode, result.Stdout, result.Stderr)
|
|
}
|
|
|
|
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 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, 0)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|