mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
feat: report chat member partial results
This commit is contained in:
@@ -4,12 +4,16 @@
|
||||
package im
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
@@ -19,6 +23,9 @@ const (
|
||||
imChatMembersAddUserLimit = 50
|
||||
imChatMembersAddBotLimit = 5
|
||||
imChatMembersAddIDMaxBytes = 256
|
||||
|
||||
imChatMembersAddReadbackHint = "List current chat members with lark-cli im +chat-members-list --chat-id <chat_id> --page-all before retrying; retry only members not confirmed present."
|
||||
imChatBotsAddReadbackHint = "List current chat members with lark-cli im +chat-members-list --chat-id <chat_id> --page-all before retrying; retry only bots not confirmed present."
|
||||
)
|
||||
|
||||
var imChatMembersAddIDSuffix = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
@@ -42,7 +49,7 @@ type chatMembersAddResult struct {
|
||||
NotExistedIDList []string `json:"not_existed_id_list"`
|
||||
PendingApprovalIDList []string `json:"pending_approval_id_list"`
|
||||
FailedMemberType string `json:"failed_member_type,omitempty"`
|
||||
OutcomeUnknown bool `json:"outcome_unknown,omitempty"`
|
||||
OutcomeUnknown bool `json:"-"`
|
||||
Error *chatMembersAddError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -62,6 +69,22 @@ type chatMembersAddError struct {
|
||||
ConsoleURL string `json:"console_url,omitempty"`
|
||||
}
|
||||
|
||||
func (r chatMembersAddResult) MarshalJSON() ([]byte, error) {
|
||||
type resultAlias chatMembersAddResult
|
||||
var outcomeUnknown *bool
|
||||
if r.FailedMemberType != "" {
|
||||
value := r.OutcomeUnknown
|
||||
outcomeUnknown = &value
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
resultAlias
|
||||
OutcomeUnknown *bool `json:"outcome_unknown,omitempty"`
|
||||
}{
|
||||
resultAlias: resultAlias(r),
|
||||
OutcomeUnknown: outcomeUnknown,
|
||||
})
|
||||
}
|
||||
|
||||
func readChatMembersAddSpec(runtime *common.RuntimeContext) (chatMembersAddSpec, error) {
|
||||
chatID, err := common.ValidateChatIDTyped("--chat-id", runtime.Str("chat-id"))
|
||||
if err != nil {
|
||||
@@ -188,33 +211,125 @@ func buildChatMembersAddDryRun(spec chatMembersAddSpec) *common.DryRunAPI {
|
||||
|
||||
func executeChatMembersAdd(runtime *common.RuntimeContext, spec chatMembersAddSpec) error {
|
||||
responses := make([]chatMembersAddResponse, 0, 2)
|
||||
completedCount := 0
|
||||
if len(spec.Users) > 0 {
|
||||
response, err := callChatMembersAddBatch(runtime, spec.ChatID, "open_id", spec.Users)
|
||||
if err != nil {
|
||||
return err
|
||||
return withChatMembersAddUnknownOutcome(err, false)
|
||||
}
|
||||
responses = append(responses, response)
|
||||
completedCount += len(spec.Users)
|
||||
}
|
||||
if len(spec.Bots) > 0 {
|
||||
response, err := callChatMembersAddBatch(runtime, spec.ChatID, "app_id", spec.Bots)
|
||||
if err != nil {
|
||||
return err
|
||||
if completedCount == 0 {
|
||||
return withChatMembersAddUnknownOutcome(err, true)
|
||||
}
|
||||
projectedErr := withChatMembersAddUnknownOutcome(err, true)
|
||||
merged := mergeChatMembersAddResponse(responses...)
|
||||
result := newChatMembersAddResult(spec.ChatID, completedCount, merged)
|
||||
result.FailedMemberType = "bot"
|
||||
result.OutcomeUnknown = errs.IsNetwork(err)
|
||||
result.Error = projectChatMembersAddError(projectedErr, true)
|
||||
return runtime.OutPartialFailure(result, nil)
|
||||
}
|
||||
responses = append(responses, response)
|
||||
completedCount += len(spec.Bots)
|
||||
}
|
||||
|
||||
merged := mergeChatMembersAddResponse(responses...)
|
||||
result := chatMembersAddResult{
|
||||
ChatID: spec.ChatID,
|
||||
SuccessCount: confirmedChatMembersAddCount(len(spec.Users)+len(spec.Bots), merged),
|
||||
InvalidIDList: merged.InvalidIDList,
|
||||
NotExistedIDList: merged.NotExistedIDList,
|
||||
PendingApprovalIDList: merged.PendingApprovalIDList,
|
||||
result := newChatMembersAddResult(spec.ChatID, completedCount, merged)
|
||||
if hasUnfinishedChatMembersAdd(result) {
|
||||
return runtime.OutPartialFailure(result, nil)
|
||||
}
|
||||
runtime.Out(result, nil)
|
||||
runtime.OutFormat(result, &output.Meta{Count: result.SuccessCount}, func(w io.Writer) {
|
||||
renderChatMembersAddPretty(w, result)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func newChatMembersAddResult(chatID string, completedCount int, response chatMembersAddResponse) chatMembersAddResult {
|
||||
return chatMembersAddResult{
|
||||
ChatID: chatID,
|
||||
SuccessCount: confirmedChatMembersAddCount(completedCount, response),
|
||||
InvalidIDList: response.InvalidIDList,
|
||||
NotExistedIDList: response.NotExistedIDList,
|
||||
PendingApprovalIDList: response.PendingApprovalIDList,
|
||||
}
|
||||
}
|
||||
|
||||
func hasUnfinishedChatMembersAdd(result chatMembersAddResult) bool {
|
||||
return len(result.InvalidIDList) > 0 ||
|
||||
len(result.NotExistedIDList) > 0 ||
|
||||
len(result.PendingApprovalIDList) > 0
|
||||
}
|
||||
|
||||
func renderChatMembersAddPretty(w io.Writer, result chatMembersAddResult) {
|
||||
fmt.Fprintf(w, "Chat: %s\n", result.ChatID)
|
||||
fmt.Fprintf(w, "Added members: %d\n", result.SuccessCount)
|
||||
}
|
||||
|
||||
func withChatMembersAddUnknownOutcome(err error, botBatch bool) error {
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
cloned := *networkErr
|
||||
cloned.Problem = networkErr.Problem
|
||||
cloned.Retryable = false
|
||||
cloned.Hint = chatMembersAddUnknownOutcomeHint(botBatch)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func projectChatMembersAddError(err error, botBatch bool) *chatMembersAddError {
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return &chatMembersAddError{
|
||||
Type: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
Message: "member request failed",
|
||||
Retryable: false,
|
||||
}
|
||||
}
|
||||
|
||||
projected := &chatMembersAddError{
|
||||
Type: problem.Category,
|
||||
Subtype: problem.Subtype,
|
||||
Code: problem.Code,
|
||||
Message: problem.Message,
|
||||
Hint: problem.Hint,
|
||||
LogID: problem.LogID,
|
||||
Troubleshooter: problem.Troubleshooter,
|
||||
Retryable: problem.Retryable,
|
||||
}
|
||||
|
||||
var networkErr *errs.NetworkError
|
||||
if errors.As(err, &networkErr) {
|
||||
projected.Retryable = false
|
||||
projected.Hint = chatMembersAddUnknownOutcomeHint(botBatch)
|
||||
}
|
||||
|
||||
var permissionErr *errs.PermissionError
|
||||
if errors.As(err, &permissionErr) {
|
||||
projected.MissingScopes = append([]string(nil), permissionErr.MissingScopes...)
|
||||
projected.RequestedScopes = append([]string(nil), permissionErr.RequestedScopes...)
|
||||
projected.GrantedScopes = append([]string(nil), permissionErr.GrantedScopes...)
|
||||
projected.Identity = permissionErr.Identity
|
||||
projected.ConsoleURL = permissionErr.ConsoleURL
|
||||
}
|
||||
|
||||
return projected
|
||||
}
|
||||
|
||||
func chatMembersAddUnknownOutcomeHint(botBatch bool) string {
|
||||
if botBatch {
|
||||
return imChatBotsAddReadbackHint
|
||||
}
|
||||
return imChatMembersAddReadbackHint
|
||||
}
|
||||
|
||||
func callChatMembersAddBatch(
|
||||
runtime *common.RuntimeContext,
|
||||
chatID string,
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -570,6 +571,470 @@ func TestExecuteChatMembersAddPassesThroughUserAPIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddReturnsPartialFailureForRejectedIDs(t *testing.T) {
|
||||
runtime := newUserShortcutRuntime(t, chatMembersAddSuccessTransport(t, map[string]interface{}{
|
||||
"invalid_id_list": []interface{}{"ou_invalid"},
|
||||
"not_existed_id_list": []interface{}{"ou_missing"},
|
||||
"pending_approval_id_list": []interface{}{"ou_pending"},
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_ok,ou_invalid,ou_missing,ou_pending", "")
|
||||
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{
|
||||
ChatID: "oc_test",
|
||||
Users: []string{"ou_ok", "ou_invalid", "ou_missing", "ou_pending"},
|
||||
})
|
||||
assertChatMembersAddPartialFailure(t, err)
|
||||
|
||||
data := decodeChatMembersAddPartialOutput(t, runtime)
|
||||
if got := int(data["success_count"].(float64)); got != 1 {
|
||||
t.Fatalf("success_count = %d, want 1", got)
|
||||
}
|
||||
assertChatMembersAddOutputLists(t, data,
|
||||
[]string{"ou_invalid"}, []string{"ou_missing"}, []string{"ou_pending"})
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddStopsAfterUserAPIError(t *testing.T) {
|
||||
causeMarker := "log-chat-members-add"
|
||||
requestCount := 0
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not applied",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": causeMarker,
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat.members:write_only"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_private", "cli_private")
|
||||
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{
|
||||
ChatID: "oc_test",
|
||||
Users: []string{"ou_private"},
|
||||
Bots: []string{"cli_private"},
|
||||
})
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("error = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf() returned ok=false")
|
||||
}
|
||||
if problem.Category != errs.CategoryAuthorization || problem.Subtype != errs.SubtypeAppScopeNotApplied || problem.Code != 99991672 || problem.LogID != causeMarker {
|
||||
t.Fatalf("problem = %#v, want preserved permission metadata", problem)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("request count = %d, want 1", requestCount)
|
||||
}
|
||||
if got := chatMembersAddStdout(t, runtime); got != "" {
|
||||
t.Fatalf("stdout = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddDisablesRetryForUnknownUserOutcome(t *testing.T) {
|
||||
transportCause := errors.New("transport marker")
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, transportCause
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_private", "")
|
||||
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{ChatID: "oc_test", Users: []string{"ou_private"}})
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("error = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if networkErr.Retryable {
|
||||
t.Fatal("network error Retryable = true, want false")
|
||||
}
|
||||
assertChatMembersAddReadbackHint(t, networkErr.Hint, false)
|
||||
if !errors.Is(err, transportCause) {
|
||||
t.Fatalf("error does not preserve transport cause: %v", err)
|
||||
}
|
||||
if strings.Contains(networkErr.Hint, "ou_private") {
|
||||
t.Fatal("network hint contains a member identifier")
|
||||
}
|
||||
if got := chatMembersAddStdout(t, runtime); got != "" {
|
||||
t.Fatalf("stdout = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddReturnsPriorResultWhenBotRequestFails(t *testing.T) {
|
||||
requestCount := 0
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"invalid_id_list": []interface{}{"ou_invalid"},
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not applied",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "log-bot-request",
|
||||
"troubleshooter": "https://example.invalid/troubleshooter",
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "im:chat.members:write_only"},
|
||||
},
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_ok,ou_invalid", "cli_private")
|
||||
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{
|
||||
ChatID: "oc_test",
|
||||
Users: []string{"ou_ok", "ou_invalid"},
|
||||
Bots: []string{"cli_private"},
|
||||
})
|
||||
assertChatMembersAddPartialFailure(t, err)
|
||||
if requestCount != 2 {
|
||||
t.Fatalf("request count = %d, want 2", requestCount)
|
||||
}
|
||||
|
||||
data := decodeChatMembersAddPartialOutput(t, runtime)
|
||||
if data["failed_member_type"] != "bot" || data["outcome_unknown"] != false {
|
||||
t.Fatalf("failure metadata = %#v, want failed bot with known outcome", data)
|
||||
}
|
||||
if got := int(data["success_count"].(float64)); got != 1 {
|
||||
t.Fatalf("success_count = %d, want 1", got)
|
||||
}
|
||||
assertChatMembersAddOutputLists(t, data, []string{"ou_invalid"}, []string{}, []string{})
|
||||
errorData, ok := data["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("error projection = %T, want object", data["error"])
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"type": string(errs.CategoryAuthorization),
|
||||
"subtype": string(errs.SubtypeAppScopeNotApplied),
|
||||
"code": float64(99991672),
|
||||
"log_id": "log-bot-request",
|
||||
"troubleshooter": "https://example.invalid/troubleshooter",
|
||||
"retryable": false,
|
||||
"identity": "user",
|
||||
} {
|
||||
if got := errorData[key]; got != want {
|
||||
t.Errorf("error.%s = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := errorData["missing_scopes"].([]interface{}); !ok {
|
||||
t.Fatalf("error.missing_scopes = %T, want array", errorData["missing_scopes"])
|
||||
}
|
||||
if message, _ := errorData["message"].(string); message == "" {
|
||||
t.Fatal("error.message is empty")
|
||||
}
|
||||
errOut := chatMembersAddStderr(t, runtime)
|
||||
if strings.Contains(errOut, "ou_") || strings.Contains(errOut, "cli_") || strings.Contains(errOut, "PermissionError") {
|
||||
t.Fatalf("stderr exposes member data or an error object: %q", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectChatMembersAddErrorCopiesPermissionFields(t *testing.T) {
|
||||
cause := errors.New("permission cause")
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "permission denied").
|
||||
WithHint("grant required scopes").
|
||||
WithLogID("log-projection").
|
||||
WithCode(99991679).
|
||||
WithRetryable().
|
||||
WithMissingScopes("scope.missing").
|
||||
WithRequestedScopes("scope.requested").
|
||||
WithGrantedScopes("scope.granted").
|
||||
WithIdentity("bot").
|
||||
WithConsoleURL("https://example.invalid/console").
|
||||
WithCause(cause)
|
||||
source.Troubleshooter = "https://example.invalid/help"
|
||||
|
||||
got := projectChatMembersAddError(source, false)
|
||||
if got == nil {
|
||||
t.Fatal("projectChatMembersAddError() = nil")
|
||||
}
|
||||
if got.Type != source.Category || got.Subtype != source.Subtype || got.Code != source.Code || got.Message != source.Message || got.Hint != source.Hint || got.LogID != source.LogID || got.Troubleshooter != source.Troubleshooter || !got.Retryable {
|
||||
t.Fatalf("projected common fields = %#v, want %#v", got, source.Problem)
|
||||
}
|
||||
if !reflect.DeepEqual(got.MissingScopes, source.MissingScopes) || !reflect.DeepEqual(got.RequestedScopes, source.RequestedScopes) || !reflect.DeepEqual(got.GrantedScopes, source.GrantedScopes) || got.Identity != source.Identity || got.ConsoleURL != source.ConsoleURL {
|
||||
t.Fatalf("projected permission fields = %#v, want copied fields", got)
|
||||
}
|
||||
source.MissingScopes[0] = "mutated"
|
||||
source.RequestedScopes[0] = "mutated"
|
||||
source.GrantedScopes[0] = "mutated"
|
||||
if got.MissingScopes[0] == "mutated" || got.RequestedScopes[0] == "mutated" || got.GrantedScopes[0] == "mutated" {
|
||||
t.Fatal("projected permission slices alias source slices")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithChatMembersAddUnknownOutcomePreservesDeterministicError(t *testing.T) {
|
||||
cause := errors.New("permission cause")
|
||||
source := errs.NewPermissionError(errs.SubtypeMissingScope, "permission denied").WithCause(cause)
|
||||
|
||||
got := withChatMembersAddUnknownOutcome(source, false)
|
||||
if got != source {
|
||||
t.Fatalf("deterministic error = %T, want original pointer", got)
|
||||
}
|
||||
if !errors.Is(got, cause) {
|
||||
t.Fatal("deterministic error cause was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithChatMembersAddUnknownOutcomeCopiesNetworkError(t *testing.T) {
|
||||
cause := errors.New("network cause")
|
||||
source := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "request timed out").
|
||||
WithHint("retry request").
|
||||
WithLogID("log-network").
|
||||
WithCode(504).
|
||||
WithRetryable().
|
||||
WithCause(cause)
|
||||
source.Troubleshooter = "https://example.invalid/network-help"
|
||||
|
||||
err := withChatMembersAddUnknownOutcome(source, false)
|
||||
var got *errs.NetworkError
|
||||
if !errors.As(err, &got) {
|
||||
t.Fatalf("error = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if got == source {
|
||||
t.Fatal("withChatMembersAddUnknownOutcome() returned the source pointer")
|
||||
}
|
||||
if got.Category != source.Category || got.Subtype != source.Subtype || got.Code != source.Code || got.Message != source.Message || got.LogID != source.LogID || got.Troubleshooter != source.Troubleshooter {
|
||||
t.Fatalf("network problem = %#v, want source metadata preserved", got.Problem)
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Fatal("network error Retryable = true, want false")
|
||||
}
|
||||
assertChatMembersAddReadbackHint(t, got.Hint, false)
|
||||
if !errors.Is(got, cause) {
|
||||
t.Fatal("network error cause was not preserved")
|
||||
}
|
||||
if source.Hint != "retry request" || !source.Retryable {
|
||||
t.Fatalf("source network error was mutated: %#v", source.Problem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddMarksBotNetworkOutcomeUnknown(t *testing.T) {
|
||||
requestCount := 0
|
||||
transportCause := errors.New("bot transport marker")
|
||||
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{},
|
||||
}), nil
|
||||
}
|
||||
return nil, transportCause
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_ok", "cli_private")
|
||||
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{
|
||||
ChatID: "oc_test",
|
||||
Users: []string{"ou_ok"},
|
||||
Bots: []string{"cli_private"},
|
||||
})
|
||||
assertChatMembersAddPartialFailure(t, err)
|
||||
data := decodeChatMembersAddPartialOutput(t, runtime)
|
||||
if data["failed_member_type"] != "bot" || data["outcome_unknown"] != true {
|
||||
t.Fatalf("failure metadata = %#v, want failed bot with unknown outcome", data)
|
||||
}
|
||||
if got := int(data["success_count"].(float64)); got != 1 {
|
||||
t.Fatalf("success_count = %d, want 1", got)
|
||||
}
|
||||
errorData := data["error"].(map[string]interface{})
|
||||
if retryable, ok := errorData["retryable"].(bool); !ok || retryable {
|
||||
t.Fatalf("error.retryable = %#v, want false", errorData["retryable"])
|
||||
}
|
||||
hint, _ := errorData["hint"].(string)
|
||||
assertChatMembersAddReadbackHint(t, hint, true)
|
||||
if _, exists := errorData["cause"]; exists {
|
||||
t.Fatal("serialized error projection contains cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddBotOnlyFailureBehavior(t *testing.T) {
|
||||
t.Run("deterministic error passes through", func(t *testing.T) {
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not applied",
|
||||
}), nil
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "", "cli_private")
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{ChatID: "oc_test", Bots: []string{"cli_private"}})
|
||||
var permissionErr *errs.PermissionError
|
||||
if !errors.As(err, &permissionErr) {
|
||||
t.Fatalf("error = %T, want *errs.PermissionError", err)
|
||||
}
|
||||
if got := chatMembersAddStdout(t, runtime); got != "" {
|
||||
t.Fatalf("stdout = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("network error disables retry", func(t *testing.T) {
|
||||
transportCause := errors.New("bot-only transport marker")
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, transportCause
|
||||
}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "", "cli_private")
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{ChatID: "oc_test", Bots: []string{"cli_private"}})
|
||||
var networkErr *errs.NetworkError
|
||||
if !errors.As(err, &networkErr) {
|
||||
t.Fatalf("error = %T, want *errs.NetworkError", err)
|
||||
}
|
||||
if networkErr.Retryable {
|
||||
t.Fatal("network error Retryable = true, want false")
|
||||
}
|
||||
assertChatMembersAddReadbackHint(t, networkErr.Hint, true)
|
||||
if got := chatMembersAddStdout(t, runtime); got != "" {
|
||||
t.Fatalf("stdout = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteChatMembersAddSuccessOutput(t *testing.T) {
|
||||
t.Run("json", func(t *testing.T) {
|
||||
runtime := newUserShortcutRuntime(t, chatMembersAddSuccessTransport(t, map[string]interface{}{}))
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_a,ou_b", "")
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{ChatID: "oc_test", Users: []string{"ou_a", "ou_b"}})
|
||||
if err != nil {
|
||||
t.Fatalf("executeChatMembersAdd() error = %v", err)
|
||||
}
|
||||
data, meta := decodeChatMembersAddSuccessOutput(t, runtime)
|
||||
if data["chat_id"] != "oc_test" || int(data["success_count"].(float64)) != 2 {
|
||||
t.Fatalf("success data = %#v", data)
|
||||
}
|
||||
for _, key := range []string{"failed_member_type", "outcome_unknown", "error"} {
|
||||
if _, exists := data[key]; exists {
|
||||
t.Errorf("success data contains failure-only field %q", key)
|
||||
}
|
||||
}
|
||||
if meta == nil || meta.Count != 2 {
|
||||
t.Fatalf("meta = %#v, want count 2", meta)
|
||||
}
|
||||
assertChatMembersAddOutputLists(t, data, []string{}, []string{}, []string{})
|
||||
})
|
||||
|
||||
t.Run("pretty", func(t *testing.T) {
|
||||
runtime := newUserShortcutRuntime(t, chatMembersAddSuccessTransport(t, map[string]interface{}{}))
|
||||
runtime.Format = "pretty"
|
||||
setChatMembersAddTestFlags(t, runtime, "oc_test", "ou_private", "")
|
||||
err := executeChatMembersAdd(runtime, chatMembersAddSpec{ChatID: "oc_test", Users: []string{"ou_private"}})
|
||||
if err != nil {
|
||||
t.Fatalf("executeChatMembersAdd() error = %v", err)
|
||||
}
|
||||
got := chatMembersAddStdout(t, runtime)
|
||||
want := "Chat: oc_test\nAdded members: 1\n"
|
||||
if got != want {
|
||||
t.Fatalf("pretty output = %q, want %q", got, want)
|
||||
}
|
||||
if strings.Contains(got, "ou_private") {
|
||||
t.Fatal("pretty output contains a member identifier")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func chatMembersAddSuccessTransport(t *testing.T, data map[string]interface{}) shortcutRoundTripFunc {
|
||||
t.Helper()
|
||||
return shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return shortcutJSONResponse(http.StatusOK, map[string]interface{}{"code": 0, "data": data}), nil
|
||||
})
|
||||
}
|
||||
|
||||
func assertChatMembersAddPartialFailure(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
var partialErr *output.PartialFailureError
|
||||
if !errors.As(err, &partialErr) {
|
||||
t.Fatalf("error = %T (%v), want *output.PartialFailureError", err, err)
|
||||
}
|
||||
if partialErr.Code != output.ExitAPI || output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Fatalf("partial failure exit = %d/%d, want %d", partialErr.Code, output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeChatMembersAddEnvelope(t *testing.T, runtime *common.RuntimeContext) (bool, map[string]interface{}, *output.Meta) {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Meta *output.Meta `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(chatMembersAddStdout(t, runtime)), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
return envelope.OK, envelope.Data, envelope.Meta
|
||||
}
|
||||
|
||||
func decodeChatMembersAddPartialOutput(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
ok, data, _ := decodeChatMembersAddEnvelope(t, runtime)
|
||||
if ok {
|
||||
t.Fatal("stdout envelope ok = true, want false")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func decodeChatMembersAddSuccessOutput(t *testing.T, runtime *common.RuntimeContext) (map[string]interface{}, *output.Meta) {
|
||||
t.Helper()
|
||||
ok, data, meta := decodeChatMembersAddEnvelope(t, runtime)
|
||||
if !ok {
|
||||
t.Fatal("stdout envelope ok = false, want true")
|
||||
}
|
||||
return data, meta
|
||||
}
|
||||
|
||||
func assertChatMembersAddOutputLists(t *testing.T, data map[string]interface{}, invalid, notExisted, pending []string) {
|
||||
t.Helper()
|
||||
for key, want := range map[string][]string{
|
||||
"invalid_id_list": invalid,
|
||||
"not_existed_id_list": notExisted,
|
||||
"pending_approval_id_list": pending,
|
||||
} {
|
||||
raw, ok := data[key].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("%s = %T, want non-nil array", key, data[key])
|
||||
}
|
||||
got := make([]string, len(raw))
|
||||
for i := range raw {
|
||||
got[i], _ = raw[i].(string)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertChatMembersAddReadbackHint(t *testing.T, hint string, botsOnly bool) {
|
||||
t.Helper()
|
||||
for _, want := range []string{"im +chat-members-list", "--chat-id <chat_id>", "--page-all", "only"} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Errorf("hint = %q, want substring %q", hint, want)
|
||||
}
|
||||
}
|
||||
if botsOnly && !strings.Contains(strings.ToLower(hint), "bot") {
|
||||
t.Errorf("hint = %q, want bot-specific retry guidance", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func chatMembersAddStdout(t *testing.T, runtime *common.RuntimeContext) string {
|
||||
t.Helper()
|
||||
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func chatMembersAddStderr(t *testing.T, runtime *common.RuntimeContext) string {
|
||||
t.Helper()
|
||||
errOut, ok := runtime.Factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stderr buffer has type %T", runtime.Factory.IOStreams.ErrOut)
|
||||
}
|
||||
return errOut.String()
|
||||
}
|
||||
|
||||
func TestProjectChatMembersAddResponseUsesEmptySlices(t *testing.T) {
|
||||
got, err := projectChatMembersAddResponse(map[string]interface{}{}, []string{"ou_a"})
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user