Files
larksuite-cli/shortcuts/wiki/wiki_member_test.go
liujinkun2025 652e96906c feat(wiki): add +member-add / +member-remove / +member-list shortcuts (#997)
- +member-add: wrap POST /spaces/{id}/members; --member-type / --member-role
  enums, optional --need-notification query (omitted entirely when the flag
  is unset, instead of forcing need_notification=false), my_library
  resolution under --as user, flattened single-member output
- +member-remove: wrap DELETE /spaces/{id}/members/{member_id}; surfaces the
  required member_type + member_role body the API expects, my_library
  resolution, fallback to echoing the caller's inputs when the API omits
  the member echo
- +member-list: wrap GET /spaces/{id}/members; reuses the +space-list /
  +node-list pagination contract (single page by default, --page-all walks
  every page capped by --page-limit, --page-token resumes a cursor)
- All three reject bot identity + my_library upfront with a clear hint and
  declare the narrowest scope the API accepts (wiki:member:create /
  wiki:member:update / wiki:member:retrieve) so tokens carrying only the
  narrow scope are not false-rejected by the exact-string preflight
- skill docs: reference pages for the three new shortcuts + SKILL.md
  shortcuts table; switch the membership flow guidance from raw
  `wiki members create` to the new +member-add path

Change-Id: I158a86aa7f00bb7cecc7a4e99346f3fb151b3c09
2026-05-21 20:40:55 +08:00

735 lines
23 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"encoding/json"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// ── registration / declared contract ────────────────────────────────────────
func TestWikiShortcutsIncludesMembers(t *testing.T) {
t.Parallel()
commands := map[string]bool{}
for _, s := range Shortcuts() {
commands[s.Command] = true
}
for _, want := range []string{"+member-add", "+member-remove", "+member-list"} {
if !commands[want] {
t.Errorf("Shortcuts() missing %q", want)
}
}
}
// TestWikiMemberShortcutsDeclareNarrowScopes pins the per-endpoint scope so a
// future broadening (e.g. wiki:wiki) doesn't silently reject tokens that
// carry only the narrow scope the API accepts.
func TestWikiMemberShortcutsDeclareNarrowScopes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
want []string
}{
{"+member-add", WikiMemberAdd, []string{"wiki:member:create"}},
{"+member-remove", WikiMemberRemove, []string{"wiki:member:update"}},
{"+member-list", WikiMemberList, []string{"wiki:member:retrieve"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if !reflect.DeepEqual(tc.shortcut.Scopes, tc.want) {
t.Fatalf("%s scopes = %v, want %v", tc.name, tc.shortcut.Scopes, tc.want)
}
})
}
}
func TestWikiMemberShortcutsDeclareRiskAndAuth(t *testing.T) {
t.Parallel()
cases := []struct {
name string
shortcut common.Shortcut
risk string
}{
{"+member-add", WikiMemberAdd, "write"},
{"+member-remove", WikiMemberRemove, "write"},
{"+member-list", WikiMemberList, "read"},
}
wantAuth := []string{"user", "bot"}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.shortcut.Risk != tc.risk {
t.Errorf("Risk = %q, want %q", tc.shortcut.Risk, tc.risk)
}
if !reflect.DeepEqual(tc.shortcut.AuthTypes, wantAuth) {
t.Errorf("AuthTypes = %v, want %v", tc.shortcut.AuthTypes, wantAuth)
}
})
}
}
// ── +member-add ──────────────────────────────────────────────────────────────
func TestWikiMemberAddRequestBodyOmitsQueryWhenNotificationFlagUnset(t *testing.T) {
t.Parallel()
spec := wikiMemberAddSpec{
SpaceID: "space_1",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
}
if got := spec.QueryParams(); got != nil {
t.Fatalf("QueryParams() = %v, want nil when --need-notification was not set", got)
}
body := spec.RequestBody()
want := map[string]interface{}{"member_id": "ou_x", "member_type": "openid", "member_role": "member"}
if !reflect.DeepEqual(body, want) {
t.Fatalf("RequestBody() = %v, want %v", body, want)
}
}
func TestWikiMemberAddQueryParamsHonorsExplicitNotification(t *testing.T) {
t.Parallel()
spec := wikiMemberAddSpec{
NotificationSet: true,
NeedNotification: true,
}
if got := spec.QueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"need_notification": true}) {
t.Fatalf("QueryParams() = %v, want need_notification=true", got)
}
}
func TestWikiMemberAddQueryParamsHonorsExplicitFalse(t *testing.T) {
t.Parallel()
// The three-state design (unset / true / false) must distinguish false from
// unset so --need-notification=false reaches the server instead of being
// dropped along with the param block.
spec := wikiMemberAddSpec{
NotificationSet: true,
NeedNotification: false,
}
if got := spec.QueryParams(); !reflect.DeepEqual(got, map[string]interface{}{"need_notification": false}) {
t.Fatalf("QueryParams() = %v, want need_notification=false", got)
}
}
func TestWikiMemberAddDryRunSingleStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberAddDryRun(wikiMemberAddSpec{
SpaceID: "space_42",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "admin",
})
api := dryRunAPIList(t, dry)
if len(api) != 1 || api[0].Method != "POST" || api[0].URL != "/open-apis/wiki/v2/spaces/space_42/members" {
t.Fatalf("dry-run api = %#v", api)
}
if api[0].Body["member_id"] != "ou_x" || api[0].Body["member_role"] != "admin" {
t.Fatalf("dry-run body = %#v", api[0].Body)
}
}
func TestWikiMemberAddDryRunMyLibraryIsTwoStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberAddDryRun(wikiMemberAddSpec{
SpaceID: wikiMyLibrarySpaceID,
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
})
api := dryRunAPIList(t, dry)
if len(api) != 2 {
t.Fatalf("dry-run api count = %d, want 2", len(api))
}
if api[0].Method != "GET" || !strings.Contains(api[0].URL, "/spaces/my_library") {
t.Fatalf("dry-run step 1 = %#v", api[0])
}
if api[1].Method != "POST" || !strings.Contains(api[1].URL, "<resolved_space_id>/members") {
t.Fatalf("dry-run step 2 = %#v", api[1])
}
}
func TestWikiMemberAddRejectsMyLibraryForBot(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "my_library",
"--member-id", "ou_x",
"--member-type", "openid",
"--member-role", "member",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiMemberAddRejectsBotWithDepartment(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "od_dept_1",
"--member-type", "opendepartmentid",
"--member-role", "member",
"--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "--as bot does not support --member-type opendepartmentid") {
t.Fatalf("expected bot+opendepartmentid rejection, got %v", err)
}
}
func TestWikiMemberAddMountedExecuteFlattensMember(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, reg := cmdutil.TestFactory(t, wikiTestConfig())
var capturedQuery string
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
OnMatch: func(req *http.Request) { capturedQuery = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_abc",
"member_type": "openid",
"member_role": "admin",
"type": "user",
},
},
"msg": "success",
},
}
reg.Register(stub)
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--need-notification",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" {
t.Fatalf("space_id = %#v", data["space_id"])
}
if data["member_id"] != "ou_abc" || data["member_role"] != "admin" || data["type"] != "user" {
t.Fatalf("flattened envelope = %#v", data)
}
// Captured body must carry the three required fields; query must include the
// notification flag because the caller passed --need-notification.
var captured map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &captured); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if captured["member_id"] != "ou_abc" || captured["member_type"] != "openid" || captured["member_role"] != "admin" {
t.Fatalf("captured request body = %#v", captured)
}
if !strings.Contains(capturedQuery, "need_notification=true") {
t.Fatalf("captured query = %q, want need_notification=true", capturedQuery)
}
if !strings.Contains(stderr.String(), "Added wiki space member") {
t.Fatalf("stderr = %q, want success log", stderr.String())
}
}
func TestWikiMemberAddFallsBackToSpecWhenMemberEchoIsEmpty(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Server returns an empty member object: scripts must still see the three
// identifying fields, restored from the caller's spec.
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" ||
data["member_id"] != "ou_abc" ||
data["member_type"] != "openid" ||
data["member_role"] != "admin" {
t.Fatalf("fallback envelope = %#v", data)
}
}
func TestWikiMemberAddResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{"space_id": "space_personal_7", "name": "My Library", "space_type": "my_library"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_personal_7/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_x",
"member_type": "openid",
"member_role": "member",
},
},
},
})
err := mountAndRunWiki(t, WikiMemberAdd, []string{
"+member-add",
"--space-id", "my_library",
"--member-id", "ou_x",
"--member-type", "openid",
"--member-role", "member",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_personal_7" {
t.Fatalf("space_id = %#v, want space_personal_7", data["space_id"])
}
}
// ── +member-remove ───────────────────────────────────────────────────────────
func TestWikiMemberRemoveSpecRequiresMemberID(t *testing.T) {
t.Parallel()
cmd := newMemberRemoveCmd("space_1", "", "openid", "member")
runtime := common.TestNewRuntimeContext(cmd, nil)
if _, err := readWikiMemberRemoveSpec(runtime); err == nil || !strings.Contains(err.Error(), "--member-id is required") {
t.Fatalf("expected --member-id rejection, got %v", err)
}
}
func TestWikiMemberRemoveDryRunIncludesBody(t *testing.T) {
t.Parallel()
dry := buildWikiMemberRemoveDryRun(wikiMemberRemoveSpec{
SpaceID: "space_42",
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "admin",
})
api := dryRunAPIList(t, dry)
if len(api) != 1 || api[0].Method != "DELETE" {
t.Fatalf("dry-run api = %#v", api)
}
if api[0].URL != "/open-apis/wiki/v2/spaces/space_42/members/ou_x" {
t.Fatalf("dry-run url = %q", api[0].URL)
}
if api[0].Body["member_type"] != "openid" || api[0].Body["member_role"] != "admin" {
t.Fatalf("dry-run body = %#v", api[0].Body)
}
}
func TestWikiMemberRemoveDryRunMyLibraryIsTwoStep(t *testing.T) {
t.Parallel()
dry := buildWikiMemberRemoveDryRun(wikiMemberRemoveSpec{
SpaceID: wikiMyLibrarySpaceID,
MemberID: "ou_x",
MemberType: "openid",
MemberRole: "member",
})
api := dryRunAPIList(t, dry)
if len(api) != 2 {
t.Fatalf("dry-run api count = %d, want 2", len(api))
}
if api[1].Method != "DELETE" || !strings.Contains(api[1].URL, "<resolved_space_id>/members/ou_x") {
t.Fatalf("dry-run step 2 = %#v", api[1])
}
}
func TestWikiMemberRemoveMountedExecuteFlattensMember(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/wiki/v2/spaces/space_42/members/ou_abc",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_abc",
"member_type": "openid",
"member_role": "admin",
},
},
"msg": "success",
},
})
err := mountAndRunWiki(t, WikiMemberRemove, []string{
"+member-remove",
"--space-id", "space_42",
"--member-id", "ou_abc",
"--member-type", "openid",
"--member-role", "admin",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["space_id"] != "space_42" || data["member_id"] != "ou_abc" || data["member_role"] != "admin" {
t.Fatalf("envelope = %#v", data)
}
}
// ── +member-list ─────────────────────────────────────────────────────────────
func TestWikiMemberListRequiresSpaceID(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberList, []string{"+member-list", "--as", "user"}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "required") {
t.Fatalf("expected required flag error, got %v", err)
}
}
func TestWikiMemberListRejectsMyLibraryForBot(t *testing.T) {
t.Parallel()
factory, _, _, _ := cmdutil.TestFactory(t, wikiTestConfig())
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "my_library", "--as", "bot",
}, factory, nil)
if err == nil || !strings.Contains(err.Error(), "bot identity does not support --space-id my_library") {
t.Fatalf("expected my_library bot rejection, got %v", err)
}
}
func TestWikiMemberListReturnsMembers(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"members": []interface{}{
map[string]interface{}{
"member_id": "ou_1",
"member_type": "openid",
"member_role": "admin",
},
map[string]interface{}{
"member_id": "ou_2",
"member_type": "openid",
"member_role": "member",
},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
OK bool `json:"ok"`
Data struct {
SpaceID string `json:"space_id"`
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if !envelope.OK {
t.Fatalf("expected ok=true, got %s", stdout.String())
}
if envelope.Meta.Count != 2 {
t.Fatalf("meta.count = %v, want 2", envelope.Meta.Count)
}
if envelope.Data.SpaceID != "space_42" {
t.Fatalf("data.space_id = %q, want space_42", envelope.Data.SpaceID)
}
if envelope.Data.Members[0]["member_role"] != "admin" {
t.Fatalf("members[0].member_role = %v", envelope.Data.Members[0]["member_role"])
}
}
func TestWikiMemberListResolvesMyLibraryForUser(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/my_library",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"space": map[string]interface{}{"space_id": "space_personal_7", "name": "My Library", "space_type": "my_library"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_personal_7/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"members": []interface{}{},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "my_library", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
SpaceID string `json:"space_id"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Data.SpaceID != "space_personal_7" {
t.Fatalf("data.space_id = %q, want space_personal_7", envelope.Data.SpaceID)
}
}
func TestWikiMemberListAutoPaginatesAcrossPages(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Page 1: has_more=true, page_token set. Loop must continue.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_page2",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_1", "member_type": "openid", "member_role": "admin"},
},
},
},
})
// Page 2: must carry page_token=tok_page2 in the query. Captured to verify.
var page2Query string
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
OnMatch: func(req *http.Request) { page2Query = req.URL.RawQuery },
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_2", "member_type": "openid", "member_role": "member"},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--page-all", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
Meta struct {
Count float64 `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if envelope.Meta.Count != 2 || len(envelope.Data.Members) != 2 {
t.Fatalf("merged members = %d / count=%v, want 2 / 2", len(envelope.Data.Members), envelope.Meta.Count)
}
if envelope.Data.HasMore || envelope.Data.PageToken != "" {
t.Fatalf("natural end should clear has_more/page_token, got has_more=%v page_token=%q",
envelope.Data.HasMore, envelope.Data.PageToken)
}
q, _ := url.ParseQuery(page2Query)
if q.Get("page_token") != "tok_page2" {
t.Fatalf("page2 page_token = %q, want tok_page2", q.Get("page_token"))
}
}
func TestWikiMemberListPageLimitTruncatesAndExposesNextCursor(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
// Only stub page 1; with --page-limit=1 the loop must stop BEFORE page 2 —
// and the response must surface has_more/page_token so the caller can resume.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/space_42/members",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "tok_next",
"members": []interface{}{
map[string]interface{}{"member_id": "ou_only", "member_type": "openid", "member_role": "admin"},
},
},
},
})
err := mountAndRunWiki(t, WikiMemberList, []string{
"+member-list", "--space-id", "space_42", "--page-all", "--page-limit", "1", "--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
var envelope struct {
Data struct {
Members []map[string]interface{} `json:"members"`
HasMore bool `json:"has_more"`
PageToken string `json:"page_token"`
} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err)
}
if len(envelope.Data.Members) != 1 {
t.Fatalf("members = %d, want 1 (capped)", len(envelope.Data.Members))
}
if !envelope.Data.HasMore || envelope.Data.PageToken != "tok_next" {
t.Fatalf("truncated state = has_more=%v page_token=%q, want true / tok_next",
envelope.Data.HasMore, envelope.Data.PageToken)
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func newMemberRemoveCmd(spaceID, memberID, memberType, memberRole string) *cobra.Command {
cmd := &cobra.Command{Use: "wiki +member-remove"}
cmd.Flags().String("space-id", spaceID, "")
cmd.Flags().String("member-id", memberID, "")
cmd.Flags().String("member-type", memberType, "")
cmd.Flags().String("member-role", memberRole, "")
return cmd
}
// dryRunAPIList serializes a DryRunAPI through JSON to match how the framework
// exposes it to callers — same approach used by +space-create's tests.
func dryRunAPIList(t *testing.T, dry *common.DryRunAPI) []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} {
t.Helper()
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Body map[string]interface{} `json:"body"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run: %v", err)
}
return got.API
}