Files
larksuite-cli/shortcuts/wiki/wiki_node_delete_test.go
liujinkun2025 c4fb7006d2 feat(wiki): add +node-get / +node-delete / +space-create shortcuts (#904)
- +node-get: wrap wiki.spaces.get_node; accepts node_token, obj_token,
  or a Lark URL (URL path auto-infers obj_type); formatted output with
  creator / updated_at. No synthesized url — get_node returns none and a
  BuildResourceURL fallback is a non-canonical link that misleads in a
  read/confirm command (sibling read shortcuts omit it too)
- +node-delete: wrap space.node delete; high-risk-write (--yes gated),
  async delete-node task polling, auto-resolves space_id via get_node
  when --space-id omitted, actionable hints for codes 131011 / 131003.
  The delete-node task result lives under the gateway's generic
  `simple_task_result` key (NOT `delete_node_result`)
- +space-create: wrap spaces.create; user-only identity, --name
  required (no empty-name spaces), flattened space output, no url
- factor the shared wiki async-task poll loop into wiki_async_task.go;
  preserve upstream Lark Detail.Code on poll exhaustion (no longer
  rebuilt via lossy ErrWithHint)
- drive +task_result: add wiki_delete_node scenario so +node-delete's
  async-timeout next_command actually resolves
- skill docs: reference pages for the 3 new shortcuts + SKILL.md
  shortcuts table (no raw nodes.delete API exists — it's shortcut-only,
  so it is intentionally absent from API Resources / permission table);
  drop the circular TestWikiShortcutsIncludeAllCommands change-detector

Change-Id: I316f78290cec5bc50f80d629173e3bf2a35dd005
2026-05-19 11:21:54 +08:00

612 lines
20 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// ── parseWikiNodeDeleteSpec ─────────────────────────────────────────────────
func TestParseWikiNodeDeleteSpecAcceptsRawWikiToken(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "raw" || !spec.IncludeChildren {
t.Fatalf("spec = %+v", spec)
}
body := spec.RequestBody()
if body["obj_type"] != "wiki" || body["include_children"] != true {
t.Fatalf("RequestBody = %#v", body)
}
}
func TestParseWikiNodeDeleteSpecRejectsMissingObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "", "", true)
if err == nil || !strings.Contains(err.Error(), "--obj-type is required") {
t.Fatalf("expected obj-type required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsInvalidObjType(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("wikcnABC", "comment", "", true)
if err == nil || !strings.Contains(err.Error(), "is not valid") {
t.Fatalf("expected invalid obj-type error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsEmptyToken(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec(" ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "--node-token is required") {
t.Fatalf("expected token required error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecExtractsTokenFromWikiURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/wiki/wikcnABC", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "wikcnABC" || spec.ObjType != "wiki" || spec.SourceKind != "url" {
t.Fatalf("spec = %+v, want url-extracted node_token + obj_type=wiki", spec)
}
}
func TestParseWikiNodeDeleteSpecInfersObjTypeFromDocxURL(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
if spec.NodeToken != "docxXYZ" || spec.ObjType != "docx" || spec.IncludeChildren {
t.Fatalf("spec = %+v, want docxXYZ obj_type=docx include_children=false", spec)
}
}
func TestParseWikiNodeDeleteSpecRejectsURLObjTypeMismatch(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "does not match the obj_type") {
t.Fatalf("expected obj-type mismatch error, got %v", err)
}
}
func TestParseWikiNodeDeleteSpecRejectsPartialPath(t *testing.T) {
t.Parallel()
_, err := parseWikiNodeDeleteSpec("/wiki/wikcnABC", "wiki", "", true)
if err == nil || !strings.Contains(err.Error(), "partial paths are not accepted") {
t.Fatalf("expected partial-path rejection, got %v", err)
}
}
// ── DryRun ──────────────────────────────────────────────────────────────────
func TestBuildWikiNodeDeleteDryRunWithoutSpaceIDShowsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("https://feishu.cn/docx/docxXYZ", "", "", true)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 3 {
t.Fatalf("len(dry.api) = %d, want 3 (get_node, delete, task poll)", len(got))
}
if got[0].URL != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("step[0].URL = %q, want get_node", got[0].URL)
}
if got[0].Params["obj_type"] != "docx" || got[0].Params["token"] != "docxXYZ" {
t.Fatalf("step[0].params = %#v", got[0].Params)
}
if got[1].URL != "/open-apis/wiki/v2/spaces/<resolved_space_id>/nodes/docxXYZ" {
t.Fatalf("step[1].URL = %q, want delete with placeholder", got[1].URL)
}
if got[1].Body["obj_type"] != "docx" || got[1].Body["include_children"] != true {
t.Fatalf("step[1].body = %#v", got[1].Body)
}
if got[2].Params["task_type"] != "delete_node" {
t.Fatalf("step[2].params task_type = %#v, want delete_node", got[2].Params)
}
}
func TestBuildWikiNodeDeleteDryRunWithSpaceIDOmitsResolve(t *testing.T) {
t.Parallel()
spec, err := parseWikiNodeDeleteSpec("wikcnABC", "wiki", "7629741305993170448", false)
if err != nil {
t.Fatalf("parseWikiNodeDeleteSpec() error = %v", err)
}
dry := buildWikiNodeDeleteDryRun(spec)
got := decodeDryRunAPIs(t, dry)
if len(got) != 2 {
t.Fatalf("len(dry.api) = %d, want 2 (delete + task poll) when --space-id supplied", len(got))
}
if got[0].Method != "DELETE" || got[0].URL != "/open-apis/wiki/v2/spaces/7629741305993170448/nodes/wikcnABC" {
t.Fatalf("step[0] = %+v", got[0])
}
if got[0].Body["include_children"] != false {
t.Fatalf("body include_children = %#v", got[0].Body["include_children"])
}
}
// ── runWikiNodeDelete unit ──────────────────────────────────────────────────
type fakeWikiNodeDeleteClient struct {
resolveErr error
resolveNode *wikiNodeRecord
resolveCalls []string
deleteErr error
deleteTaskID string
deleteCalls []struct {
SpaceID string
Spec wikiNodeDeleteSpec
}
taskStatuses []wikiAsyncTaskStatus
taskErrs []error
taskCallArgs []string
}
func (fake *fakeWikiNodeDeleteClient) ResolveNode(ctx context.Context, token, objType string) (*wikiNodeRecord, error) {
fake.resolveCalls = append(fake.resolveCalls, token)
if fake.resolveErr != nil {
return nil, fake.resolveErr
}
return fake.resolveNode, nil
}
func (fake *fakeWikiNodeDeleteClient) DeleteNode(ctx context.Context, spaceID string, spec wikiNodeDeleteSpec) (string, error) {
fake.deleteCalls = append(fake.deleteCalls, struct {
SpaceID string
Spec wikiNodeDeleteSpec
}{SpaceID: spaceID, Spec: spec})
if fake.deleteErr != nil {
return "", fake.deleteErr
}
return fake.deleteTaskID, nil
}
func (fake *fakeWikiNodeDeleteClient) GetDeleteNodeTask(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error) {
idx := len(fake.taskCallArgs)
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiAsyncTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiAsyncTaskStatus{TaskID: taskID}, nil
}
var wikiDeleteNodePollMu sync.Mutex
func withSingleWikiDeleteNodePoll(t *testing.T) {
t.Helper()
wikiDeleteNodePollMu.Lock()
prevAttempts, prevInterval := wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = 1, 0
t.Cleanup(func() {
wikiDeleteNodePollAttempts, wikiDeleteNodePollInterval = prevAttempts, prevInterval
wikiDeleteNodePollMu.Unlock()
})
}
func newWikiNodeDeleteRuntime(t *testing.T, as core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +node-delete"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func TestRunWikiNodeDeleteResolvesSpaceWhenMissing(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
resolveNode: &wikiNodeRecord{SpaceID: "space_resolved"},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC",
ObjType: "wiki",
IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 1 || client.resolveCalls[0] != "wikcnABC" {
t.Fatalf("resolve calls = %v", client.resolveCalls)
}
if len(client.deleteCalls) != 1 || client.deleteCalls[0].SpaceID != "space_resolved" {
t.Fatalf("delete calls = %+v", client.deleteCalls)
}
if out["space_id"] != "space_resolved" || out["ready"] != true || out["status"] != "success" {
t.Fatalf("sync output = %#v", out)
}
}
func TestRunWikiNodeDeleteSkipsResolveWhenSpaceProvided(t *testing.T) {
t.Parallel()
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_explicit",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if len(client.resolveCalls) != 0 {
t.Fatalf("resolveCalls should be empty when --space-id supplied, got %v", client.resolveCalls)
}
if client.deleteCalls[0].SpaceID != "space_explicit" {
t.Fatalf("delete used wrong space: %+v", client.deleteCalls)
}
}
func TestRunWikiNodeDeleteAsyncReadyShape(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "success"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123", IncludeChildren: true,
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
if out["task_id"] != "task_async_node" || out["ready"] != true || out["failed"] != false {
t.Fatalf("async-ready output = %#v", out)
}
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "delete-node task completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestRunWikiNodeDeleteAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "processing"}},
}
out, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err != nil {
t.Fatalf("runWikiNodeDelete() error = %v", err)
}
wantNext := wikiDeleteNodeTaskResultCommand("task_async_node", core.AsUser)
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
t.Fatalf("timeout output = %#v", out)
}
if !strings.Contains(wantNext, "wiki_delete_node") {
t.Fatalf("next command should scope wiki_delete_node, got %q", wantNext)
}
}
func TestRunWikiNodeDeleteAsyncFailureSurfacesReason(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
client := &fakeWikiNodeDeleteClient{
deleteTaskID: "task_async_node",
taskStatuses: []wikiAsyncTaskStatus{{Status: "failure", StatusMsg: "permission denied"}},
}
_, err := runWikiNodeDelete(context.Background(), client, runtime, wikiNodeDeleteSpec{
NodeToken: "wikcnABC", ObjType: "wiki", SpaceID: "space_123",
})
if err == nil || !strings.Contains(err.Error(), "delete-node task task_async_node failed: permission denied") {
t.Fatalf("expected async failure error, got %v", err)
}
}
// ── error code hint mapping ─────────────────────────────────────────────────
func TestWrapWikiNodeDeleteAPIErrorAddsApprovalHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeApprovalRequired,
Message: "node requires delete approval",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "delete-approval enabled") || !strings.Contains(exitErr.Detail.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", exitErr.Detail.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if exitErr.Detail.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", exitErr.Detail.Code)
}
if exitErr.Detail.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", exitErr.Detail.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: wikiDeleteNodeErrCodeSubtreeTooLarge,
Message: "subtree too large",
},
}
got := wrapWikiNodeDeleteAPIError(in)
var exitErr *output.ExitError
if !errors.As(got, &exitErr) {
t.Fatalf("expected ExitError, got %T %v", got, got)
}
if !strings.Contains(exitErr.Detail.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", exitErr.Detail.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{Type: "api_error", Code: 131005, Message: "node not found"},
}
got := wrapWikiNodeDeleteAPIError(in)
if !reflect.DeepEqual(got, in) {
t.Fatalf("unknown code should pass through; got %#v", got)
}
}
func TestWrapWikiNodeDeleteAPIErrorIgnoresNonExit(t *testing.T) {
t.Parallel()
in := errors.New("transport boom")
if got := wrapWikiNodeDeleteAPIError(in); got != in {
t.Fatalf("non-ExitError should pass through, got %T %v", got, got)
}
if got := wrapWikiNodeDeleteAPIError(nil); got != nil {
t.Fatalf("nil should pass through, got %v", got)
}
}
// ── Mounted execute (httpmock) ──────────────────────────────────────────────
func TestWikiNodeDeleteExecuteRequiresYesConfirmation(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--as", "user",
}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("expected high-risk confirmation error, got %v", err)
}
}
func TestWikiNodeDeleteExecuteSync(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
"msg": "success",
},
}
reg.Register(deleteStub)
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["ready"] != true || data["failed"] != false || data["space_id"] != "space_123" {
t.Fatalf("sync output = %#v", data)
}
if data["obj_type"] != "wiki" || data["include_children"] != true {
t.Fatalf("obj_type/include_children = %#v / %#v", data["obj_type"], data["include_children"])
}
var captured map[string]interface{}
if err := json.Unmarshal(deleteStub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["obj_type"] != "wiki" || captured["include_children"] != true {
t.Fatalf("captured DELETE body = %#v", captured)
}
}
func TestWikiNodeDeleteExecuteResolvesSpaceIDFromURL(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
resolveStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_resolved",
"node_token": "wikcnABC",
"obj_token": "docxXYZ",
"obj_type": "docx",
},
},
},
}
var resolveQuery string
resolveStub.OnMatch = func(req *http.Request) { resolveQuery = req.URL.RawQuery }
reg.Register(resolveStub)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_resolved/nodes/docxXYZ",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "https://feishu.cn/docx/docxXYZ",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
if !strings.Contains(resolveQuery, "token=docxXYZ") || !strings.Contains(resolveQuery, "obj_type=docx") {
t.Fatalf("resolve query = %q, want token+obj_type", resolveQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_resolved" || data["obj_type"] != "docx" {
t.Fatalf("output = %#v", data)
}
}
func TestWikiNodeDeleteExecuteAsyncSuccess(t *testing.T) {
withSingleWikiDeleteNodePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123/nodes/wikcnABC",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_async_node"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_async_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// Gateway returns delete-node status under the generic
// simple_task_result key (NOT delete_node_result).
"simple_task_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunWiki(t, WikiNodeDelete, []string{
"+node-delete",
"--node-token", "wikcnABC",
"--obj-type", "wiki",
"--space-id", "space_123",
"--yes",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["task_id"] != "task_async_node" || data["ready"] != true || data["failed"] != false {
t.Fatalf("async-success output = %#v", data)
}
}
// ── helpers ─────────────────────────────────────────────────────────────────
type dryRunStep struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
}
func decodeDryRunAPIs(t *testing.T, dry *common.DryRunAPI) []dryRunStep {
t.Helper()
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []dryRunStep `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return got.API
}