Skip to content

Commit c2ca5d8

Browse files
committed
feat(wiki): emit typed error envelopes across the wiki domain
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.
1 parent 8e667db commit c2ca5d8

24 files changed

Lines changed: 416 additions & 310 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
76+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
83+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper enforced on domains whose shared validation/save
8888
# helpers have migrated to typed final errors.
89-
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/)
89+
- path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

lint/errscontract/rule_no_legacy_common_helper_call.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ var migratedCommonHelperPaths = []string{
3333
"shortcuts/task/",
3434
"shortcuts/vc/",
3535
"shortcuts/whiteboard/",
36+
"shortcuts/wiki/",
3637
}
3738

3839
const commonImportPath = "github.com/larksuite/cli/shortcuts/common"

lint/errscontract/rule_no_legacy_envelope_literal.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ var migratedEnvelopePaths = []string{
3434
"shortcuts/task/",
3535
"shortcuts/vc/",
3636
"shortcuts/whiteboard/",
37+
"shortcuts/wiki/",
3738
"shortcuts/im/",
3839
}
3940

lint/errscontract/rules_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -960,6 +960,7 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes
960960
"shortcuts/slides/slides_create.go",
961961
"shortcuts/task/task_update.go",
962962
"shortcuts/whiteboard/whiteboard_query.go",
963+
"shortcuts/wiki/wiki_node_get.go",
963964
}
964965
for _, path := range paths {
965966
for _, helper := range helpers {
@@ -1076,6 +1077,23 @@ func boom() {
10761077
}
10771078
}
10781079

1080+
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
1081+
src := `package migrated
1082+
1083+
import c "github.com/larksuite/cli/shortcuts/common"
1084+
1085+
func boom() {
1086+
f := c.FlagErrorf
1087+
_ = f
1088+
c.WrapInputStatError(nil)
1089+
}
1090+
`
1091+
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
1092+
if len(v) != 2 {
1093+
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
1094+
}
1095+
}
1096+
10791097
func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) {
10801098
src := `package contact
10811099

shortcuts/wiki/wiki_async_task.go

Lines changed: 14 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,11 @@ package wiki
55

66
import (
77
"context"
8-
"errors"
98
"fmt"
109
"strings"
1110
"time"
1211

13-
"github.com/larksuite/cli/internal/output"
12+
"github.com/larksuite/cli/errs"
1413
"github.com/larksuite/cli/shortcuts/common"
1514
)
1615

@@ -95,15 +94,15 @@ func (s wikiAsyncTaskStatus) StatusLabel() string {
9594
}
9695

9796
// wikiAsyncTaskFetcher returns the latest status for taskID. Implementations
98-
// translate from runtime.CallAPI responses or test fakes.
97+
// translate from runtime.CallAPITyped responses or test fakes.
9998
type wikiAsyncTaskFetcher func(ctx context.Context, taskID string) (wikiAsyncTaskStatus, error)
10099

101100
// parseWikiAsyncTaskStatus normalizes an /wiki/v2/tasks/{task_id} payload.
102101
// resultKey selects the right shape ("delete_space_result" for delete-space,
103102
// "simple_task_result" for delete-node).
104103
func parseWikiAsyncTaskStatus(taskID string, task map[string]interface{}, resultKey string) (wikiAsyncTaskStatus, error) {
105104
if task == nil {
106-
return wikiAsyncTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
105+
return wikiAsyncTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
107106
}
108107

109108
result := common.GetMap(task, resultKey)
@@ -167,7 +166,7 @@ func pollWikiAsyncTask(
167166
return status, true, nil
168167
}
169168
if status.Failed() {
170-
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
169+
return status, false, errs.NewAPIError(errs.SubtypeServerError, "wiki %s task %s failed: %s", label, taskID, status.StatusLabel())
171170
}
172171

173172
fmt.Fprintf(runtime.IO().ErrOut, "Wiki %s status %d/%d: %s\n", label, attempt, attempts, status.StatusLabel())
@@ -178,29 +177,18 @@ func pollWikiAsyncTask(
178177
"the wiki %s task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
179178
label, taskID, nextCommand,
180179
)
181-
var exitErr *output.ExitError
182-
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
183-
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
184-
hint = exitErr.Detail.Hint + "\n" + hint
185-
}
186-
// ErrWithHint rebuilds the error and drops the upstream Lark
187-
// Detail.Code / ConsoleURL / Risk / nested Detail. Build the
188-
// ExitError by hand so the original API code survives a fully
189-
// failed poll, matching wrapWikiNodeDeleteAPIError.
190-
return lastStatus, false, &output.ExitError{
191-
Code: exitErr.Code,
192-
Detail: &output.ErrDetail{
193-
Type: exitErr.Detail.Type,
194-
Code: exitErr.Detail.Code,
195-
Message: exitErr.Detail.Message,
196-
Hint: hint,
197-
ConsoleURL: exitErr.Detail.ConsoleURL,
198-
Risk: exitErr.Detail.Risk,
199-
Detail: exitErr.Detail.Detail,
200-
},
180+
// The poll error comes from a typed CallAPITyped path; append the resume
181+
// hint in place so the original category / subtype / code / log_id
182+
// survives a fully failed poll (per ERROR_CONTRACT.md "propagate typed
183+
// errors unchanged"), matching wrapWikiNodeDeleteAPIError.
184+
if p, ok := errs.ProblemOf(lastErr); ok {
185+
if strings.TrimSpace(p.Hint) != "" {
186+
hint = p.Hint + "\n" + hint
201187
}
188+
p.Hint = hint
189+
return lastStatus, false, lastErr
202190
}
203-
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
191+
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).WithHint("%s", hint).WithCause(lastErr)
204192
}
205193

206194
return lastStatus, false, nil

shortcuts/wiki/wiki_async_task_test.go

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
"testing"
1111
"time"
1212

13+
"github.com/larksuite/cli/errs"
1314
"github.com/larksuite/cli/internal/core"
14-
"github.com/larksuite/cli/internal/output"
1515
)
1616

1717
// pollWikiAsyncTask is shared infrastructure for every wiki delete shortcut,
@@ -88,69 +88,78 @@ func TestPollWikiAsyncTaskAllPollsFailWrapsWithResumeHint(t *testing.T) {
8888
t.Parallel()
8989

9090
runtime, stderr := newWikiNodeDeleteRuntime(t, core.AsUser)
91+
transportErr := errors.New("transport boom")
9192
_, ready, err := pollWikiAsyncTask(
9293
context.Background(), runtime, "task_lost", "delete-node", 2, 0,
9394
func(context.Context, string) (wikiAsyncTaskStatus, error) {
94-
return wikiAsyncTaskStatus{}, errors.New("transport boom")
95+
return wikiAsyncTaskStatus{}, transportErr
9596
},
9697
"lark-cli drive +task_result --task-id task_lost",
9798
)
9899
if ready {
99100
t.Fatalf("ready = true, want false when every poll failed")
100101
}
101-
var exitErr *output.ExitError
102-
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
103-
t.Fatalf("err = %T %v, want *output.ExitError with detail", err, err)
102+
p, ok := errs.ProblemOf(err)
103+
if !ok {
104+
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
104105
}
105-
if exitErr.Code != output.ExitAPI {
106-
t.Fatalf("exit code = %d, want ExitAPI", exitErr.Code)
106+
if p.Subtype != errs.SubtypeUnknown {
107+
t.Fatalf("subtype = %q, want unknown for an untyped poll failure", p.Subtype)
107108
}
108-
if !strings.Contains(exitErr.Detail.Hint, "every status poll failed (task_id=task_lost)") ||
109-
!strings.Contains(exitErr.Detail.Hint, "lark-cli drive +task_result --task-id task_lost") {
110-
t.Fatalf("hint = %q, want resume guidance naming the task", exitErr.Detail.Hint)
109+
if !errors.Is(err, transportErr) {
110+
t.Fatalf("err does not preserve the transport cause: %v", err)
111+
}
112+
if !strings.Contains(p.Hint, "every status poll failed (task_id=task_lost)") ||
113+
!strings.Contains(p.Hint, "lark-cli drive +task_result --task-id task_lost") {
114+
t.Fatalf("hint = %q, want resume guidance naming the task", p.Hint)
111115
}
112116
if !strings.Contains(stderr.String(), "attempt 2/2 failed") {
113117
t.Fatalf("stderr = %q, want per-attempt progress", stderr.String())
114118
}
115119
}
116120

121+
func TestParseWikiAsyncTaskStatusRejectsNilTask(t *testing.T) {
122+
t.Parallel()
123+
124+
_, err := parseWikiAsyncTaskStatus("task_x", nil, "delete_space_result")
125+
p, ok := errs.ProblemOf(err)
126+
if !ok || p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
127+
t.Fatalf("expected internal/invalid_response, got %v", err)
128+
}
129+
}
130+
117131
func TestPollWikiAsyncTaskPrependsUpstreamExitHint(t *testing.T) {
118132
t.Parallel()
119133

120134
runtime, _ := newWikiNodeDeleteRuntime(t, core.AsUser)
121-
upstream := &output.ExitError{
122-
Code: output.ExitAPI,
123-
Detail: &output.ErrDetail{
124-
Type: "permission",
125-
Code: 99991663,
126-
Message: "permission denied",
127-
Hint: "grant the wiki:node:retrieve scope",
128-
},
129-
}
135+
// The upstream poll error is a typed error carrying its own hint, mirroring
136+
// what runtime.CallAPITyped produces for a permission failure.
137+
upstream := errs.NewPermissionError(errs.SubtypePermissionDenied, "permission denied").
138+
WithHint("grant the wiki:node:retrieve scope")
130139
_, _, err := pollWikiAsyncTask(
131140
context.Background(), runtime, "task_perm", "delete-node", 1, 0,
132141
func(context.Context, string) (wikiAsyncTaskStatus, error) {
133142
return wikiAsyncTaskStatus{}, upstream
134143
},
135144
"resume-cmd",
136145
)
137-
var exitErr *output.ExitError
138-
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
139-
t.Fatalf("err = %T %v, want *output.ExitError", err, err)
146+
p, ok := errs.ProblemOf(err)
147+
if !ok {
148+
t.Fatalf("err = %T %v, want a typed errs.* error", err, err)
140149
}
141150
// The upstream hint must lead so the actionable cause is read first, with
142-
// the resume guidance appended. Type and exit code propagate from upstream.
143-
if !strings.HasPrefix(exitErr.Detail.Hint, "grant the wiki:node:retrieve scope\n") {
144-
t.Fatalf("hint = %q, want upstream hint prepended", exitErr.Detail.Hint)
151+
// the resume guidance appended. The original typed error propagates in place.
152+
if !strings.HasPrefix(p.Hint, "grant the wiki:node:retrieve scope\n") {
153+
t.Fatalf("hint = %q, want upstream hint prepended", p.Hint)
145154
}
146-
if !strings.Contains(exitErr.Detail.Hint, "resume-cmd") {
147-
t.Fatalf("hint = %q, want resume command appended", exitErr.Detail.Hint)
155+
if !strings.Contains(p.Hint, "resume-cmd") {
156+
t.Fatalf("hint = %q, want resume command appended", p.Hint)
148157
}
149-
if exitErr.Detail.Type != "permission" || exitErr.Code != output.ExitAPI {
150-
t.Fatalf("exitErr = %+v, want permission/ExitAPI propagated", exitErr)
158+
if p.Subtype != errs.SubtypePermissionDenied {
159+
t.Fatalf("subtype = %q, want permission_denied propagated", p.Subtype)
151160
}
152-
if exitErr.Detail.Message != "permission denied" {
153-
t.Fatalf("message = %q, want upstream message preserved", exitErr.Detail.Message)
161+
if p.Message != "permission denied" {
162+
t.Fatalf("message = %q, want upstream message preserved", p.Message)
154163
}
155164
}
156165

shortcuts/wiki/wiki_delete.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
"strings"
1010
"time"
1111

12+
"github.com/larksuite/cli/errs"
1213
"github.com/larksuite/cli/internal/core"
13-
"github.com/larksuite/cli/internal/output"
1414
"github.com/larksuite/cli/internal/validate"
1515
"github.com/larksuite/cli/shortcuts/common"
1616
)
@@ -89,7 +89,7 @@ type wikiDeleteSpaceAPI struct {
8989
}
9090

9191
func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (*wikiDeleteSpaceResponse, error) {
92-
data, err := api.runtime.CallAPI(
92+
data, err := api.runtime.CallAPITyped(
9393
"DELETE",
9494
fmt.Sprintf("/open-apis/wiki/v2/spaces/%s", validate.EncodePathSegment(spaceID)),
9595
nil,
@@ -104,7 +104,7 @@ func (api wikiDeleteSpaceAPI) DeleteSpace(ctx context.Context, spaceID string) (
104104
}
105105

106106
func (api wikiDeleteSpaceAPI) GetDeleteSpaceTask(ctx context.Context, taskID string) (wikiDeleteSpaceTaskStatus, error) {
107-
data, err := api.runtime.CallAPI(
107+
data, err := api.runtime.CallAPITyped(
108108
"GET",
109109
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
110110
map[string]interface{}{"task_type": "delete_space"},
@@ -124,7 +124,7 @@ func readWikiDeleteSpaceSpec(runtime *common.RuntimeContext) wikiDeleteSpaceSpec
124124

125125
func validateWikiDeleteSpaceSpec(spec wikiDeleteSpaceSpec) error {
126126
if spec.SpaceID == "" {
127-
return output.ErrValidation("--space-id is required")
127+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--space-id is required").WithParam("--space-id")
128128
}
129129
return validateOptionalResourceName(spec.SpaceID, "--space-id")
130130
}

shortcuts/wiki/wiki_delete_test.go

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,19 @@ package wiki
66
import (
77
"bytes"
88
"context"
9-
"errors"
109
"reflect"
1110
"strings"
1211
"sync"
1312
"testing"
1413

1514
"github.com/spf13/cobra"
1615

16+
"github.com/larksuite/cli/errs"
1717
"github.com/larksuite/cli/internal/cmdutil"
1818
"github.com/larksuite/cli/internal/core"
1919
"github.com/larksuite/cli/internal/credential"
20+
"github.com/larksuite/cli/internal/errclass"
2021
"github.com/larksuite/cli/internal/httpmock"
21-
"github.com/larksuite/cli/internal/output"
2222
"github.com/larksuite/cli/shortcuts/common"
2323
)
2424

@@ -266,19 +266,18 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
266266
withSingleWikiDeleteSpacePoll(t)
267267

268268
runtime, stderr := newWikiDeleteSpaceRuntimeWithScopes(t, core.AsUser, "")
269-
// Seed an error that carries an upstream Lark Detail.Code so the test
269+
// Seed a typed error that carries an upstream Lark code and hint so the test
270270
// pins that structured fields survive a fully failed poll (not just the
271-
// hint). ErrWithHint drops Detail.Code, which is exactly what we fixed.
271+
// hint): the poll-exhaustion path must propagate the typed error in place.
272+
seeded := errclass.BuildAPIError(
273+
map[string]any{"code": float64(131006), "msg": "poll failed"},
274+
errclass.ClassifyContext{},
275+
)
276+
if p, ok := errs.ProblemOf(seeded); ok {
277+
p.Hint = "retry original"
278+
}
272279
client := &fakeWikiDeleteSpaceClient{
273-
taskErrs: []error{&output.ExitError{
274-
Code: output.ExitAPI,
275-
Detail: &output.ErrDetail{
276-
Type: "api_error",
277-
Code: 131006,
278-
Message: "poll failed",
279-
Hint: "retry original",
280-
},
281-
}},
280+
taskErrs: []error{seeded},
282281
}
283282

284283
status, ready, err := pollWikiDeleteSpaceTask(context.Background(), client, runtime, "task_123")
@@ -291,15 +290,15 @@ func TestPollWikiDeleteSpaceTaskWrapsPollFailuresWithHint(t *testing.T) {
291290
if status.TaskID != "task_123" {
292291
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
293292
}
294-
var exitErr *output.ExitError
295-
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
296-
t.Fatalf("expected structured exit error, got %T %v", err, err)
293+
p, ok := errs.ProblemOf(err)
294+
if !ok {
295+
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
297296
}
298-
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
299-
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
297+
if !strings.Contains(p.Hint, "retry original") || !strings.Contains(p.Hint, wikiDeleteSpaceTaskResultCommand("task_123", core.AsUser)) {
298+
t.Fatalf("hint = %q, want original hint and resume command", p.Hint)
300299
}
301-
if exitErr.Detail.Code != 131006 {
302-
t.Fatalf("Detail.Code = %d, want 131006 preserved through poll exhaustion", exitErr.Detail.Code)
300+
if p.Code != 131006 {
301+
t.Fatalf("Code = %d, want 131006 preserved through poll exhaustion", p.Code)
303302
}
304303
if !strings.Contains(stderr.String(), "Wiki delete-space status attempt 1/1 failed") {
305304
t.Fatalf("stderr = %q, want poll failure log", stderr.String())

shortcuts/wiki/wiki_list_copy_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ func TestWikiNodeCopyRequiresTargetSpaceOrParent(t *testing.T) {
351351
if err == nil || !strings.Contains(err.Error(), "--target-space-id or --target-parent-node-token") {
352352
t.Fatalf("expected target validation error, got %v", err)
353353
}
354+
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
354355
}
355356

356357
func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
@@ -365,6 +366,7 @@ func TestWikiNodeCopyRejectsBothTargetFlags(t *testing.T) {
365366
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
366367
t.Fatalf("expected mutually exclusive error, got %v", err)
367368
}
369+
requireWikiValidationParams(t, err, "--target-space-id", "--target-parent-node-token")
368370
}
369371

370372
// TestWikiNodeCopyDeclaredHighRiskWrite pins down the high-risk-write

0 commit comments

Comments
 (0)