Files
larksuite-cli/shortcuts/wiki/wiki_node_delete_test.go
evandance 154ecdb90f feat(wiki): emit typed error envelopes across the wiki domain (#1350)
Emit structured validation, API, network, file, and internal error envelopes for Wiki shortcuts so users and agents can recover from failed wiki workflows using stable type, subtype, param, and code fields.

Add Wiki domain errscontract and golangci guards to prevent legacy envelope and common helper regressions.
2026-06-11 14:02:29 +08:00

604 lines
19 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"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 := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeApprovalRequired), "msg": "node requires delete approval"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(p.Hint, "delete-approval enabled") || !strings.Contains(p.Hint, "Wiki UI") {
t.Fatalf("hint = %q, want approval guidance", p.Hint)
}
// Original code/message must be preserved so logs and dashboards still
// pivot on the upstream error code.
if p.Code != wikiDeleteNodeErrCodeApprovalRequired {
t.Fatalf("hint wrapper lost the original code: %d", p.Code)
}
if p.Message != "node requires delete approval" {
t.Fatalf("message changed unexpectedly: %q", p.Message)
}
}
func TestWrapWikiNodeDeleteAPIErrorAddsSubtreeHint(t *testing.T) {
t.Parallel()
in := errclass.BuildAPIError(
map[string]any{"code": float64(wikiDeleteNodeErrCodeSubtreeTooLarge), "msg": "subtree too large"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("expected a typed errs.* error, got %T %v", got, got)
}
if !strings.Contains(p.Hint, "--include-children=false") {
t.Fatalf("hint = %q, want subtree-too-large guidance", p.Hint)
}
}
func TestWrapWikiNodeDeleteAPIErrorPassesThroughUnknownCodes(t *testing.T) {
t.Parallel()
in := errclass.BuildAPIError(
map[string]any{"code": float64(131005), "msg": "node not found"},
errclass.ClassifyContext{},
)
got := wrapWikiNodeDeleteAPIError(in)
if got != in {
t.Fatalf("unknown code should pass through unchanged; 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
}