mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
- +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
424 lines
14 KiB
Go
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)
|
|
}
|
|
}
|