Files
larksuite-cli/shortcuts/wiki/wiki_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

424 lines
14 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"errors"
"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/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeWikiDeleteSpaceClient struct {
deleteResp *wikiDeleteSpaceResponse
deleteErr error
taskStatuses []wikiDeleteSpaceTaskStatus
taskErrs []error
deleteCalls []string
taskCallArgs []string
}
func (fake *fakeWikiDeleteSpaceClient) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
fake.deleteCalls = append(fake.deleteCalls, spaceID)
if fake.deleteErr != nil {
return nil, fake.deleteErr
}
if fake.deleteResp != nil {
return fake.deleteResp, nil
}
return &wikiDeleteSpaceResponse{}, nil
}
func (fake *fakeWikiDeleteSpaceClient) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
idx := len(fake.taskCallArgs)
fake.taskCallArgs = append(fake.taskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiDeleteSpaceTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiDeleteSpaceTaskStatus{TaskID: taskID}, nil
}
var wikiDeleteSpacePollMu sync.Mutex
func withSingleWikiDeleteSpacePoll(t *testing.T) {
t.Helper()
wikiDeleteSpacePollMu.Lock()
prevAttempts, prevInterval := wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval = 1, 0
t.Cleanup(func() {
wikiDeleteSpacePollAttempts, wikiDeleteSpacePollInterval = prevAttempts, prevInterval
wikiDeleteSpacePollMu.Unlock()
})
}
func newWikiDeleteSpaceRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +delete-space"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func TestValidateWikiDeleteSpaceSpecRequiresSpaceID(t *testing.T) {
t.Parallel()
if err := validateWikiDeleteSpaceSpec(wikiDeleteSpaceSpec{}); err == nil || !strings.Contains(err.Error(), "--space-id is required") {
t.Fatalf("expected missing space-id error, got %v", err)
}
if err := validateWikiDeleteSpaceSpec(wikiDeleteSpaceSpec{SpaceID: "7629741305993170448"}); err != nil {
t.Fatalf("validateWikiDeleteSpaceSpec(valid) = %v, want nil", err)
}
}
func TestWikiDeleteSpaceDeclaredScopes(t *testing.T) {
t.Parallel()
want := []string{"wiki:space:write_only", "wiki:space:read"}
if !reflect.DeepEqual(WikiDeleteSpace.Scopes, want) {
t.Fatalf("WikiDeleteSpace.Scopes = %v, want %v", WikiDeleteSpace.Scopes, want)
}
}
func TestWikiDeleteSpaceTaskStatusClassification(t *testing.T) {
t.Parallel()
pending := wikiDeleteSpaceTaskStatus{}
if pending.Ready() || pending.Failed() || pending.StatusLabel() != wikiDeleteSpaceStatusProcessing {
t.Fatalf("pending = %+v", pending)
}
success := wikiDeleteSpaceTaskStatus{Status: "success"}
if !success.Ready() || success.Failed() || success.StatusLabel() != "success" {
t.Fatalf("success = %+v", success)
}
failed := wikiDeleteSpaceTaskStatus{Status: "failure", StatusMsg: "permission denied"}
if failed.Ready() || !failed.Failed() || failed.StatusLabel() != "permission denied" {
t.Fatalf("failed = %+v", failed)
}
// Unknown non-success statuses must not be misreported as hard failures.
unknown := wikiDeleteSpaceTaskStatus{Status: "running"}
if unknown.Ready() || unknown.Failed() || unknown.StatusLabel() != "running" {
t.Fatalf("unknown = %+v", unknown)
}
// Whitespace + mixed case must normalize consistently across Ready /
// Failed / StatusCode — otherwise `" SUCCESS "` would be neither ready
// nor failed and polling would loop to timeout on a terminal success.
noisy := wikiDeleteSpaceTaskStatus{Status: " SUCCESS "}
if !noisy.Ready() || noisy.Failed() {
t.Fatalf("noisy success classification = %+v", noisy)
}
// StatusCode must never be empty so the output envelope's `status` field
// can't surprise users with "" on a timeout branch.
if got := (wikiDeleteSpaceTaskStatus{}).StatusCode(); got != wikiDeleteSpaceStatusProcessing {
t.Fatalf("empty StatusCode = %q, want %q", got, wikiDeleteSpaceStatusProcessing)
}
}
func TestWikiDeleteSpaceDryRunIncludesTaskPoll(t *testing.T) {
t.Parallel()
dry := buildWikiDeleteSpaceDryRun(wikiDeleteSpaceSpec{SpaceID: "space_123"})
if dry == nil {
t.Fatal("buildWikiDeleteSpaceDryRun returned nil")
}
formatted := dry.Format()
if !strings.Contains(formatted, "DELETE /open-apis/wiki/v2/spaces/space_123") {
t.Fatalf("dry run missing DELETE line: %s", formatted)
}
if !strings.Contains(formatted, "task_type") || !strings.Contains(formatted, "delete_space") {
t.Fatalf("dry run missing task_type=delete_space: %s", formatted)
}
}
func TestRunWikiDeleteSpaceSync(t *testing.T) {
t.Parallel()
runtime, _ := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiDeleteSpaceClient{
deleteResp: &wikiDeleteSpaceResponse{},
}
out, err := runWikiDeleteSpace(context.Background(), client, runtime, wikiDeleteSpaceSpec{SpaceID: "space_123"})
if err != nil {
t.Fatalf("runWikiDeleteSpace() error = %v", err)
}
if out["ready"] != true || out["failed"] != false || out["space_id"] != "space_123" {
t.Fatalf("unexpected sync output: %#v", out)
}
// Sync envelope must mirror the async success shape so downstream scripts
// can read `status` uniformly regardless of which branch fired.
if out["status"] != "success" || out["status_msg"] != "success" {
t.Fatalf("sync status fields = %#v / %#v, want both success", out["status"], out["status_msg"])
}
if _, ok := out["task_id"]; ok {
t.Fatalf("sync output should not include task_id, got %#v", out)
}
if len(client.taskCallArgs) != 0 {
t.Fatalf("sync path should not poll, got calls %v", client.taskCallArgs)
}
}
func TestRunWikiDeleteSpaceAsyncReady(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiDeleteSpaceClient{
deleteResp: &wikiDeleteSpaceResponse{TaskID: "task_123"},
taskStatuses: []wikiDeleteSpaceTaskStatus{{
Status: "success",
}},
}
out, err := runWikiDeleteSpace(context.Background(), client, runtime, wikiDeleteSpaceSpec{SpaceID: "space_123"})
if err != nil {
t.Fatalf("runWikiDeleteSpace() error = %v", err)
}
if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false || out["status"] != "success" {
t.Fatalf("unexpected async-ready output: %#v", out)
}
if !strings.Contains(stderr.String(), "async, polling task") || !strings.Contains(stderr.String(), "completed successfully") {
t.Fatalf("stderr = %q, want async progress logs", stderr.String())
}
}
func TestRunWikiDeleteSpaceAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiDeleteSpaceClient{
deleteResp: &wikiDeleteSpaceResponse{TaskID: "task_123"},
taskStatuses: []wikiDeleteSpaceTaskStatus{{
Status: "processing",
}},
}
out, err := runWikiDeleteSpace(context.Background(), client, runtime, wikiDeleteSpaceSpec{SpaceID: "space_123"})
if err != nil {
t.Fatalf("runWikiDeleteSpace() error = %v", err)
}
wantNext := wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wantNext {
t.Fatalf("expected timeout response, got %#v", out)
}
// Both `status` and `status_msg` must surface a human-readable value on
// timeout — never "" — otherwise downstream scripts parsing the envelope
// will disagree with the reference doc example.
if out["status"] != "processing" || out["status_msg"] != "processing" {
t.Fatalf("status fields = %#v / %#v, want both processing", out["status"], out["status_msg"])
}
if !strings.Contains(stderr.String(), "Continue with") {
t.Fatalf("stderr = %q, want continuation hint", stderr.String())
}
}
func TestRunWikiDeleteSpaceAsyncFailure(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, _ := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiDeleteSpaceClient{
deleteResp: &wikiDeleteSpaceResponse{TaskID: "task_123"},
taskStatuses: []wikiDeleteSpaceTaskStatus{{
Status: "failure",
StatusMsg: "permission denied",
}},
}
_, err := runWikiDeleteSpace(context.Background(), client, runtime, wikiDeleteSpaceSpec{SpaceID: "space_123"})
// The error surface must carry both the task_id (for post-mortem) and the
// backend-reported failure reason.
if err == nil || !strings.Contains(err.Error(), "wiki delete-space task task_123 failed: permission denied") {
t.Fatalf("expected async failure error, got %v", err)
}
}
func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
// Seed an error that carries an upstream Lark Detail.Code so the test
// pins that structured fields survive a fully failed poll (not just the
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
client := &fakeWikiDeleteSpaceClient{
taskErrs: []error{&output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "api_error",
Code: 131006,
Message: "poll failed",
Hint: "retry original",
},
}},
}
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
if err == nil {
t.Fatal("expected pollWikiDeleteSpaceTask() error, got nil")
}
if ready {
t.Fatal("expected ready=false when every poll fails")
}
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
}
if exitErr.Detail.Code != 131006 {
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
}
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
}
}
func TestParseWikiDeleteSpaceTaskStatusFallbackTaskID(t *testing.T) {
t.Parallel()
status, err := parseWikiDeleteSpaceTaskStatus("task_fallback", map[string]interface{}{
"delete_space_result": map[string]interface{}{
"status": "success",
},
})
if err != nil {
t.Fatalf("parseWikiDeleteSpaceTaskStatus() error = %v", err)
}
if status.TaskID != "task_fallback" {
t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback")
}
if !status.Ready() || status.StatusLabel() != "success" {
t.Fatalf("unexpected parsed status: %+v", status)
}
}
func TestParseWikiDeleteSpaceTaskStatusRejectsMissingTask(t *testing.T) {
t.Parallel()
_, err := parseWikiDeleteSpaceTaskStatus("task_123", nil)
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
}
func TestWikiDeleteSpaceExecuteSync(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task_id": "",
},
},
}
reg.Register(deleteStub)
err := mountAndRunWiki(t, WikiDeleteSpace, []string{
"+delete-space",
"--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("unexpected sync execute output: %#v", data)
}
}
func TestWikiDeleteSpaceExecuteRequiresYesConfirmation(t *testing.T) {
factory, stdout, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiDeleteSpace, []string{
"+delete-space",
"--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 TestWikiDeleteSpaceExecuteAsyncSuccess(t *testing.T) {
withSingleWikiDeleteSpacePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task_id": "task_async_1",
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_async_1",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"delete_space_result": map[string]interface{}{
"status": "success",
},
},
},
},
})
err := mountAndRunWiki(t, WikiDeleteSpace, []string{
"+delete-space",
"--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_1" || data["ready"] != true || data["failed"] != false {
t.Fatalf("unexpected async execute output: %#v", data)
}
}