From 4b162b9eb85046f8008e5f24ed5ce998211b4a60 Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:44:48 +0800 Subject: [PATCH 1/2] fix: complete mail rule reorder ids Co-authored-by: TRAE CLI --- cmd/service/mail_rule_reorder.go | 230 ++++++++++++++++++++++++++ cmd/service/service.go | 5 + cmd/service/service_test.go | 275 +++++++++++++++++++++++++++++++ 3 files changed, 510 insertions(+) create mode 100644 cmd/service/mail_rule_reorder.go diff --git a/cmd/service/mail_rule_reorder.go b/cmd/service/mail_rule_reorder.go new file mode 100644 index 0000000000..a4634edba5 --- /dev/null +++ b/cmd/service/mail_rule_reorder.go @@ -0,0 +1,230 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "context" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/client" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +const mailRuleReorderSuffix = "/user_mailboxes/" + +func completeMailRuleReorderRequest(ctx context.Context, ac *client.APIClient, opts *ServiceMethodOptions, request client.RawApiRequest, checkErr func(interface{}, core.Identity) error) (client.RawApiRequest, error) { + if !isMailRuleReorderMethod(opts, request) { + return request, nil + } + + requestedIDs, body, err := mailRuleRequestedIDs(request.Data) + if err != nil { + return request, err + } + currentIDs, err := fetchAllMailRuleIDs(ctx, ac, request, checkErr) + if err != nil { + return request, err + } + completedIDs, err := completeMailRuleIDs(requestedIDs, currentIDs) + if err != nil { + return request, err + } + + body["rule_ids"] = completedIDs + request.Data = body + return request, nil +} + +func isMailRuleReorderMethod(opts *ServiceMethodOptions, request client.RawApiRequest) bool { + if opts == nil { + return false + } + if opts.Method.ID == "ReorderUserMailboxRule" || opts.SchemaPath == "mail.user_mailbox.rules.reorder" { + return true + } + path := strings.Trim(request.URL, "/") + return strings.HasPrefix(path, "open-apis/mail/v1/user_mailboxes/") && + strings.HasSuffix(path, "/rules/reorder") && + strings.Contains(path, mailRuleReorderSuffix) +} + +func mailRuleRequestedIDs(data interface{}) ([]string, map[string]interface{}, error) { + body, ok := data.(map[string]interface{}) + if !ok || body == nil { + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids is required").WithParam("rule_ids") + } + raw, ok := body["rule_ids"] + if !ok { + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids is required").WithParam("rule_ids") + } + ids, ok := stringSlice(raw) + if !ok { + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids must be an array of strings").WithParam("rule_ids") + } + if len(ids) == 0 { + return nil, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids must contain at least one rule ID").WithParam("rule_ids") + } + return ids, body, nil +} + +func fetchAllMailRuleIDs(ctx context.Context, ac *client.APIClient, reorderRequest client.RawApiRequest, checkErr func(interface{}, core.Identity) error) ([]string, error) { + listURL := strings.TrimSuffix(reorderRequest.URL, "/reorder") + if listURL == reorderRequest.URL { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule reorder path %q does not end with /reorder", reorderRequest.URL) + } + + params := copyParams(reorderRequest.Params) + delete(params, "page_token") + params["page_size"] = 100 + + var ids []string + var pageToken string + for { + if pageToken != "" { + params["page_token"] = pageToken + } else { + delete(params, "page_token") + } + result, err := ac.CallAPI(ctx, client.RawApiRequest{ + Method: "GET", + URL: listURL, + Params: copyParams(params), + As: reorderRequest.As, + ExtraOpts: reorderRequest.ExtraOpts, + }) + if err != nil { + return nil, err + } + if err := checkErr(result, reorderRequest.As); err != nil { + return nil, err + } + pageIDs, hasMore, nextToken, err := extractMailRulePage(result) + if err != nil { + return nil, err + } + ids = append(ids, pageIDs...) + if !hasMore { + break + } + if nextToken == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list response has has_more=true but no page_token") + } + pageToken = nextToken + } + return ids, nil +} + +func extractMailRulePage(result interface{}) ([]string, bool, string, error) { + resultMap, ok := result.(map[string]interface{}) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list response must be a JSON object") + } + data, ok := resultMap["data"].(map[string]interface{}) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list response missing data object") + } + arrayField := output.FindArrayField(data) + if _, ok := data["rules"].([]interface{}); ok { + arrayField = "rules" + } + if arrayField == "" { + return nil, false, "", nil + } + items, ok := data[arrayField].([]interface{}) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list field %s must be an array", arrayField) + } + + ids := make([]string, 0, len(items)) + for i, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list item %d must be an object", i) + } + ruleID, ok := mailRuleID(itemMap) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list item %d missing rule_id", i) + } + ids = append(ids, ruleID) + } + + hasMore, _ := data["has_more"].(bool) + pageToken, _ := data["page_token"].(string) + if pageToken == "" { + pageToken, _ = data["next_page_token"].(string) + } + return ids, hasMore, pageToken, nil +} + +func mailRuleID(item map[string]interface{}) (string, bool) { + for _, key := range []string{"rule_id", "ruleId", "id"} { + if id, ok := item[key].(string); ok && strings.TrimSpace(id) != "" { + return id, true + } + } + return "", false +} + +func completeMailRuleIDs(requestedIDs, currentIDs []string) ([]string, error) { + seenRequested := make(map[string]bool, len(requestedIDs)) + for _, id := range requestedIDs { + if strings.TrimSpace(id) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_ids contains an empty rule ID").WithParam("rule_ids") + } + if seenRequested[id] { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rule_id %q in rule_ids", id).WithParam("rule_ids") + } + seenRequested[id] = true + } + + currentSet := make(map[string]bool, len(currentIDs)) + for _, id := range currentIDs { + currentSet[id] = true + } + if len(currentIDs) == 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot reorder rules because current mailbox rule list is empty").WithParam("rule_ids") + } + for _, id := range requestedIDs { + if !currentSet[id] { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "rule_id %q was not found in current mailbox rules", id).WithParam("rule_ids") + } + } + + completed := append([]string(nil), requestedIDs...) + for _, id := range currentIDs { + if !seenRequested[id] { + completed = append(completed, id) + } + } + return completed, nil +} + +func copyParams(in map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func stringSlice(raw interface{}) ([]string, bool) { + switch v := raw.(type) { + case []string: + return append([]string(nil), v...), true + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + default: + return nil, false + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 3cb6ab5d2f..97a7d6a12b 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -430,6 +430,11 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { // with MissingScopes / Identity / ConsoleURL populated from the response. checkErr := ac.CheckResponse + request, err = completeMailRuleReorderRequest(opts.Ctx, ac, opts, request, checkErr) + if err != nil { + return err + } + if opts.PageAll { return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(), client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr) diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 1eb6260b96..b06de17001 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -10,6 +10,7 @@ import ( "errors" "mime" "mime/multipart" + "net/http" "os" "path/filepath" "strings" @@ -998,6 +999,280 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err } } +func TestCompleteMailRuleIDs(t *testing.T) { + tests := []struct { + name string + requested []string + current []string + want []string + wantErr string + }{ + { + name: "partial input is prefix and missing rules append in current order", + requested: []string{"C", "A"}, + current: []string{"A", "B", "C", "D"}, + want: []string{"C", "A", "B", "D"}, + }, + { + name: "complete input preserves user order", + requested: []string{"C", "B", "A"}, + current: []string{"A", "B", "C"}, + want: []string{"C", "B", "A"}, + }, + { + name: "duplicate requested id", + requested: []string{"A", "A"}, + current: []string{"A", "B"}, + wantErr: "duplicate rule_id", + }, + { + name: "unknown requested id", + requested: []string{"A", "X"}, + current: []string{"A", "B"}, + wantErr: "not found", + }, + { + name: "empty current list rejects requested ids", + requested: []string{"A"}, + current: nil, + wantErr: "current mailbox rule list is empty", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := completeMailRuleIDs(tt.requested, tt.current) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("completeMailRuleIDs() error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("completeMailRuleIDs() error = %v", err) + } + if !stringSlicesEqual(got, tt.want) { + t.Fatalf("completeMailRuleIDs() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestServiceMethod_MailRuleReorderCompletesIDs(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules", AppSecret: "test-secret-mail-rules", Brand: core.BrandFeishu, + }) + + reg.Register(mailRuleListStub("", []string{"A", "B", "C", "D"}, false, "")) + reorderStub := mailRuleReorderStub(t, []string{"C", "A", "B", "D"}, map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"ok": true}, + }) + reg.Register(reorderStub) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["C","A"]}`, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + if !strings.Contains(stdout.String(), `"ok": true`) && !strings.Contains(stdout.String(), `"ok":true`) { + t.Fatalf("expected success envelope, got: %s", stdout.String()) + } +} + +func TestServiceMethod_MailRuleReorderCompletesIDsAcrossPages(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-pages", AppSecret: "test-secret-mail-rules-pages", Brand: core.BrandFeishu, + }) + + reg.Register(mailRuleListStub("", []string{"A", "B"}, true, "next-1")) + reg.Register(mailRuleListStub("page_token=next-1", []string{"C", "D"}, false, "")) + reg.Register(mailRuleReorderStub(t, []string{"D", "A", "B", "C"}, map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"ok": true}, + })) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["D","A"]}`, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) +} + +func TestServiceMethod_MailRuleReorderListFailureDoesNotCallReorder(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-list-fail", AppSecret: "test-secret-mail-rules-list-fail", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/rules", + Body: map[string]interface{}{"code": 99991400, "msg": "rate limited"}, + }) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["A"]}`, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected list API error") + } + requireProblem(t, err, errs.CategoryAPI, errs.SubtypeRateLimit, 99991400) +} + +func TestServiceMethod_MailRuleReorderFailurePreservesAPIError(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-reorder-fail", AppSecret: "test-secret-mail-rules-reorder-fail", Brand: core.BrandFeishu, + }) + + reg.Register(mailRuleListStub("", []string{"A", "B"}, false, "")) + reg.Register(mailRuleReorderStub(t, []string{"B", "A"}, map[string]interface{}{ + "code": 230027, + "msg": "user not authorized", + })) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["B"]}`, + }) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected reorder API error") + } + requireProblem(t, err, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 230027) +} + +func TestServiceMethod_MailRuleReorderValidationErrorsBeforeReorder(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-validation", AppSecret: "test-secret-mail-rules-validation", Brand: core.BrandFeishu, + }) + + reg.Register(mailRuleListStub("", []string{"A", "B"}, false, "")) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["A","X"]}`, + }) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), `rule_id "X" was not found`) { + t.Fatalf("expected unknown rule validation error, got: %v", err) + } +} + +func mailRuleSpec() meta.Service { + return meta.ServiceFromMap(map[string]interface{}{ + "name": "mail", + "servicePath": "/open-apis/mail/v1", + }) +} + +func mailRuleReorderMethod() meta.Method { + return meta.FromMap(map[string]interface{}{ + "id": "ReorderUserMailboxRule", + "path": "user_mailboxes/{user_mailbox_id}/rules/reorder", + "httpMethod": "POST", + "risk": "write", + "parameters": map[string]interface{}{ + "user_mailbox_id": map[string]interface{}{ + "type": "string", "location": "path", "required": true, + }, + }, + "requestBody": map[string]interface{}{ + "rule_ids": map[string]interface{}{ + "type": "array", "required": true, + }, + }, + "accessTokens": []interface{}{"tenant"}, + }) +} + +func mailRuleListStub(queryContains string, ids []string, hasMore bool, nextToken string) *httpmock.Stub { + items := make([]interface{}, 0, len(ids)) + for _, id := range ids { + items = append(items, map[string]interface{}{"rule_id": id}) + } + data := map[string]interface{}{ + "items": items, + "has_more": hasMore, + } + if nextToken != "" { + data["page_token"] = nextToken + } + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }, + BodyFilter: nil, + OnMatch: func(req *http.Request) { + if queryContains != "" && !strings.Contains(req.URL.RawQuery, queryContains) { + panic("unexpected mail rule list query: " + req.URL.RawQuery) + } + if queryContains == "" && req.URL.Query().Get("page_token") != "" { + panic("unexpected first mail rule list page token: " + req.URL.RawQuery) + } + }, + } +} + +func mailRuleReorderStub(t *testing.T, wantIDs []string, body map[string]interface{}) *httpmock.Stub { + t.Helper() + return &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/mail/v1/user_mailboxes/me/rules/reorder", + Body: body, + BodyFilter: func(raw []byte) bool { + var got map[string]interface{} + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("reorder body is not JSON: %v\n%s", err, string(raw)) + } + ids, ok := stringSlice(got["rule_ids"]) + if !ok { + t.Fatalf("reorder rule_ids has wrong shape: %#v", got["rule_ids"]) + } + return stringSlicesEqual(ids, wantIDs) + }, + } +} + +func stringSlicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + // ── file upload ── func imImageMethod() meta.Method { From 46eb144f510151a7a50650818858c9852858294b Mon Sep 17 00:00:00 2001 From: yangr-happy <301323675+yangr-happy@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:32:08 +0800 Subject: [PATCH 2/2] fix: address mail rule reorder review Change-Type: ci-fix Co-authored-by: TRAE CLI --- cmd/service/mail_rule_reorder.go | 20 ++-- cmd/service/service.go | 3 + cmd/service/service_test.go | 179 ++++++++++++++++++++++++++++--- 3 files changed, 179 insertions(+), 23 deletions(-) diff --git a/cmd/service/mail_rule_reorder.go b/cmd/service/mail_rule_reorder.go index a4634edba5..d7e4531747 100644 --- a/cmd/service/mail_rule_reorder.go +++ b/cmd/service/mail_rule_reorder.go @@ -77,16 +77,13 @@ func fetchAllMailRuleIDs(ctx context.Context, ac *client.APIClient, reorderReque } params := copyParams(reorderRequest.Params) - delete(params, "page_token") - params["page_size"] = 100 var ids []string var pageToken string + seenPageTokens := map[string]bool{} for { if pageToken != "" { params["page_token"] = pageToken - } else { - delete(params, "page_token") } result, err := ac.CallAPI(ctx, client.RawApiRequest{ Method: "GET", @@ -112,6 +109,10 @@ func fetchAllMailRuleIDs(ctx context.Context, ac *client.APIClient, reorderReque if nextToken == "" { return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list response has has_more=true but no page_token") } + if seenPageTokens[nextToken] { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list response repeated page_token %q", nextToken) + } + seenPageTokens[nextToken] = true pageToken = nextToken } return ids, nil @@ -151,7 +152,14 @@ func extractMailRulePage(result interface{}) ([]string, bool, string, error) { ids = append(ids, ruleID) } - hasMore, _ := data["has_more"].(bool) + hasMore := false + if rawHasMore, exists := data["has_more"]; exists { + b, ok := rawHasMore.(bool) + if !ok { + return nil, false, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "mail rule list field has_more must be a boolean") + } + hasMore = b + } pageToken, _ := data["page_token"].(string) if pageToken == "" { pageToken, _ = data["next_page_token"].(string) @@ -185,7 +193,7 @@ func completeMailRuleIDs(requestedIDs, currentIDs []string) ([]string, error) { currentSet[id] = true } if len(currentIDs) == 0 { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot reorder rules because current mailbox rule list is empty").WithParam("rule_ids") + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot reorder rules because current mailbox rule list is empty") } for _, id := range requestedIDs { if !currentSet[id] { diff --git a/cmd/service/service.go b/cmd/service/service.go index 97a7d6a12b..689e0a8a91 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -402,6 +402,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } if opts.DryRun { + if isMailRuleReorderMethod(opts, request) { + fmt.Fprintln(f.IOStreams.ErrOut, "warning: mail rule reorder expands rule_ids from the current mailbox rule list when executed; dry-run shows the request before that API-backed expansion") + } if fileMeta != nil { return cmdutil.PrintDryRunWithFile(f.IOStreams.Out, request, config, opts.Format, fileMeta.FieldName, fileMeta.FilePath, fileMeta.FormFields) } diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index b06de17001..8e2addd408 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -232,6 +232,27 @@ func TestServiceMethod_DryRun_PathParam(t *testing.T) { } } +func TestServiceMethod_DryRun_MailRuleReorderWarnsAboutExecutionExpansion(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["C","A"]}`, + "--dry-run", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"rule_ids"`) || !strings.Contains(stdout.String(), `"C"`) { + t.Fatalf("expected dry-run request body to remain unchanged, got: %s", stdout.String()) + } + if !strings.Contains(stderr.String(), "expands rule_ids") { + t.Fatalf("expected mail rule reorder dry-run warning, got: %s", stderr.String()) + } +} + func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) { tests := []struct { name string @@ -1001,11 +1022,13 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err func TestCompleteMailRuleIDs(t *testing.T) { tests := []struct { - name string - requested []string - current []string - want []string - wantErr string + name string + requested []string + current []string + want []string + wantErr string + wantSubtype errs.Subtype + wantParam string }{ { name: "partial input is prefix and missing rules append in current order", @@ -1020,22 +1043,27 @@ func TestCompleteMailRuleIDs(t *testing.T) { want: []string{"C", "B", "A"}, }, { - name: "duplicate requested id", - requested: []string{"A", "A"}, - current: []string{"A", "B"}, - wantErr: "duplicate rule_id", + name: "duplicate requested id", + requested: []string{"A", "A"}, + current: []string{"A", "B"}, + wantErr: "duplicate rule_id", + wantSubtype: errs.SubtypeInvalidArgument, + wantParam: "rule_ids", }, { - name: "unknown requested id", - requested: []string{"A", "X"}, - current: []string{"A", "B"}, - wantErr: "not found", + name: "unknown requested id", + requested: []string{"A", "X"}, + current: []string{"A", "B"}, + wantErr: "not found", + wantSubtype: errs.SubtypeInvalidArgument, + wantParam: "rule_ids", }, { - name: "empty current list rejects requested ids", - requested: []string{"A"}, - current: nil, - wantErr: "current mailbox rule list is empty", + name: "empty current list rejects requested ids", + requested: []string{"A"}, + current: nil, + wantErr: "current mailbox rule list is empty", + wantSubtype: errs.SubtypeFailedPrecondition, }, } for _, tt := range tests { @@ -1045,6 +1073,14 @@ func TestCompleteMailRuleIDs(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("completeMailRuleIDs() error = %v, want substring %q", err, tt.wantErr) } + requireProblem(t, err, errs.CategoryValidation, tt.wantSubtype, 0) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if ve.Param != tt.wantParam { + t.Fatalf("ValidationError.Param = %q, want %q", ve.Param, tt.wantParam) + } return } if err != nil { @@ -1086,6 +1122,51 @@ func TestServiceMethod_MailRuleReorderCompletesIDs(t *testing.T) { } } +func TestServiceMethod_MailRuleReorderListKeepsRequestParams(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-params", AppSecret: "test-secret-mail-rules-params", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"rule_id": "A"}, map[string]interface{}{"rule_id": "B"}}, + "has_more": false, + }, + }, + OnMatch: func(req *http.Request) { + q := req.URL.Query() + if q.Get("locale") != "en-US" { + panic("expected original locale query param, got: " + req.URL.RawQuery) + } + if q.Get("page_size") != "" || q.Get("page_token") != "" { + panic("unexpected pagination query params: " + req.URL.RawQuery) + } + }, + }) + reg.Register(mailRuleReorderStub(t, []string{"B", "A"}, map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{"ok": true}, + })) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me","locale":"en-US"}`, + "--data", `{"rule_ids":["B"]}`, + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) +} + func TestServiceMethod_MailRuleReorderCompletesIDsAcrossPages(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app-mail-rules-pages", AppSecret: "test-secret-mail-rules-pages", Brand: core.BrandFeishu, @@ -1112,6 +1193,62 @@ func TestServiceMethod_MailRuleReorderCompletesIDsAcrossPages(t *testing.T) { reg.Verify(t) } +func TestServiceMethod_MailRuleReorderRejectsRepeatedPageToken(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-repeat-token", AppSecret: "test-secret-mail-rules-repeat-token", Brand: core.BrandFeishu, + }) + + reg.Register(mailRuleListStub("", []string{"A"}, true, "next-1")) + reg.Register(mailRuleListStub("page_token=next-1", []string{"B"}, true, "next-1")) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["A"]}`, + }) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "repeated page_token") { + t.Fatalf("expected repeated page_token error, got: %v", err) + } + requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, 0) + reg.Verify(t) +} + +func TestServiceMethod_MailRuleReorderRejectsMalformedHasMore(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-mail-rules-bad-has-more", AppSecret: "test-secret-mail-rules-bad-has-more", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/mail/v1/user_mailboxes/me/rules", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"rule_id": "A"}}, + "has_more": "false", + }, + }, + }) + + cmd := NewCmdServiceMethod(f, mailRuleSpec(), mailRuleReorderMethod(), "reorder", "user_mailbox.rules", nil) + cmd.SetArgs([]string{ + "--as", "bot", + "--params", `{"user_mailbox_id":"me"}`, + "--data", `{"rule_ids":["A"]}`, + }) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "has_more must be a boolean") { + t.Fatalf("expected malformed has_more error, got: %v", err) + } + requireProblem(t, err, errs.CategoryInternal, errs.SubtypeInvalidResponse, 0) + reg.Verify(t) +} + func TestServiceMethod_MailRuleReorderListFailureDoesNotCallReorder(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app-mail-rules-list-fail", AppSecret: "test-secret-mail-rules-list-fail", Brand: core.BrandFeishu, @@ -1180,6 +1317,14 @@ func TestServiceMethod_MailRuleReorderValidationErrorsBeforeReorder(t *testing.T if err == nil || !strings.Contains(err.Error(), `rule_id "X" was not found`) { t.Fatalf("expected unknown rule validation error, got: %v", err) } + requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if ve.Param != "rule_ids" { + t.Fatalf("ValidationError.Param = %q, want rule_ids", ve.Param) + } } func mailRuleSpec() meta.Service {